@qodo/sdk 0.13.4 → 2.0.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +31 -118
- package/README.md +133 -121
- package/bin/qodo-skills.mjs +13 -0
- package/bundled-skills/code-review/SKILL.md +41 -0
- package/bundled-skills/pr-summary/SKILL.md +59 -0
- package/bundled-skills/test-gen/SKILL.md +47 -0
- package/dist/auth/index.browser.d.ts +38 -0
- package/dist/auth/index.browser.d.ts.map +1 -0
- package/dist/auth/index.browser.js +62 -0
- package/dist/auth/index.browser.js.map +1 -0
- package/dist/auth/index.d.ts +44 -30
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +57 -110
- package/dist/auth/index.js.map +1 -1
- package/dist/client/AgentsClient.d.ts +33 -0
- package/dist/client/AgentsClient.d.ts.map +1 -0
- package/dist/client/AgentsClient.js +40 -0
- package/dist/client/AgentsClient.js.map +1 -0
- package/dist/client/ArtifactsClient.d.ts +43 -0
- package/dist/client/ArtifactsClient.d.ts.map +1 -0
- package/dist/client/ArtifactsClient.js +54 -0
- package/dist/client/ArtifactsClient.js.map +1 -0
- package/dist/client/BulletinClient.d.ts +45 -0
- package/dist/client/BulletinClient.d.ts.map +1 -0
- package/dist/client/BulletinClient.js +51 -0
- package/dist/client/BulletinClient.js.map +1 -0
- package/dist/client/InfoClient.d.ts +58 -0
- package/dist/client/InfoClient.d.ts.map +1 -0
- package/dist/client/InfoClient.js +135 -0
- package/dist/client/InfoClient.js.map +1 -0
- package/dist/client/PipelineClient.d.ts +162 -0
- package/dist/client/PipelineClient.d.ts.map +1 -0
- package/dist/client/PipelineClient.js +340 -0
- package/dist/client/PipelineClient.js.map +1 -0
- package/dist/client/QarRegistryClient.d.ts +396 -0
- package/dist/client/QarRegistryClient.d.ts.map +1 -0
- package/dist/client/QarRegistryClient.js +536 -0
- package/dist/client/QarRegistryClient.js.map +1 -0
- package/dist/client/QodoClient.d.ts +296 -0
- package/dist/client/QodoClient.d.ts.map +1 -0
- package/dist/client/QodoClient.js +803 -0
- package/dist/client/QodoClient.js.map +1 -0
- package/dist/client/SpecsClient.d.ts +121 -0
- package/dist/client/SpecsClient.d.ts.map +1 -0
- package/dist/client/SpecsClient.js +252 -0
- package/dist/client/SpecsClient.js.map +1 -0
- package/dist/client/StateClient.d.ts +35 -0
- package/dist/client/StateClient.d.ts.map +1 -0
- package/dist/client/StateClient.js +36 -0
- package/dist/client/StateClient.js.map +1 -0
- package/dist/client/TaskClient.d.ts +706 -0
- package/dist/client/TaskClient.d.ts.map +1 -0
- package/dist/client/TaskClient.js +2522 -0
- package/dist/client/TaskClient.js.map +1 -0
- package/dist/client/ToolClient.d.ts +278 -0
- package/dist/client/ToolClient.d.ts.map +1 -0
- package/dist/client/ToolClient.js +1139 -0
- package/dist/client/ToolClient.js.map +1 -0
- package/dist/client/a2a/index.d.ts +10 -0
- package/dist/client/a2a/index.d.ts.map +1 -0
- package/dist/client/a2a/index.js +9 -0
- package/dist/client/a2a/index.js.map +1 -0
- package/dist/client/a2a/registerA2A.d.ts +170 -0
- package/dist/client/a2a/registerA2A.d.ts.map +1 -0
- package/dist/client/a2a/registerA2A.js +85 -0
- package/dist/client/a2a/registerA2A.js.map +1 -0
- package/dist/client/connection.d.ts +893 -0
- package/dist/client/connection.d.ts.map +1 -0
- package/dist/client/connection.js +2189 -0
- package/dist/client/connection.js.map +1 -0
- package/dist/client/errors.d.ts +735 -0
- package/dist/client/errors.d.ts.map +1 -0
- package/dist/client/errors.js +921 -0
- package/dist/client/errors.js.map +1 -0
- package/dist/client/index.d.ts +26 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +20 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/inlineGraph.d.ts +66 -0
- package/dist/client/inlineGraph.d.ts.map +1 -0
- package/dist/client/inlineGraph.js +500 -0
- package/dist/client/inlineGraph.js.map +1 -0
- package/dist/client/internal/thenable.d.ts +27 -0
- package/dist/client/internal/thenable.d.ts.map +1 -0
- package/dist/client/internal/thenable.js +31 -0
- package/dist/client/internal/thenable.js.map +1 -0
- package/dist/client/iterator.d.ts +32 -0
- package/dist/client/iterator.d.ts.map +1 -0
- package/dist/client/iterator.js +73 -0
- package/dist/client/iterator.js.map +1 -0
- package/dist/client/mcp/McpClientPool.browser.d.ts +76 -0
- package/dist/client/mcp/McpClientPool.browser.d.ts.map +1 -0
- package/dist/client/mcp/McpClientPool.browser.js +78 -0
- package/dist/client/mcp/McpClientPool.browser.js.map +1 -0
- package/dist/client/mcp/McpClientPool.d.ts +236 -0
- package/dist/client/mcp/McpClientPool.d.ts.map +1 -0
- package/dist/client/mcp/McpClientPool.js +585 -0
- package/dist/client/mcp/McpClientPool.js.map +1 -0
- package/dist/client/mcp/projection.d.ts +109 -0
- package/dist/client/mcp/projection.d.ts.map +1 -0
- package/dist/client/mcp/projection.js +446 -0
- package/dist/client/mcp/projection.js.map +1 -0
- package/dist/client/mcp/substituteEnv.browser.d.ts +18 -0
- package/dist/client/mcp/substituteEnv.browser.d.ts.map +1 -0
- package/dist/client/mcp/substituteEnv.browser.js +20 -0
- package/dist/client/mcp/substituteEnv.browser.js.map +1 -0
- package/dist/client/mcp/substituteEnv.d.ts +45 -0
- package/dist/client/mcp/substituteEnv.d.ts.map +1 -0
- package/dist/client/mcp/substituteEnv.js +63 -0
- package/dist/client/mcp/substituteEnv.js.map +1 -0
- package/dist/client/observers.d.ts +57 -0
- package/dist/client/observers.d.ts.map +1 -0
- package/dist/client/observers.js +203 -0
- package/dist/client/observers.js.map +1 -0
- package/dist/client/options.d.ts +269 -0
- package/dist/client/options.d.ts.map +1 -0
- package/dist/client/options.js +9 -0
- package/dist/client/options.js.map +1 -0
- package/dist/client/tools/_readlineApprovalPrompt.browser.d.ts +17 -0
- package/dist/client/tools/_readlineApprovalPrompt.browser.d.ts.map +1 -0
- package/dist/client/tools/_readlineApprovalPrompt.browser.js +24 -0
- package/dist/client/tools/_readlineApprovalPrompt.browser.js.map +1 -0
- package/dist/client/tools/_readlineApprovalPrompt.d.ts +33 -0
- package/dist/client/tools/_readlineApprovalPrompt.d.ts.map +1 -0
- package/dist/client/tools/_readlineApprovalPrompt.js +90 -0
- package/dist/client/tools/_readlineApprovalPrompt.js.map +1 -0
- package/dist/client/tools/approval.d.ts +280 -0
- package/dist/client/tools/approval.d.ts.map +1 -0
- package/dist/client/tools/approval.js +229 -0
- package/dist/client/tools/approval.js.map +1 -0
- package/dist/client/tools/bindFunctionToolDefs.d.ts +156 -0
- package/dist/client/tools/bindFunctionToolDefs.d.ts.map +1 -0
- package/dist/client/tools/bindFunctionToolDefs.js +360 -0
- package/dist/client/tools/bindFunctionToolDefs.js.map +1 -0
- package/dist/client/tools/defineFunctionTool.d.ts +277 -0
- package/dist/client/tools/defineFunctionTool.d.ts.map +1 -0
- package/dist/client/tools/defineFunctionTool.js +190 -0
- package/dist/client/tools/defineFunctionTool.js.map +1 -0
- package/dist/client/transport.browser.d.ts +20 -0
- package/dist/client/transport.browser.d.ts.map +1 -0
- package/dist/client/transport.browser.js +29 -0
- package/dist/client/transport.browser.js.map +1 -0
- package/dist/client/transport.d.ts +47 -0
- package/dist/client/transport.d.ts.map +1 -0
- package/dist/client/transport.js +102 -0
- package/dist/client/transport.js.map +1 -0
- package/dist/client/transport.shared.d.ts +30 -0
- package/dist/client/transport.shared.d.ts.map +1 -0
- package/dist/client/transport.shared.js +40 -0
- package/dist/client/transport.shared.js.map +1 -0
- package/dist/client/uuid.d.ts +32 -0
- package/dist/client/uuid.d.ts.map +1 -0
- package/dist/client/uuid.js +65 -0
- package/dist/client/uuid.js.map +1 -0
- package/dist/index.d.ts +88 -39
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +166 -43
- package/dist/index.js.map +1 -1
- package/dist/observability/attributes.d.ts +136 -0
- package/dist/observability/attributes.d.ts.map +1 -0
- package/dist/observability/attributes.js +184 -0
- package/dist/observability/attributes.js.map +1 -0
- package/dist/observability/index.d.ts +14 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +11 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/resolveOTel.browser.d.ts +13 -0
- package/dist/observability/resolveOTel.browser.d.ts.map +1 -0
- package/dist/observability/resolveOTel.browser.js +14 -0
- package/dist/observability/resolveOTel.browser.js.map +1 -0
- package/dist/observability/resolveOTel.d.ts +28 -0
- package/dist/observability/resolveOTel.d.ts.map +1 -0
- package/dist/observability/resolveOTel.js +74 -0
- package/dist/observability/resolveOTel.js.map +1 -0
- package/dist/observability/spans.d.ts +198 -0
- package/dist/observability/spans.d.ts.map +1 -0
- package/dist/observability/spans.js +300 -0
- package/dist/observability/spans.js.map +1 -0
- package/dist/observability/traceContext.d.ts +51 -0
- package/dist/observability/traceContext.d.ts.map +1 -0
- package/dist/observability/traceContext.js +151 -0
- package/dist/observability/traceContext.js.map +1 -0
- package/dist/observability/transportMetrics.d.ts +58 -0
- package/dist/observability/transportMetrics.d.ts.map +1 -0
- package/dist/observability/transportMetrics.js +55 -0
- package/dist/observability/transportMetrics.js.map +1 -0
- package/dist/qar/agentSpec.d.ts +93 -0
- package/dist/qar/agentSpec.d.ts.map +1 -0
- package/dist/qar/agentSpec.js +184 -0
- package/dist/qar/agentSpec.js.map +1 -0
- package/dist/qar/clientEvents.d.ts +86 -0
- package/dist/qar/clientEvents.d.ts.map +1 -0
- package/dist/qar/clientEvents.js +36 -0
- package/dist/qar/clientEvents.js.map +1 -0
- package/dist/qar/envelopes.d.ts +227 -0
- package/dist/qar/envelopes.d.ts.map +1 -0
- package/dist/qar/envelopes.js +67 -0
- package/dist/qar/envelopes.js.map +1 -0
- package/dist/qar/generated/envelope.d.ts +332 -0
- package/dist/qar/generated/envelope.d.ts.map +1 -0
- package/dist/qar/generated/envelope.js +15 -0
- package/dist/qar/generated/envelope.js.map +1 -0
- package/dist/qar/generated/qar-info.d.ts +76 -0
- package/dist/qar/generated/qar-info.d.ts.map +1 -0
- package/dist/qar/generated/qar-info.js +15 -0
- package/dist/qar/generated/qar-info.js.map +1 -0
- package/dist/qar/generated/qodo-task-start-payload.d.ts +54 -0
- package/dist/qar/generated/qodo-task-start-payload.d.ts.map +1 -0
- package/dist/qar/generated/qodo-task-start-payload.js +15 -0
- package/dist/qar/generated/qodo-task-start-payload.js.map +1 -0
- package/dist/qar/ids.d.ts +19 -0
- package/dist/qar/ids.d.ts.map +1 -0
- package/dist/qar/ids.js +11 -0
- package/dist/qar/ids.js.map +1 -0
- package/dist/qar/index.d.ts +24 -0
- package/dist/qar/index.d.ts.map +1 -0
- package/dist/qar/index.js +16 -0
- package/dist/qar/index.js.map +1 -0
- package/dist/qar/info.d.ts +37 -0
- package/dist/qar/info.d.ts.map +1 -0
- package/dist/qar/info.js +17 -0
- package/dist/qar/info.js.map +1 -0
- package/dist/qar/json.d.ts +14 -0
- package/dist/qar/json.d.ts.map +1 -0
- package/dist/qar/json.js +9 -0
- package/dist/qar/json.js.map +1 -0
- package/dist/qar/payloads.d.ts +480 -0
- package/dist/qar/payloads.d.ts.map +1 -0
- package/dist/qar/payloads.js +37 -0
- package/dist/qar/payloads.js.map +1 -0
- package/dist/qar/specs.d.ts +604 -0
- package/dist/qar/specs.d.ts.map +1 -0
- package/dist/qar/specs.js +29 -0
- package/dist/qar/specs.js.map +1 -0
- package/dist/qar/taskEvents.d.ts +25 -0
- package/dist/qar/taskEvents.d.ts.map +1 -0
- package/dist/qar/taskEvents.js +22 -0
- package/dist/qar/taskEvents.js.map +1 -0
- package/dist/qar/trace.d.ts +12 -0
- package/dist/qar/trace.d.ts.map +1 -0
- package/dist/qar/trace.js +12 -0
- package/dist/qar/trace.js.map +1 -0
- package/dist/skills/activation.d.ts +177 -0
- package/dist/skills/activation.d.ts.map +1 -0
- package/dist/skills/activation.js +428 -0
- package/dist/skills/activation.js.map +1 -0
- package/dist/skills/cli/index.browser.d.ts +18 -0
- package/dist/skills/cli/index.browser.d.ts.map +1 -0
- package/dist/skills/cli/index.browser.js +27 -0
- package/dist/skills/cli/index.browser.js.map +1 -0
- package/dist/skills/cli/index.d.ts +37 -0
- package/dist/skills/cli/index.d.ts.map +1 -0
- package/dist/skills/cli/index.js +494 -0
- package/dist/skills/cli/index.js.map +1 -0
- package/dist/skills/events.d.ts +255 -0
- package/dist/skills/events.d.ts.map +1 -0
- package/dist/skills/events.js +224 -0
- package/dist/skills/events.js.map +1 -0
- package/dist/skills/index.d.ts +45 -0
- package/dist/skills/index.d.ts.map +1 -0
- package/dist/skills/index.js +34 -0
- package/dist/skills/index.js.map +1 -0
- package/dist/skills/inject.d.ts +57 -0
- package/dist/skills/inject.d.ts.map +1 -0
- package/dist/skills/inject.js +162 -0
- package/dist/skills/inject.js.map +1 -0
- package/dist/skills/lockfile.browser.d.ts +56 -0
- package/dist/skills/lockfile.browser.d.ts.map +1 -0
- package/dist/skills/lockfile.browser.js +55 -0
- package/dist/skills/lockfile.browser.js.map +1 -0
- package/dist/skills/lockfile.d.ts +137 -0
- package/dist/skills/lockfile.d.ts.map +1 -0
- package/dist/skills/lockfile.js +423 -0
- package/dist/skills/lockfile.js.map +1 -0
- package/dist/skills/manager.browser.d.ts +94 -0
- package/dist/skills/manager.browser.d.ts.map +1 -0
- package/dist/skills/manager.browser.js +159 -0
- package/dist/skills/manager.browser.js.map +1 -0
- package/dist/skills/manager.d.ts +362 -0
- package/dist/skills/manager.d.ts.map +1 -0
- package/dist/skills/manager.js +1386 -0
- package/dist/skills/manager.js.map +1 -0
- package/dist/skills/mcp/index.d.ts +15 -0
- package/dist/skills/mcp/index.d.ts.map +1 -0
- package/dist/skills/mcp/index.js +12 -0
- package/dist/skills/mcp/index.js.map +1 -0
- package/dist/skills/mcp/path.browser.d.ts +27 -0
- package/dist/skills/mcp/path.browser.d.ts.map +1 -0
- package/dist/skills/mcp/path.browser.js +33 -0
- package/dist/skills/mcp/path.browser.js.map +1 -0
- package/dist/skills/mcp/path.d.ts +57 -0
- package/dist/skills/mcp/path.d.ts.map +1 -0
- package/dist/skills/mcp/path.js +150 -0
- package/dist/skills/mcp/path.js.map +1 -0
- package/dist/skills/mcp/server.browser.d.ts +32 -0
- package/dist/skills/mcp/server.browser.d.ts.map +1 -0
- package/dist/skills/mcp/server.browser.js +53 -0
- package/dist/skills/mcp/server.browser.js.map +1 -0
- package/dist/skills/mcp/server.d.ts +144 -0
- package/dist/skills/mcp/server.d.ts.map +1 -0
- package/dist/skills/mcp/server.js +841 -0
- package/dist/skills/mcp/server.js.map +1 -0
- package/dist/skills/mcp/types.d.ts +72 -0
- package/dist/skills/mcp/types.d.ts.map +1 -0
- package/dist/skills/mcp/types.js +20 -0
- package/dist/skills/mcp/types.js.map +1 -0
- package/dist/skills/mcp/wireDefs.d.ts +58 -0
- package/dist/skills/mcp/wireDefs.d.ts.map +1 -0
- package/dist/skills/mcp/wireDefs.js +141 -0
- package/dist/skills/mcp/wireDefs.js.map +1 -0
- package/dist/skills/parser.d.ts +63 -0
- package/dist/skills/parser.d.ts.map +1 -0
- package/dist/skills/parser.js +755 -0
- package/dist/skills/parser.js.map +1 -0
- package/dist/skills/prefilter.d.ts +104 -0
- package/dist/skills/prefilter.d.ts.map +1 -0
- package/dist/skills/prefilter.js +398 -0
- package/dist/skills/prefilter.js.map +1 -0
- package/dist/skills/preprocess.d.ts +169 -0
- package/dist/skills/preprocess.d.ts.map +1 -0
- package/dist/skills/preprocess.js +535 -0
- package/dist/skills/preprocess.js.map +1 -0
- package/dist/skills/render.d.ts +83 -0
- package/dist/skills/render.d.ts.map +1 -0
- package/dist/skills/render.js +397 -0
- package/dist/skills/render.js.map +1 -0
- package/dist/skills/sources/index.browser.d.ts +29 -0
- package/dist/skills/sources/index.browser.d.ts.map +1 -0
- package/dist/skills/sources/index.browser.js +16 -0
- package/dist/skills/sources/index.browser.js.map +1 -0
- package/dist/skills/sources/index.d.ts +59 -0
- package/dist/skills/sources/index.d.ts.map +1 -0
- package/dist/skills/sources/index.js +471 -0
- package/dist/skills/sources/index.js.map +1 -0
- package/dist/skills/sources/walk.browser.d.ts +17 -0
- package/dist/skills/sources/walk.browser.d.ts.map +1 -0
- package/dist/skills/sources/walk.browser.js +19 -0
- package/dist/skills/sources/walk.browser.js.map +1 -0
- package/dist/skills/sources/walk.d.ts +68 -0
- package/dist/skills/sources/walk.d.ts.map +1 -0
- package/dist/skills/sources/walk.js +264 -0
- package/dist/skills/sources/walk.js.map +1 -0
- package/dist/skills/substitute.d.ts +87 -0
- package/dist/skills/substitute.d.ts.map +1 -0
- package/dist/skills/substitute.js +322 -0
- package/dist/skills/substitute.js.map +1 -0
- package/dist/skills/testing/SkillKit.browser.d.ts +62 -0
- package/dist/skills/testing/SkillKit.browser.d.ts.map +1 -0
- package/dist/skills/testing/SkillKit.browser.js +41 -0
- package/dist/skills/testing/SkillKit.browser.js.map +1 -0
- package/dist/skills/testing/SkillKit.d.ts +130 -0
- package/dist/skills/testing/SkillKit.d.ts.map +1 -0
- package/dist/skills/testing/SkillKit.js +316 -0
- package/dist/skills/testing/SkillKit.js.map +1 -0
- package/dist/skills/testing/index.d.ts +9 -0
- package/dist/skills/testing/index.d.ts.map +1 -0
- package/dist/skills/testing/index.js +8 -0
- package/dist/skills/testing/index.js.map +1 -0
- package/dist/skills/trust.d.ts +72 -0
- package/dist/skills/trust.d.ts.map +1 -0
- package/dist/skills/trust.js +183 -0
- package/dist/skills/trust.js.map +1 -0
- package/dist/skills/types.d.ts +627 -0
- package/dist/skills/types.d.ts.map +1 -0
- package/dist/skills/types.js +85 -0
- package/dist/skills/types.js.map +1 -0
- package/dist/skills/validator.d.ts +95 -0
- package/dist/skills/validator.d.ts.map +1 -0
- package/dist/skills/validator.js +486 -0
- package/dist/skills/validator.js.map +1 -0
- package/dist/tracing/PipelineTracer.d.ts +35 -22
- package/dist/tracing/PipelineTracer.d.ts.map +1 -1
- package/dist/tracing/PipelineTracer.js +106 -61
- package/dist/tracing/PipelineTracer.js.map +1 -1
- package/dist/tracing/SdkTracer.d.ts +63 -61
- package/dist/tracing/SdkTracer.d.ts.map +1 -1
- package/dist/tracing/SdkTracer.js +185 -177
- package/dist/tracing/SdkTracer.js.map +1 -1
- package/dist/tracing/index.d.ts +10 -1
- package/dist/tracing/index.d.ts.map +1 -1
- package/dist/tracing/index.js +9 -0
- package/dist/tracing/index.js.map +1 -1
- package/dist/tracing/types.d.ts +89 -16
- package/dist/tracing/types.d.ts.map +1 -1
- package/dist/tracing/types.js +17 -4
- package/dist/tracing/types.js.map +1 -1
- package/dist/types.d.ts +6 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +10 -20
- package/dist/version.js.map +1 -1
- package/package.json +53 -39
- package/.claude/skills/qodo-agent/SKILL.md +0 -974
- package/.claude/skills/qodo-agent/assets/programmatic-agent.ts +0 -407
- package/.claude/skills/qodo-agent/references/builtin-tools.md +0 -342
- package/.claude/skills/qodo-agent/references/common-issues.md +0 -537
- package/bin/rg +0 -0
- package/dist/api/agent.d.ts +0 -105
- package/dist/api/agent.d.ts.map +0 -1
- package/dist/api/agent.js +0 -963
- package/dist/api/agent.js.map +0 -1
- package/dist/api/analytics.d.ts +0 -43
- package/dist/api/analytics.d.ts.map +0 -1
- package/dist/api/analytics.js +0 -163
- package/dist/api/analytics.js.map +0 -1
- package/dist/api/http.d.ts +0 -5
- package/dist/api/http.d.ts.map +0 -1
- package/dist/api/http.js +0 -62
- package/dist/api/http.js.map +0 -1
- package/dist/api/index.d.ts +0 -12
- package/dist/api/index.d.ts.map +0 -1
- package/dist/api/index.js +0 -17
- package/dist/api/index.js.map +0 -1
- package/dist/api/taskTracking.d.ts +0 -54
- package/dist/api/taskTracking.d.ts.map +0 -1
- package/dist/api/taskTracking.js +0 -208
- package/dist/api/taskTracking.js.map +0 -1
- package/dist/api/types.d.ts +0 -93
- package/dist/api/types.d.ts.map +0 -1
- package/dist/api/types.js +0 -2
- package/dist/api/types.js.map +0 -1
- package/dist/api/utils.d.ts +0 -8
- package/dist/api/utils.d.ts.map +0 -1
- package/dist/api/utils.js +0 -63
- package/dist/api/utils.js.map +0 -1
- package/dist/api/websocket.d.ts +0 -203
- package/dist/api/websocket.d.ts.map +0 -1
- package/dist/api/websocket.js +0 -1166
- package/dist/api/websocket.js.map +0 -1
- package/dist/bin/install-skill.d.ts +0 -14
- package/dist/bin/install-skill.d.ts.map +0 -1
- package/dist/bin/install-skill.js +0 -125
- package/dist/bin/install-skill.js.map +0 -1
- package/dist/bin/run-helpers.d.ts +0 -34
- package/dist/bin/run-helpers.d.ts.map +0 -1
- package/dist/bin/run-helpers.js +0 -186
- package/dist/bin/run-helpers.js.map +0 -1
- package/dist/bin/run.d.ts +0 -13
- package/dist/bin/run.d.ts.map +0 -1
- package/dist/bin/run.js +0 -57
- package/dist/bin/run.js.map +0 -1
- package/dist/clients/index.d.ts +0 -10
- package/dist/clients/index.d.ts.map +0 -1
- package/dist/clients/index.js +0 -8
- package/dist/clients/index.js.map +0 -1
- package/dist/clients/info/InfoClient.d.ts +0 -37
- package/dist/clients/info/InfoClient.d.ts.map +0 -1
- package/dist/clients/info/InfoClient.js +0 -69
- package/dist/clients/info/InfoClient.js.map +0 -1
- package/dist/clients/info/index.d.ts +0 -4
- package/dist/clients/info/index.d.ts.map +0 -1
- package/dist/clients/info/index.js +0 -2
- package/dist/clients/info/index.js.map +0 -1
- package/dist/clients/info/types.d.ts +0 -21
- package/dist/clients/info/types.d.ts.map +0 -1
- package/dist/clients/info/types.js +0 -2
- package/dist/clients/info/types.js.map +0 -1
- package/dist/clients/sessions/SessionsClient.d.ts +0 -34
- package/dist/clients/sessions/SessionsClient.d.ts.map +0 -1
- package/dist/clients/sessions/SessionsClient.js +0 -71
- package/dist/clients/sessions/SessionsClient.js.map +0 -1
- package/dist/clients/sessions/index.d.ts +0 -4
- package/dist/clients/sessions/index.d.ts.map +0 -1
- package/dist/clients/sessions/index.js +0 -2
- package/dist/clients/sessions/index.js.map +0 -1
- package/dist/clients/sessions/types.d.ts +0 -20
- package/dist/clients/sessions/types.d.ts.map +0 -1
- package/dist/clients/sessions/types.js +0 -2
- package/dist/clients/sessions/types.js.map +0 -1
- package/dist/clients/tools/ToolsClient.d.ts +0 -39
- package/dist/clients/tools/ToolsClient.d.ts.map +0 -1
- package/dist/clients/tools/ToolsClient.js +0 -95
- package/dist/clients/tools/ToolsClient.js.map +0 -1
- package/dist/clients/tools/index.d.ts +0 -4
- package/dist/clients/tools/index.d.ts.map +0 -1
- package/dist/clients/tools/index.js +0 -2
- package/dist/clients/tools/index.js.map +0 -1
- package/dist/clients/tools/types.d.ts +0 -14
- package/dist/clients/tools/types.d.ts.map +0 -1
- package/dist/clients/tools/types.js +0 -2
- package/dist/clients/tools/types.js.map +0 -1
- package/dist/config/ConfigManager.d.ts +0 -43
- package/dist/config/ConfigManager.d.ts.map +0 -1
- package/dist/config/ConfigManager.js +0 -472
- package/dist/config/ConfigManager.js.map +0 -1
- package/dist/config/index.d.ts +0 -6
- package/dist/config/index.d.ts.map +0 -1
- package/dist/config/index.js +0 -7
- package/dist/config/index.js.map +0 -1
- package/dist/config/urlConfig.d.ts +0 -15
- package/dist/config/urlConfig.d.ts.map +0 -1
- package/dist/config/urlConfig.js +0 -75
- package/dist/config/urlConfig.js.map +0 -1
- package/dist/constants/errors.d.ts +0 -2
- package/dist/constants/errors.d.ts.map +0 -1
- package/dist/constants/errors.js +0 -2
- package/dist/constants/errors.js.map +0 -1
- package/dist/constants/index.d.ts +0 -7
- package/dist/constants/index.d.ts.map +0 -1
- package/dist/constants/index.js +0 -11
- package/dist/constants/index.js.map +0 -1
- package/dist/constants/tools.d.ts +0 -4
- package/dist/constants/tools.d.ts.map +0 -1
- package/dist/constants/tools.js +0 -4
- package/dist/constants/tools.js.map +0 -1
- package/dist/constants/versions.d.ts +0 -2
- package/dist/constants/versions.d.ts.map +0 -1
- package/dist/constants/versions.js +0 -2
- package/dist/constants/versions.js.map +0 -1
- package/dist/context/buildUserContext.d.ts +0 -18
- package/dist/context/buildUserContext.d.ts.map +0 -1
- package/dist/context/buildUserContext.js +0 -34
- package/dist/context/buildUserContext.js.map +0 -1
- package/dist/context/index.d.ts +0 -9
- package/dist/context/index.d.ts.map +0 -1
- package/dist/context/index.js +0 -9
- package/dist/context/index.js.map +0 -1
- package/dist/context/messageManager.d.ts +0 -42
- package/dist/context/messageManager.d.ts.map +0 -1
- package/dist/context/messageManager.js +0 -322
- package/dist/context/messageManager.js.map +0 -1
- package/dist/context/taskFocus.d.ts +0 -2
- package/dist/context/taskFocus.d.ts.map +0 -1
- package/dist/context/taskFocus.js +0 -26
- package/dist/context/taskFocus.js.map +0 -1
- package/dist/context/userInput.d.ts +0 -3
- package/dist/context/userInput.d.ts.map +0 -1
- package/dist/context/userInput.js +0 -20
- package/dist/context/userInput.js.map +0 -1
- package/dist/mcp/MCPManager.d.ts +0 -109
- package/dist/mcp/MCPManager.d.ts.map +0 -1
- package/dist/mcp/MCPManager.js +0 -592
- package/dist/mcp/MCPManager.js.map +0 -1
- package/dist/mcp/approvedTools.d.ts +0 -4
- package/dist/mcp/approvedTools.d.ts.map +0 -1
- package/dist/mcp/approvedTools.js +0 -19
- package/dist/mcp/approvedTools.js.map +0 -1
- package/dist/mcp/baseServer.d.ts +0 -75
- package/dist/mcp/baseServer.d.ts.map +0 -1
- package/dist/mcp/baseServer.js +0 -107
- package/dist/mcp/baseServer.js.map +0 -1
- package/dist/mcp/builtinServers.d.ts +0 -15
- package/dist/mcp/builtinServers.d.ts.map +0 -1
- package/dist/mcp/builtinServers.js +0 -141
- package/dist/mcp/builtinServers.js.map +0 -1
- package/dist/mcp/dynamicBEServer.d.ts +0 -20
- package/dist/mcp/dynamicBEServer.d.ts.map +0 -1
- package/dist/mcp/dynamicBEServer.js +0 -52
- package/dist/mcp/dynamicBEServer.js.map +0 -1
- package/dist/mcp/index.d.ts +0 -18
- package/dist/mcp/index.d.ts.map +0 -1
- package/dist/mcp/index.js +0 -23
- package/dist/mcp/index.js.map +0 -1
- package/dist/mcp/mcpInitialization.d.ts +0 -2
- package/dist/mcp/mcpInitialization.d.ts.map +0 -1
- package/dist/mcp/mcpInitialization.js +0 -56
- package/dist/mcp/mcpInitialization.js.map +0 -1
- package/dist/mcp/servers/filesystem.d.ts +0 -44
- package/dist/mcp/servers/filesystem.d.ts.map +0 -1
- package/dist/mcp/servers/filesystem.js +0 -776
- package/dist/mcp/servers/filesystem.js.map +0 -1
- package/dist/mcp/servers/git.d.ts +0 -18
- package/dist/mcp/servers/git.d.ts.map +0 -1
- package/dist/mcp/servers/git.js +0 -441
- package/dist/mcp/servers/git.js.map +0 -1
- package/dist/mcp/servers/ripgrep.d.ts +0 -39
- package/dist/mcp/servers/ripgrep.d.ts.map +0 -1
- package/dist/mcp/servers/ripgrep.js +0 -550
- package/dist/mcp/servers/ripgrep.js.map +0 -1
- package/dist/mcp/servers/shell.d.ts +0 -20
- package/dist/mcp/servers/shell.d.ts.map +0 -1
- package/dist/mcp/servers/shell.js +0 -519
- package/dist/mcp/servers/shell.js.map +0 -1
- package/dist/mcp/serversRegistry.d.ts +0 -55
- package/dist/mcp/serversRegistry.d.ts.map +0 -1
- package/dist/mcp/serversRegistry.js +0 -416
- package/dist/mcp/serversRegistry.js.map +0 -1
- package/dist/mcp/toolProcessor.d.ts +0 -82
- package/dist/mcp/toolProcessor.d.ts.map +0 -1
- package/dist/mcp/toolProcessor.js +0 -392
- package/dist/mcp/toolProcessor.js.map +0 -1
- package/dist/mcp/types.d.ts +0 -29
- package/dist/mcp/types.d.ts.map +0 -1
- package/dist/mcp/types.js +0 -2
- package/dist/mcp/types.js.map +0 -1
- package/dist/messages/index.d.ts +0 -8
- package/dist/messages/index.d.ts.map +0 -1
- package/dist/messages/index.js +0 -7
- package/dist/messages/index.js.map +0 -1
- package/dist/messages/openai.d.ts +0 -26
- package/dist/messages/openai.d.ts.map +0 -1
- package/dist/messages/openai.js +0 -55
- package/dist/messages/openai.js.map +0 -1
- package/dist/messages/types.d.ts +0 -73
- package/dist/messages/types.d.ts.map +0 -1
- package/dist/messages/types.js +0 -78
- package/dist/messages/types.js.map +0 -1
- package/dist/parser/index.d.ts +0 -72
- package/dist/parser/index.d.ts.map +0 -1
- package/dist/parser/index.js +0 -967
- package/dist/parser/index.js.map +0 -1
- package/dist/parser/types.d.ts +0 -153
- package/dist/parser/types.d.ts.map +0 -1
- package/dist/parser/types.js +0 -6
- package/dist/parser/types.js.map +0 -1
- package/dist/parser/utils.d.ts +0 -18
- package/dist/parser/utils.d.ts.map +0 -1
- package/dist/parser/utils.js +0 -64
- package/dist/parser/utils.js.map +0 -1
- package/dist/sdk/QodoSDK.d.ts +0 -218
- package/dist/sdk/QodoSDK.d.ts.map +0 -1
- package/dist/sdk/QodoSDK.js +0 -1115
- package/dist/sdk/QodoSDK.js.map +0 -1
- package/dist/sdk/artifacts.d.ts +0 -156
- package/dist/sdk/artifacts.d.ts.map +0 -1
- package/dist/sdk/artifacts.js +0 -166
- package/dist/sdk/artifacts.js.map +0 -1
- package/dist/sdk/bootstrap.d.ts +0 -16
- package/dist/sdk/bootstrap.d.ts.map +0 -1
- package/dist/sdk/bootstrap.js +0 -28
- package/dist/sdk/bootstrap.js.map +0 -1
- package/dist/sdk/builders.d.ts +0 -54
- package/dist/sdk/builders.d.ts.map +0 -1
- package/dist/sdk/builders.js +0 -117
- package/dist/sdk/builders.js.map +0 -1
- package/dist/sdk/defaults.d.ts +0 -11
- package/dist/sdk/defaults.d.ts.map +0 -1
- package/dist/sdk/defaults.js +0 -39
- package/dist/sdk/defaults.js.map +0 -1
- package/dist/sdk/discovery.d.ts +0 -2
- package/dist/sdk/discovery.d.ts.map +0 -1
- package/dist/sdk/discovery.js +0 -25
- package/dist/sdk/discovery.js.map +0 -1
- package/dist/sdk/events.d.ts +0 -269
- package/dist/sdk/events.d.ts.map +0 -1
- package/dist/sdk/events.js +0 -69
- package/dist/sdk/events.js.map +0 -1
- package/dist/sdk/exit-expression.d.ts +0 -13
- package/dist/sdk/exit-expression.d.ts.map +0 -1
- package/dist/sdk/exit-expression.js +0 -35
- package/dist/sdk/exit-expression.js.map +0 -1
- package/dist/sdk/index.d.ts +0 -17
- package/dist/sdk/index.d.ts.map +0 -1
- package/dist/sdk/index.js +0 -17
- package/dist/sdk/index.js.map +0 -1
- package/dist/sdk/middleware.d.ts +0 -59
- package/dist/sdk/middleware.d.ts.map +0 -1
- package/dist/sdk/middleware.js +0 -69
- package/dist/sdk/middleware.js.map +0 -1
- package/dist/sdk/pipeline/PipelineBuilder.d.ts +0 -79
- package/dist/sdk/pipeline/PipelineBuilder.d.ts.map +0 -1
- package/dist/sdk/pipeline/PipelineBuilder.js +0 -129
- package/dist/sdk/pipeline/PipelineBuilder.js.map +0 -1
- package/dist/sdk/pipeline/PipelineRunner.d.ts +0 -28
- package/dist/sdk/pipeline/PipelineRunner.d.ts.map +0 -1
- package/dist/sdk/pipeline/PipelineRunner.js +0 -326
- package/dist/sdk/pipeline/PipelineRunner.js.map +0 -1
- package/dist/sdk/pipeline/compiler.d.ts +0 -24
- package/dist/sdk/pipeline/compiler.d.ts.map +0 -1
- package/dist/sdk/pipeline/compiler.js +0 -199
- package/dist/sdk/pipeline/compiler.js.map +0 -1
- package/dist/sdk/pipeline/declarative.d.ts +0 -34
- package/dist/sdk/pipeline/declarative.d.ts.map +0 -1
- package/dist/sdk/pipeline/declarative.js +0 -9
- package/dist/sdk/pipeline/declarative.js.map +0 -1
- package/dist/sdk/pipeline/index.d.ts +0 -20
- package/dist/sdk/pipeline/index.d.ts.map +0 -1
- package/dist/sdk/pipeline/index.js +0 -19
- package/dist/sdk/pipeline/index.js.map +0 -1
- package/dist/sdk/pipeline/types.d.ts +0 -93
- package/dist/sdk/pipeline/types.d.ts.map +0 -1
- package/dist/sdk/pipeline/types.js +0 -10
- package/dist/sdk/pipeline/types.js.map +0 -1
- package/dist/sdk/policies.d.ts +0 -163
- package/dist/sdk/policies.d.ts.map +0 -1
- package/dist/sdk/policies.js +0 -243
- package/dist/sdk/policies.js.map +0 -1
- package/dist/sdk/runner/AgentRunner.d.ts +0 -22
- package/dist/sdk/runner/AgentRunner.d.ts.map +0 -1
- package/dist/sdk/runner/AgentRunner.js +0 -222
- package/dist/sdk/runner/AgentRunner.js.map +0 -1
- package/dist/sdk/runner/finalize.d.ts +0 -56
- package/dist/sdk/runner/finalize.d.ts.map +0 -1
- package/dist/sdk/runner/finalize.js +0 -155
- package/dist/sdk/runner/finalize.js.map +0 -1
- package/dist/sdk/runner/formats.d.ts +0 -7
- package/dist/sdk/runner/formats.d.ts.map +0 -1
- package/dist/sdk/runner/formats.js +0 -76
- package/dist/sdk/runner/formats.js.map +0 -1
- package/dist/sdk/runner/index.d.ts +0 -9
- package/dist/sdk/runner/index.d.ts.map +0 -1
- package/dist/sdk/runner/index.js +0 -9
- package/dist/sdk/runner/index.js.map +0 -1
- package/dist/sdk/runner/progress.d.ts +0 -3
- package/dist/sdk/runner/progress.d.ts.map +0 -1
- package/dist/sdk/runner/progress.js +0 -16
- package/dist/sdk/runner/progress.js.map +0 -1
- package/dist/sdk/schemas.d.ts +0 -72
- package/dist/sdk/schemas.d.ts.map +0 -1
- package/dist/sdk/schemas.js +0 -282
- package/dist/sdk/schemas.js.map +0 -1
- package/dist/sdk/trigger-context.d.ts +0 -24
- package/dist/sdk/trigger-context.d.ts.map +0 -1
- package/dist/sdk/trigger-context.js +0 -136
- package/dist/sdk/trigger-context.js.map +0 -1
- package/dist/session/SessionContext.d.ts +0 -89
- package/dist/session/SessionContext.d.ts.map +0 -1
- package/dist/session/SessionContext.js +0 -410
- package/dist/session/SessionContext.js.map +0 -1
- package/dist/session/environment.d.ts +0 -52
- package/dist/session/environment.d.ts.map +0 -1
- package/dist/session/environment.js +0 -27
- package/dist/session/environment.js.map +0 -1
- package/dist/session/history.d.ts +0 -18
- package/dist/session/history.d.ts.map +0 -1
- package/dist/session/history.js +0 -68
- package/dist/session/history.js.map +0 -1
- package/dist/session/index.d.ts +0 -10
- package/dist/session/index.d.ts.map +0 -1
- package/dist/session/index.js +0 -9
- package/dist/session/index.js.map +0 -1
- package/dist/session/serverData.d.ts +0 -38
- package/dist/session/serverData.d.ts.map +0 -1
- package/dist/session/serverData.js +0 -261
- package/dist/session/serverData.js.map +0 -1
- package/dist/tracing/pipelineHelpers.d.ts +0 -29
- package/dist/tracing/pipelineHelpers.d.ts.map +0 -1
- package/dist/tracing/pipelineHelpers.js +0 -224
- package/dist/tracing/pipelineHelpers.js.map +0 -1
- package/dist/tracking/Tracker.d.ts +0 -55
- package/dist/tracking/Tracker.d.ts.map +0 -1
- package/dist/tracking/Tracker.js +0 -217
- package/dist/tracking/Tracker.js.map +0 -1
- package/dist/tracking/index.d.ts +0 -8
- package/dist/tracking/index.d.ts.map +0 -1
- package/dist/tracking/index.js +0 -8
- package/dist/tracking/index.js.map +0 -1
- package/dist/tracking/schemas.d.ts +0 -292
- package/dist/tracking/schemas.d.ts.map +0 -1
- package/dist/tracking/schemas.js +0 -91
- package/dist/tracking/schemas.js.map +0 -1
- package/dist/utils/extractSetFlags.d.ts +0 -6
- package/dist/utils/extractSetFlags.d.ts.map +0 -1
- package/dist/utils/extractSetFlags.js +0 -16
- package/dist/utils/extractSetFlags.js.map +0 -1
- package/dist/utils/formatTimeAgo.d.ts +0 -2
- package/dist/utils/formatTimeAgo.d.ts.map +0 -1
- package/dist/utils/formatTimeAgo.js +0 -20
- package/dist/utils/formatTimeAgo.js.map +0 -1
- package/dist/utils/index.d.ts +0 -12
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js +0 -12
- package/dist/utils/index.js.map +0 -1
- package/dist/utils/machineId.d.ts +0 -14
- package/dist/utils/machineId.d.ts.map +0 -1
- package/dist/utils/machineId.js +0 -66
- package/dist/utils/machineId.js.map +0 -1
- package/dist/utils/pathUtils.d.ts +0 -22
- package/dist/utils/pathUtils.d.ts.map +0 -1
- package/dist/utils/pathUtils.js +0 -54
- package/dist/utils/pathUtils.js.map +0 -1
- package/scripts/download-ripgrep.js +0 -269
|
@@ -0,0 +1,2189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Connection` — owns the WebSocket, multiplexes inbound envelopes onto
|
|
3
|
+
* subscriptions, encodes outbound envelopes.
|
|
4
|
+
*
|
|
5
|
+
* One `Connection` per `QodoClient.connect()` call. `tasks.start` /
|
|
6
|
+
* `tasks.continue` register a `TaskSubscription`; `client.receive()` registers
|
|
7
|
+
* a raw subscription that taps every inbound envelope. The connection routes
|
|
8
|
+
* inbound envelopes to subscriptions whose `parent_message_id` chain matches.
|
|
9
|
+
*
|
|
10
|
+
* **Reconnect + replay.** The Connection survives transport drops:
|
|
11
|
+
* subscriptions stay alive while the underlying `WSTransport` is replaced via
|
|
12
|
+
* the same factory. On a fresh transport, every active task subscription's
|
|
13
|
+
* `task_id` + `lastSeenMessageId` is sent back to the server as a
|
|
14
|
+
* `task.resubscribe` envelope; the server replays anything after that anchor
|
|
15
|
+
* from its per-session ring buffer (cap 1000 envelopes per session). No
|
|
16
|
+
* client-side outbox — durability is server-side. The 1.x `OutboxTurn` /
|
|
17
|
+
* `Resume` / `ResumeAck` machinery does not exist here.
|
|
18
|
+
*/
|
|
19
|
+
import { AsyncQueue } from './iterator.js';
|
|
20
|
+
import { QodoBackpressureError, QodoColdAddressError } from './errors.js';
|
|
21
|
+
import { GEN_AI_CONVERSATION_ID, QAR_SESSION_ID, QAR_TASK_ID, } from '../observability/attributes.js';
|
|
22
|
+
import { asMessageId, asSessionId, uuidv7 } from './uuid.js';
|
|
23
|
+
/**
|
|
24
|
+
* A single task's inbound stream. Routes by parent-message chain: every
|
|
25
|
+
* envelope whose `parent_message_id` is in the chain becomes part of the chain
|
|
26
|
+
* (its `message_id` joins). Terminal envelopes (`task.done`, `error`) close the
|
|
27
|
+
* iterator.
|
|
28
|
+
*
|
|
29
|
+
* On `Connection`-level reconnect, the matching subscription stays alive and
|
|
30
|
+
* the `Connection` stitches the resubscribe envelope's id into `chain` so the
|
|
31
|
+
* server's replayed envelopes route back here transparently.
|
|
32
|
+
*/
|
|
33
|
+
export class TaskSubscription {
|
|
34
|
+
rootMessageId;
|
|
35
|
+
onEarlyReturn;
|
|
36
|
+
onClose;
|
|
37
|
+
metrics;
|
|
38
|
+
queue = new AsyncQueue();
|
|
39
|
+
chain = new Set();
|
|
40
|
+
/**
|
|
41
|
+
* Lineage-ownership set — message_ids this subscription OWNS, used to
|
|
42
|
+
* attribute an inbound `tool.request` to the turn that produced it (so the
|
|
43
|
+
* SDK's `qar.client.tool.handler` span runs in the right task context).
|
|
44
|
+
*
|
|
45
|
+
* Distinct from {@link chain}, which is the broader ROUTING set: `chain`
|
|
46
|
+
* also accepts envelopes via the `task_id` fallback (a passive same-task
|
|
47
|
+
* subscription — e.g. an auto-paused prior `tasks.start` sub — absorbs
|
|
48
|
+
* another turn's stream that way for observation + replay continuity).
|
|
49
|
+
* `ownedMessages` grows ONLY through real `parent_message_id` lineage: this
|
|
50
|
+
* subscription's own outbound root(s), plus every inbound envelope whose
|
|
51
|
+
* parent it ALREADY owns. It never grows via the `task_id` fallback.
|
|
52
|
+
*
|
|
53
|
+
* Invariant (by construction): every message is owned by EXACTLY ONE
|
|
54
|
+
* subscription. Roots are unique per outbound request; a child is owned by
|
|
55
|
+
* its parent's unique owner. So a `tool.request` — which carries no
|
|
56
|
+
* `task_id` and links only by `parent_message_id` — has a unique owner: the
|
|
57
|
+
* subscription whose `ownedMessages` contains its parent. This is why a
|
|
58
|
+
* passive same-task absorber, which holds the other turn's deltas in `chain`
|
|
59
|
+
* but never in `ownedMessages` (their parent is a root it doesn't own),
|
|
60
|
+
* never wins ownership of that turn's tool calls — at any depth.
|
|
61
|
+
*/
|
|
62
|
+
ownedMessages = new Set();
|
|
63
|
+
taskId;
|
|
64
|
+
/**
|
|
65
|
+
* `message_id` of the most recent inbound envelope that matched this
|
|
66
|
+
* subscription's chain or `task_id`. Anchor for `task.resubscribe` on
|
|
67
|
+
* reconnect — the server replays everything after this point. Stays
|
|
68
|
+
* `undefined` until the first inbound envelope arrives. Connection-scoped
|
|
69
|
+
* envelopes (`flow.pause` / `flow.resume`) don't update it: they're
|
|
70
|
+
* session-scoped, not task-scoped, so they can't anchor a per-task replay
|
|
71
|
+
* window.
|
|
72
|
+
*/
|
|
73
|
+
lastSeen;
|
|
74
|
+
/**
|
|
75
|
+
* Outbound `task.resubscribe` message ids whose direct descendant marks
|
|
76
|
+
* the start of replay accounting for this subscription. Seeded by
|
|
77
|
+
* {@link attachOutboundMessageId} (auto-reconnect) and the constructor's
|
|
78
|
+
* `rootIsReplayAnchor` flag (manual `tasks.resubscribe`). Stays small —
|
|
79
|
+
* one entry per resubscribe envelope this subscription owns, not one
|
|
80
|
+
* entry per replay envelope received.
|
|
81
|
+
*/
|
|
82
|
+
replayAnchors = new Set();
|
|
83
|
+
/**
|
|
84
|
+
* Once an envelope absorbed by this subscription has been identified as
|
|
85
|
+
* a replay descendant (its `parent_message_id` was in `replayAnchors`),
|
|
86
|
+
* every subsequently-absorbed envelope is also counted as a replay
|
|
87
|
+
* envelope: QAR's per-session ring buffer replays a contiguous chain
|
|
88
|
+
* after the anchor, so the post-anchor continuation is logically the
|
|
89
|
+
* same wire window as the replay itself.
|
|
90
|
+
*
|
|
91
|
+
* Sticky boolean rather than a growing chain Set — cheaper memory
|
|
92
|
+
* profile for long-running subscriptions that survive many reconnects.
|
|
93
|
+
*/
|
|
94
|
+
replayCountingActive = false;
|
|
95
|
+
/**
|
|
96
|
+
* @param rootMessageId message_id of the outbound envelope that started the
|
|
97
|
+
* task (the `task.start` or `task.continue`).
|
|
98
|
+
* @param knownTaskId task_id known up front (only for `tasks.continue` /
|
|
99
|
+
* `tasks.cancel` / `tasks.resubscribe` — `tasks.start`
|
|
100
|
+
* doesn't know the id until the first inbound envelope
|
|
101
|
+
* reveals it). Pre-seeding the matcher closes a
|
|
102
|
+
* routing gap where envelopes that carry
|
|
103
|
+
* `payload.task_id` but no chain link to our outbound
|
|
104
|
+
* message would otherwise be missed.
|
|
105
|
+
* @param onEarlyReturn called once if the consumer breaks the iterator
|
|
106
|
+
* before terminal — best-effort `task.cancel`. Pass a
|
|
107
|
+
* no-op for resubscribe-style subscriptions where
|
|
108
|
+
* breaking the loop must NOT cancel the underlying
|
|
109
|
+
* task (the consumer is observing, not driving).
|
|
110
|
+
* @param onClose called once when the subscription is no longer
|
|
111
|
+
* routable (terminal received OR consumer broke).
|
|
112
|
+
*/
|
|
113
|
+
/** Span lifecycle for the task this subscription represents. Closed on terminal/fail/early-return. */
|
|
114
|
+
span;
|
|
115
|
+
/**
|
|
116
|
+
* Resolver for the `task.started` admission ack. Receives both the
|
|
117
|
+
* inherited `session_id` (server-derived UUID) AND the canonical
|
|
118
|
+
* `task_id` from the envelope's payload — the two ids are distinct on
|
|
119
|
+
* the admission-retry path, where a retried `task.start` carries a
|
|
120
|
+
* fresh `message_id` while the server's canonical task_id stays bound
|
|
121
|
+
* to the FIRST winning attempt.
|
|
122
|
+
*
|
|
123
|
+
* Provided by `TaskClient.subscribeAndSend` for `task.start`
|
|
124
|
+
* subscriptions only — `task.continue` / `task.cancel` /
|
|
125
|
+
* `task.resubscribe` already know the session_id + task_id from a
|
|
126
|
+
* prior admission and don't wire a resolver. `null` once the resolver
|
|
127
|
+
* has fired (resolved OR rejected) — prevents a second resolution
|
|
128
|
+
* from a spec-violating duplicate ack.
|
|
129
|
+
*/
|
|
130
|
+
taskStartedResolver = null;
|
|
131
|
+
/**
|
|
132
|
+
* Resolver for the `task.force_resumed` ack — fires when the SDK's
|
|
133
|
+
* outbound `task.forceResume` envelope's response arrives. Wired by
|
|
134
|
+
* `TaskClient.forceResume`. Mirrors {@link taskStartedResolver} in
|
|
135
|
+
* shape and lifecycle (null after fire; rejected if the subscription
|
|
136
|
+
* closes before the ack lands).
|
|
137
|
+
*/
|
|
138
|
+
taskForceResumedResolver = null;
|
|
139
|
+
constructor(rootMessageId, knownTaskId, onEarlyReturn, onClose, span,
|
|
140
|
+
/**
|
|
141
|
+
* Optional transport-counter store. When provided, `consider` increments
|
|
142
|
+
* `replay_envelopes_received_total` for every absorbed envelope that
|
|
143
|
+
* descends from a replay anchor, and `replay_anchor_missing_total` for
|
|
144
|
+
* every `error { code: 'replay_anchor_missing' }` envelope that routes
|
|
145
|
+
* here.
|
|
146
|
+
*/
|
|
147
|
+
metrics,
|
|
148
|
+
/**
|
|
149
|
+
* `true` when `rootMessageId` is itself a `task.resubscribe` envelope —
|
|
150
|
+
* its descendants count as replay envelopes for
|
|
151
|
+
* `replay_envelopes_received_total`. Set by the manual
|
|
152
|
+
* `client.tasks.resubscribe(...)` seam; the `tasks.start` /
|
|
153
|
+
* `tasks.continue` paths leave it `false` so live envelopes don't
|
|
154
|
+
* masquerade as replay until an auto-replay anchor is stitched in via
|
|
155
|
+
* {@link attachOutboundMessageId}.
|
|
156
|
+
*/
|
|
157
|
+
rootIsReplayAnchor = false,
|
|
158
|
+
/**
|
|
159
|
+
* Optional resolver fired with the server-derived `session_id` when
|
|
160
|
+
* the `task.started` admission ack arrives on this subscription's
|
|
161
|
+
* chain. Wired by `task.start` paths so the caller's `TaskStartIterable`
|
|
162
|
+
* can expose the derived id via its `sessionId` Promise.
|
|
163
|
+
*
|
|
164
|
+
* Resolver lifecycle:
|
|
165
|
+
* - Resolved on inbound `task.started` whose `parent_message_id`
|
|
166
|
+
* matches the outbound `task.start.message_id`.
|
|
167
|
+
* - Rejected if the subscription terminates (clean close, transport
|
|
168
|
+
* fail, terminal `task.done` / `error`) before any `task.started`
|
|
169
|
+
* arrives — keeps the consumer's `await stream.sessionId` from
|
|
170
|
+
* hanging forever.
|
|
171
|
+
*/
|
|
172
|
+
taskStartedResolver,
|
|
173
|
+
/**
|
|
174
|
+
* Optional resolver fired with the recovered task's id +
|
|
175
|
+
* post-recovery state when an inbound `task.force_resumed` ack
|
|
176
|
+
* arrives on this subscription's chain. Wired by
|
|
177
|
+
* `TaskClient.forceResume`. Same lifecycle as
|
|
178
|
+
* {@link taskStartedResolver}: rejected on close/error before
|
|
179
|
+
* the ack lands.
|
|
180
|
+
*/
|
|
181
|
+
taskForceResumedResolver) {
|
|
182
|
+
this.rootMessageId = rootMessageId;
|
|
183
|
+
this.onEarlyReturn = onEarlyReturn;
|
|
184
|
+
this.onClose = onClose;
|
|
185
|
+
this.metrics = metrics;
|
|
186
|
+
this.chain.add(rootMessageId);
|
|
187
|
+
// The outbound root is the origin of this subscription's lineage — it
|
|
188
|
+
// owns it. Every inbound envelope whose parent chain leads back here
|
|
189
|
+
// (via real `parent_message_id` links) is owned by this subscription.
|
|
190
|
+
this.ownedMessages.add(rootMessageId);
|
|
191
|
+
if (rootIsReplayAnchor) {
|
|
192
|
+
this.replayAnchors.add(rootMessageId);
|
|
193
|
+
}
|
|
194
|
+
this.taskId = knownTaskId;
|
|
195
|
+
this.span = span;
|
|
196
|
+
if (taskStartedResolver !== undefined) {
|
|
197
|
+
this.taskStartedResolver = taskStartedResolver;
|
|
198
|
+
}
|
|
199
|
+
if (taskForceResumedResolver !== undefined) {
|
|
200
|
+
this.taskForceResumedResolver = taskForceResumedResolver;
|
|
201
|
+
}
|
|
202
|
+
// Stamp `qar.task_id` eagerly when we know the id at construction.
|
|
203
|
+
// For `task.start` subscriptions the SDK derives `task_id` from the
|
|
204
|
+
// outbound `task.start.message_id`; for `task.continue` /
|
|
205
|
+
// `task.cancel` / `task.resubscribe` the caller supplies it. The
|
|
206
|
+
// lazy stamp inside `consider` stays as defense-in-depth for any
|
|
207
|
+
// callsite that constructs without an id.
|
|
208
|
+
if (knownTaskId !== undefined && span !== undefined) {
|
|
209
|
+
span.setAttribute(QAR_TASK_ID, knownTaskId);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** The `task_id` once we've seen the first envelope that carries it. */
|
|
213
|
+
get currentTaskId() {
|
|
214
|
+
return this.taskId;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Most-recent inbound envelope's `message_id`, or `undefined` if no inbound
|
|
218
|
+
* matched yet. Public so `Connection.replayActiveTasks` can read it without
|
|
219
|
+
* pinning to a specific subscription class.
|
|
220
|
+
*/
|
|
221
|
+
get lastSeenMessageId() {
|
|
222
|
+
return this.lastSeen;
|
|
223
|
+
}
|
|
224
|
+
/** Whether this subscription has terminated and stopped accepting envelopes. */
|
|
225
|
+
get isTerminated() {
|
|
226
|
+
return this.queue.isClosed;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Stitch a new outbound `message_id` (typically a `task.resubscribe`) into
|
|
230
|
+
* this subscription's chain so the server's replies routed via
|
|
231
|
+
* `parent_message_id` link back here. Idempotent — Set.add no-ops on dups.
|
|
232
|
+
*
|
|
233
|
+
* Called by `Connection` after sending a resubscribe on behalf of an
|
|
234
|
+
* already-live subscription (the auto-replay path on reconnect).
|
|
235
|
+
*/
|
|
236
|
+
attachOutboundMessageId(messageId) {
|
|
237
|
+
this.chain.add(messageId);
|
|
238
|
+
// A `task.resubscribe` we sent is also a real lineage origin for the
|
|
239
|
+
// envelopes the server replays in response — own it so those descendants
|
|
240
|
+
// resolve their tool-call context to this subscription.
|
|
241
|
+
this.ownedMessages.add(messageId);
|
|
242
|
+
// The only caller is `Connection.replayActiveTasks`, which stitches a
|
|
243
|
+
// `task.resubscribe` envelope's id in. Seed `replayAnchors` so the
|
|
244
|
+
// next absorbed descendant flips `replayCountingActive` on and starts
|
|
245
|
+
// counting toward `replay_envelopes_received_total`.
|
|
246
|
+
this.replayAnchors.add(messageId);
|
|
247
|
+
}
|
|
248
|
+
consider(env) {
|
|
249
|
+
// Connection-scoped flow events: broadcast to every active task
|
|
250
|
+
// iterator regardless of message-id chain — QAR emits these once per
|
|
251
|
+
// connection state change, and consumers expect them on the same iterator
|
|
252
|
+
// they're already reading. They never claim the envelope, so raw taps and
|
|
253
|
+
// peer subscriptions still see them. They DON'T update `lastSeen` —
|
|
254
|
+
// they're session-scoped, not task-scoped, so they can't anchor a
|
|
255
|
+
// per-task replay window.
|
|
256
|
+
if (env.kind === 'flow.pause' || env.kind === 'flow.resume') {
|
|
257
|
+
this.queue.push(env);
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
if (!this.matches(env))
|
|
261
|
+
return false;
|
|
262
|
+
// Did this envelope reach us as a direct reply to one of our own
|
|
263
|
+
// outbound messages (chain), or only because its payload `task_id`
|
|
264
|
+
// equals ours (fallback)? Captured BEFORE `chain.add` below stitches
|
|
265
|
+
// this envelope's own `message_id` in. The admission / recovery ack
|
|
266
|
+
// branches gate their self-close on this: an ack that matched only via
|
|
267
|
+
// the `task_id` fallback belongs to a different request and must not
|
|
268
|
+
// close THIS subscription.
|
|
269
|
+
const matchedByChain = this.matchesByChain(env);
|
|
270
|
+
// Replay-counter accounting. Two states:
|
|
271
|
+
// - `replayCountingActive` already true → count this envelope (the
|
|
272
|
+
// post-anchor continuation is part of the same wire window).
|
|
273
|
+
// - `replayCountingActive` false but env's `parent_message_id` is in
|
|
274
|
+
// `replayAnchors` → first replay descendant; flip the flag and
|
|
275
|
+
// count this envelope.
|
|
276
|
+
// Sticky-boolean replaces the prior growing replay-chain Set so memory
|
|
277
|
+
// stays bounded over long-lived subscriptions.
|
|
278
|
+
if (this.metrics !== undefined) {
|
|
279
|
+
if (this.replayCountingActive) {
|
|
280
|
+
this.metrics.recordReplayEnvelopeReceived();
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
const parent = env.parent_message_id;
|
|
284
|
+
if (parent !== undefined &&
|
|
285
|
+
parent !== null &&
|
|
286
|
+
this.replayAnchors.has(parent)) {
|
|
287
|
+
this.replayCountingActive = true;
|
|
288
|
+
this.metrics.recordReplayEnvelopeReceived();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
this.chain.add(env.message_id);
|
|
293
|
+
// Lineage ownership: this envelope belongs to THIS subscription's turn iff
|
|
294
|
+
// we already own its parent (a real `parent_message_id` link back to our
|
|
295
|
+
// outbound root). A `task_id`-fallback match (e.g. a passive same-task sub
|
|
296
|
+
// absorbing another turn's stream) is routed into `chain` above but is
|
|
297
|
+
// NEVER an ownership link — its parent is a root we don't own — so the
|
|
298
|
+
// ownership set stays exact and the unique-owner invariant holds at any
|
|
299
|
+
// depth. See {@link ownedMessages}.
|
|
300
|
+
const parentId = env.parent_message_id;
|
|
301
|
+
if (parentId !== undefined && parentId !== null && this.ownedMessages.has(parentId)) {
|
|
302
|
+
this.ownedMessages.add(env.message_id);
|
|
303
|
+
}
|
|
304
|
+
this.lastSeen = env.message_id;
|
|
305
|
+
if (this.taskId === undefined && envelopeHasTaskId(env)) {
|
|
306
|
+
this.taskId = env.payload.task_id;
|
|
307
|
+
// Stamp `qar.task_id` on the span lazily — `tasks.start`'s span doesn't
|
|
308
|
+
// know the id at open time (the server allocates it server-side and
|
|
309
|
+
// reports it on the first inbound envelope).
|
|
310
|
+
if (this.span !== undefined) {
|
|
311
|
+
this.span.setAttribute(QAR_TASK_ID, this.taskId);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Capture the server-derived `session_id` from the `task.started`
|
|
315
|
+
// admission ack and resolve the caller-facing `sessionId` Promise.
|
|
316
|
+
// The envelope is NOT yielded on the public `TaskEvent` iterator —
|
|
317
|
+
// it's the start ack, not a content event. Per the wire contract
|
|
318
|
+
// the kind is server-emitted exactly once per successful admission,
|
|
319
|
+
// before any `task.delta`; routing only matters when this
|
|
320
|
+
// subscription owns the matching outbound `task.start` (so
|
|
321
|
+
// `taskStartedResolver` was wired).
|
|
322
|
+
if (env.kind === 'task.started') {
|
|
323
|
+
if (!matchedByChain) {
|
|
324
|
+
// This `task.started` reached us only via the `task_id` fallback —
|
|
325
|
+
// it is NOT the reply to this subscription's own outbound
|
|
326
|
+
// `task.start`. Two ways that happens, both relying on the chain
|
|
327
|
+
// seeding above:
|
|
328
|
+
// - a sibling's idempotent re-admission ack
|
|
329
|
+
// (`task.started { is_new: false }`) on an already-dispatched
|
|
330
|
+
// session, which carries this task's canonical id; and
|
|
331
|
+
// - a cold `task.resubscribe` replaying the ORIGINAL `task.started`
|
|
332
|
+
// (foreign parent) from the per-session ring buffer.
|
|
333
|
+
// Absorb it — the `chain.add` above already seeded the chain so the
|
|
334
|
+
// chain-only replayed events that follow (tool.request /
|
|
335
|
+
// state.update / agent.spawn / bulletin.post / artifact.add) still
|
|
336
|
+
// route here — but do NOT resolve admission or self-close. Resolving
|
|
337
|
+
// or closing on a foreign ack is exactly what silently collapsed an
|
|
338
|
+
// unrelated in-flight consumer stream.
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
// The per-Task `session_id` is captured by
|
|
342
|
+
// {@link Connection.dispatch} into `taskSessions[task_id]` (the
|
|
343
|
+
// authoritative lookup map for outbound encoding). Holding a
|
|
344
|
+
// per-subscription copy would be dead state — encoding paths
|
|
345
|
+
// address by `task_id`, not by subscription identity.
|
|
346
|
+
//
|
|
347
|
+
// Admission_in_progress retry path: the SDK opens each
|
|
348
|
+
// `task.start` attempt with `knownTaskId = asTaskId(rootMessageId)`
|
|
349
|
+
// (the ATTEMPT id), but the server's canonical `task_id` on
|
|
350
|
+
// `task.started.payload.task_id` may differ — on a deterministic-key
|
|
351
|
+
// retry the SDK rotates `message_id` per attempt while the
|
|
352
|
+
// server-canonical task_id stays bound to the winning admission.
|
|
353
|
+
// Overwrite `this.taskId` with the CANONICAL value so that
|
|
354
|
+
// subsequent outbound envelopes (`task.cancel` from iterator
|
|
355
|
+
// early-return, `task.resubscribe` on auto-reconnect) address the
|
|
356
|
+
// task by the id `Connection.taskSessions` is keyed on. Without
|
|
357
|
+
// overwrite, post-ack ongoing emits would miss the session map
|
|
358
|
+
// and raise `QodoColdAddressError` despite admission being fully
|
|
359
|
+
// complete.
|
|
360
|
+
//
|
|
361
|
+
// Idempotent-admission fields: forward-compat — old QAR builds
|
|
362
|
+
// omit them; the SDK treats the absence as `is_new: true` (the
|
|
363
|
+
// pre-idempotent-admission default) and leaves `state` /
|
|
364
|
+
// `previousTaskId` / `previousState` undefined so the typed
|
|
365
|
+
// admission result's `isNew === true` branch is selected.
|
|
366
|
+
const payload = env.payload;
|
|
367
|
+
const canonicalTaskId = payload.task_id;
|
|
368
|
+
this.taskId = canonicalTaskId;
|
|
369
|
+
if (this.span !== undefined) {
|
|
370
|
+
// Stamp the server-derived `session_id` AND re-stamp the canonical
|
|
371
|
+
// `task_id` onto the open span. Pre-admission the span carries the
|
|
372
|
+
// attempt id (or none); post-admission both attributes reflect the
|
|
373
|
+
// canonical values QAR will report on every downstream envelope.
|
|
374
|
+
this.span.setAttribute(QAR_SESSION_ID, env.session_id);
|
|
375
|
+
this.span.setAttribute(GEN_AI_CONVERSATION_ID, env.session_id);
|
|
376
|
+
this.span.setAttribute(QAR_TASK_ID, canonicalTaskId);
|
|
377
|
+
}
|
|
378
|
+
// Default `is_new` to `true` when the field is absent (forward-compat
|
|
379
|
+
// with older QAR builds). `is_new === false` indicates an idempotent
|
|
380
|
+
// existing-session return — no further envelopes will flow on this
|
|
381
|
+
// subscription's chain (the existing session's events route on the
|
|
382
|
+
// chain that opened it). Auto-close after the ack so iterators don't
|
|
383
|
+
// hang waiting for events that will never arrive.
|
|
384
|
+
const isNew = payload.is_new ?? true;
|
|
385
|
+
if (this.taskStartedResolver !== null) {
|
|
386
|
+
const resolver = this.taskStartedResolver;
|
|
387
|
+
this.taskStartedResolver = null;
|
|
388
|
+
resolver.resolve({
|
|
389
|
+
sessionId: env.session_id,
|
|
390
|
+
taskId: canonicalTaskId,
|
|
391
|
+
isNew,
|
|
392
|
+
...(payload.state !== undefined ? { state: payload.state } : {}),
|
|
393
|
+
...(payload.previous_task_id !== undefined
|
|
394
|
+
? { previousTaskId: payload.previous_task_id }
|
|
395
|
+
: {}),
|
|
396
|
+
...(payload.previous_state !== undefined
|
|
397
|
+
? { previousState: payload.previous_state }
|
|
398
|
+
: {}),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (!isNew) {
|
|
402
|
+
// Idempotent return — close the subscription without firing
|
|
403
|
+
// `onEarlyReturn` (which would emit a spurious `task.cancel`
|
|
404
|
+
// against the existing session). Use `close()` rather than
|
|
405
|
+
// manual `queue.close()` + `onClose(this)` so the span's
|
|
406
|
+
// `succeed()` fires too — `onClose` is wired to
|
|
407
|
+
// `connection.unsubscribe` and DOES NOT end spans, so a
|
|
408
|
+
// manual teardown leaks open spans on every idempotent
|
|
409
|
+
// admission. Same safe-shutdown shape as `disconnect()` and
|
|
410
|
+
// consumer-return paths.
|
|
411
|
+
this.close();
|
|
412
|
+
}
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
if (env.kind === 'task.force_resumed') {
|
|
416
|
+
if (!matchedByChain) {
|
|
417
|
+
// Same rationale as `task.started`: a `task.force_resumed` that
|
|
418
|
+
// matched only via the `task_id` fallback is NOT this subscription's
|
|
419
|
+
// own recovery ack (the forceResume one-shot matches its ack by chain
|
|
420
|
+
// through the outbound `task.forceResume.message_id`). Absorb for
|
|
421
|
+
// chain continuity; never one-shot-close on a foreign ack — that
|
|
422
|
+
// would collapse an unrelated in-flight stream bound to the same
|
|
423
|
+
// task.
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
// `task.forceResume` ack — surface the recovered task's id +
|
|
427
|
+
// post-recovery state to `TaskClient.forceResume`. This is the
|
|
428
|
+
// one-shot terminal for the forceResume subscription; close the
|
|
429
|
+
// queue so the iterator (consumed only by SDK-internal wait
|
|
430
|
+
// logic) drains.
|
|
431
|
+
const payload = env.payload;
|
|
432
|
+
const recoveredTaskId = payload.task_id;
|
|
433
|
+
this.taskId = recoveredTaskId;
|
|
434
|
+
if (this.span !== undefined) {
|
|
435
|
+
this.span.setAttribute(QAR_SESSION_ID, env.session_id);
|
|
436
|
+
this.span.setAttribute(GEN_AI_CONVERSATION_ID, env.session_id);
|
|
437
|
+
this.span.setAttribute(QAR_TASK_ID, recoveredTaskId);
|
|
438
|
+
}
|
|
439
|
+
if (this.taskForceResumedResolver !== null) {
|
|
440
|
+
const resolver = this.taskForceResumedResolver;
|
|
441
|
+
this.taskForceResumedResolver = null;
|
|
442
|
+
resolver.resolve({
|
|
443
|
+
sessionId: env.session_id,
|
|
444
|
+
taskId: recoveredTaskId,
|
|
445
|
+
state: payload.state,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
// forceResume is one-shot — close the subscription after the
|
|
449
|
+
// ack. Use `close()` (not manual `queue.close()` + `onClose`) so
|
|
450
|
+
// the span's `succeed()` fires; `onClose` only unregisters and
|
|
451
|
+
// does not end spans.
|
|
452
|
+
this.close();
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
if (isTaskEvent(env)) {
|
|
456
|
+
this.queue.push(env);
|
|
457
|
+
}
|
|
458
|
+
if (env.kind === 'task.done') {
|
|
459
|
+
const status = env.payload.status;
|
|
460
|
+
const error = env.payload.error;
|
|
461
|
+
this.endSpanForDone(status, error);
|
|
462
|
+
// Terminal without admission ack ⇒ reject the sessionId promise
|
|
463
|
+
// so `await stream.sessionId` doesn't hang. The wire contract says
|
|
464
|
+
// `task.done` cannot land before `task.started` on a successfully
|
|
465
|
+
// admitted task; reaching here with a live resolver means the run
|
|
466
|
+
// ended without admission (test fixtures, transport-injected
|
|
467
|
+
// terminals) and the caller deserves a typed rejection.
|
|
468
|
+
this.rejectTaskStartedIfPending(new Error(`task ended before task.started ack arrived (status=${status})`));
|
|
469
|
+
this.rejectTaskForceResumedIfPending(new Error(`task ended before task.force_resumed ack arrived (status=${status})`));
|
|
470
|
+
this.queue.close();
|
|
471
|
+
this.onClose(this);
|
|
472
|
+
}
|
|
473
|
+
else if (env.kind === 'error') {
|
|
474
|
+
const code = env.payload.code;
|
|
475
|
+
const message = env.payload.message;
|
|
476
|
+
if (code === 'replay_anchor_missing') {
|
|
477
|
+
this.metrics?.recordReplayAnchorMissing();
|
|
478
|
+
}
|
|
479
|
+
// `span.fail()` records the exception + sets ERROR status; the wire
|
|
480
|
+
// `error.code` is captured in the exception message. We deliberately
|
|
481
|
+
// don't `setAttribute(QAR_*, code)` here — error envelopes are wire
|
|
482
|
+
// transport-level, not tool-level, and their span semantics belong
|
|
483
|
+
// to the recorded exception, not a custom attribute key.
|
|
484
|
+
this.span?.fail(new Error(`server error: ${code}${message !== undefined ? `: ${message}` : ''}`));
|
|
485
|
+
// Pre-admission failure (e.g. `admission_stalled`, invalid
|
|
486
|
+
// `idempotency_key`, deps build) closes the subscription before
|
|
487
|
+
// any `task.started` lands — reject the sessionId promise so the
|
|
488
|
+
// caller's `await stream.sessionId` surfaces the failure rather
|
|
489
|
+
// than hanging. The same error envelope still flows through the
|
|
490
|
+
// event iterator (or the wrap-server-errors layer in TaskClient
|
|
491
|
+
// for typed-code routes) so the rejection here is additive, not a
|
|
492
|
+
// replacement.
|
|
493
|
+
//
|
|
494
|
+
// EXCEPTION: `admission_in_progress` is retryable. The retry
|
|
495
|
+
// wrapper at the TaskClient layer dispatches a fresh subscription
|
|
496
|
+
// that may still resolve the deferred — keep the promise pending
|
|
497
|
+
// so the eventual `task.started` (on a successful retry) lands
|
|
498
|
+
// cleanly. The current per-attempt subscription is still closed
|
|
499
|
+
// (the error envelope is its terminal); only the
|
|
500
|
+
// promise-rejection step is skipped.
|
|
501
|
+
if (code !== 'admission_in_progress') {
|
|
502
|
+
this.rejectTaskStartedIfPending(new Error(`server error before task.started: ${code}${message !== undefined ? `: ${message}` : ''}`));
|
|
503
|
+
this.rejectTaskForceResumedIfPending(new Error(`server error before task.force_resumed: ${code}${message !== undefined ? `: ${message}` : ''}`));
|
|
504
|
+
}
|
|
505
|
+
this.queue.close();
|
|
506
|
+
this.onClose(this);
|
|
507
|
+
}
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Reject the pending `task.started` resolver, if any. No-op when the
|
|
512
|
+
* resolver has already fired (resolved earlier on the ack, or rejected
|
|
513
|
+
* by a prior terminal). Idempotent — every terminal path can call
|
|
514
|
+
* this without guarding.
|
|
515
|
+
*/
|
|
516
|
+
rejectTaskStartedIfPending(err) {
|
|
517
|
+
if (this.taskStartedResolver === null)
|
|
518
|
+
return;
|
|
519
|
+
const resolver = this.taskStartedResolver;
|
|
520
|
+
this.taskStartedResolver = null;
|
|
521
|
+
resolver.reject(err);
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Reject the pending `task.force_resumed` resolver, if any. Mirrors
|
|
525
|
+
* {@link rejectTaskStartedIfPending}: idempotent; called from every
|
|
526
|
+
* terminal path so `tasks.forceResume()` Promises don't hang on
|
|
527
|
+
* close / transport failure.
|
|
528
|
+
*/
|
|
529
|
+
rejectTaskForceResumedIfPending(err) {
|
|
530
|
+
if (this.taskForceResumedResolver === null)
|
|
531
|
+
return;
|
|
532
|
+
const resolver = this.taskForceResumedResolver;
|
|
533
|
+
this.taskForceResumedResolver = null;
|
|
534
|
+
resolver.reject(err);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* End the task span for a `task.done` envelope. `completed` and `canceled`
|
|
538
|
+
* are both clean terminal states (the consumer asked for the cancel, or the
|
|
539
|
+
* task ran to completion); `failed` records an error.
|
|
540
|
+
*/
|
|
541
|
+
endSpanForDone(status, error) {
|
|
542
|
+
if (this.span === undefined)
|
|
543
|
+
return;
|
|
544
|
+
if (status === 'failed') {
|
|
545
|
+
this.span.fail(new Error(error ?? 'task failed'));
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
this.span.succeed();
|
|
549
|
+
}
|
|
550
|
+
considerClient(ev) {
|
|
551
|
+
if (this.queue.isClosed)
|
|
552
|
+
return;
|
|
553
|
+
this.queue.push(ev);
|
|
554
|
+
}
|
|
555
|
+
fail(err) {
|
|
556
|
+
this.span?.fail(err);
|
|
557
|
+
// Transport failure before admission ack ⇒ surface to anyone
|
|
558
|
+
// awaiting `TaskStartIterable.sessionId` rather than letting them hang.
|
|
559
|
+
this.rejectTaskStartedIfPending(err);
|
|
560
|
+
this.rejectTaskForceResumedIfPending(err);
|
|
561
|
+
this.queue.fail(err);
|
|
562
|
+
this.onClose(this);
|
|
563
|
+
}
|
|
564
|
+
close() {
|
|
565
|
+
// Clean-close path used by `disconnect()` and consumer `.return()` —
|
|
566
|
+
// neither is an error from the SDK's perspective (the consumer asked or
|
|
567
|
+
// the connection is shutting down). `succeed()` is idempotent if the
|
|
568
|
+
// span already ended via `task.done` arriving first.
|
|
569
|
+
this.span?.succeed();
|
|
570
|
+
// Same lifecycle rejection as `fail()` — a clean close before
|
|
571
|
+
// `task.started` means the consumer never got the admission ack.
|
|
572
|
+
this.rejectTaskStartedIfPending(new Error('subscription closed before task.started ack arrived'));
|
|
573
|
+
this.rejectTaskForceResumedIfPending(new Error('subscription closed before task.force_resumed ack arrived'));
|
|
574
|
+
this.queue.close();
|
|
575
|
+
this.onClose(this);
|
|
576
|
+
}
|
|
577
|
+
next() {
|
|
578
|
+
// Run the queue read under the SDK span's OTel context so the consumer's
|
|
579
|
+
// `for await` continuation resumes with the SDK span active. This is
|
|
580
|
+
// what lets a consumer call `client.tools.respond(...)` from inside the
|
|
581
|
+
// loop body and have *that* span parent under our task span (and have
|
|
582
|
+
// its outbound `traceparent` reference our task span's id). Without
|
|
583
|
+
// this wrap, `getActiveSpan()` at await-resume time falls back to
|
|
584
|
+
// whatever was active when the consumer originally `for await`-ed,
|
|
585
|
+
// breaking the cross-call trace tree.
|
|
586
|
+
if (this.span !== undefined) {
|
|
587
|
+
return this.span.withContext(() => this.queue.next());
|
|
588
|
+
}
|
|
589
|
+
return this.queue.next();
|
|
590
|
+
}
|
|
591
|
+
async return() {
|
|
592
|
+
if (!this.queue.isClosed) {
|
|
593
|
+
this.onEarlyReturn(this.taskId);
|
|
594
|
+
// Iterator early-termination is consumer intent, not a failure. The
|
|
595
|
+
// SDK best-effort sends `task.cancel` (via `onEarlyReturn`); the span
|
|
596
|
+
// ends with `OK` so traces don't surface canceled tasks as errors.
|
|
597
|
+
this.span?.succeed();
|
|
598
|
+
this.queue.close();
|
|
599
|
+
this.onClose(this);
|
|
600
|
+
}
|
|
601
|
+
return { value: undefined, done: true };
|
|
602
|
+
}
|
|
603
|
+
async throw(err) {
|
|
604
|
+
if (!this.queue.isClosed) {
|
|
605
|
+
this.span?.fail(err);
|
|
606
|
+
this.queue.close();
|
|
607
|
+
this.onClose(this);
|
|
608
|
+
}
|
|
609
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
610
|
+
}
|
|
611
|
+
[Symbol.asyncIterator]() {
|
|
612
|
+
return this;
|
|
613
|
+
}
|
|
614
|
+
matches(env) {
|
|
615
|
+
if (this.matchesByChain(env)) {
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
if (this.taskId !== undefined && envelopeHasTaskId(env)) {
|
|
619
|
+
const payloadTaskId = env.payload.task_id;
|
|
620
|
+
if (payloadTaskId === this.taskId)
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Whether `env`'s `parent_message_id` links into this subscription's chain
|
|
627
|
+
* — i.e. the envelope is a direct reply to an outbound message this
|
|
628
|
+
* subscription owns (`task.start` / `task.continue` / `task.resubscribe`,
|
|
629
|
+
* or a prior inbound that already joined the chain).
|
|
630
|
+
*
|
|
631
|
+
* Distinct from the broader {@link matches}, which also accepts an envelope
|
|
632
|
+
* solely because its payload `task_id` equals ours. The admission /
|
|
633
|
+
* recovery acks (`task.started`, `task.force_resumed`) use THIS predicate —
|
|
634
|
+
* not the `task_id` fallback — to decide whether to resolve admission or
|
|
635
|
+
* self-close: those acks carry the canonical `task_id`, which on an
|
|
636
|
+
* idempotent re-admission (a second `task.start` on an already-dispatched
|
|
637
|
+
* session) or a buffer replay equals the id of a DIFFERENT subscription
|
|
638
|
+
* whose task is still streaming. A `task_id`-only match must still absorb
|
|
639
|
+
* the ack (so a cold resubscribe's replayed `task.started` seeds the chain
|
|
640
|
+
* for the chain-only events that follow), but must NOT drive that other
|
|
641
|
+
* subscription's admission/close — see {@link consider}.
|
|
642
|
+
*/
|
|
643
|
+
matchesByChain(env) {
|
|
644
|
+
return (env.parent_message_id !== undefined &&
|
|
645
|
+
env.parent_message_id !== null &&
|
|
646
|
+
this.chain.has(env.parent_message_id));
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Whether this subscription OWNS `env`'s turn — i.e. its `parent_message_id`
|
|
650
|
+
* is in {@link ownedMessages} (a real lineage link back to our outbound
|
|
651
|
+
* root, never a `task_id`-fallback absorb). Used by
|
|
652
|
+
* {@link Connection.runInOwningTaskContext} to attribute an inbound
|
|
653
|
+
* `tool.request` to the turn that produced it: the owner is unique (each
|
|
654
|
+
* message is owned by exactly one subscription), so this is unambiguous even
|
|
655
|
+
* when multiple same-task subscriptions overlap (e.g. a live auto-paused
|
|
656
|
+
* prior `tasks.start` sub alongside an active `tasks.continue`). A passive
|
|
657
|
+
* absorber holds the other turn's stream in {@link chain} but never in
|
|
658
|
+
* {@link ownedMessages}, so it never wins ownership — at any depth.
|
|
659
|
+
*/
|
|
660
|
+
ownsLineage(env) {
|
|
661
|
+
return (env.parent_message_id !== undefined &&
|
|
662
|
+
env.parent_message_id !== null &&
|
|
663
|
+
this.ownedMessages.has(env.parent_message_id));
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Public alias for `matches` — exposed so `Connection.dispatchCancelAcked`
|
|
667
|
+
* can route a `task.canceling` envelope to the correct subscription
|
|
668
|
+
* before broadcasting the synthetic `qar.client.cancel_acked` client
|
|
669
|
+
* event. Same chain + `task_id` matcher used for normal envelope
|
|
670
|
+
* routing.
|
|
671
|
+
*/
|
|
672
|
+
matchesEnvelope(env) {
|
|
673
|
+
return this.matches(env);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Run `fn` inside this subscription's task span context. When the
|
|
677
|
+
* subscription has no span (no OTel provider configured, or a call site
|
|
678
|
+
* that didn't supply a span builder), runs `fn` directly — a strict
|
|
679
|
+
* pass-through.
|
|
680
|
+
*
|
|
681
|
+
* Used by {@link Connection.runInOwningTaskContext} to re-attach the
|
|
682
|
+
* originating task's context around an inbound `tool.request` handler
|
|
683
|
+
* dispatch, so the handler's `qar.client.tool.handler` span (and any
|
|
684
|
+
* outbound work it triggers) parents under the task span rather than the
|
|
685
|
+
* bare WS-receive context.
|
|
686
|
+
*/
|
|
687
|
+
runInTaskContext(fn) {
|
|
688
|
+
return this.span !== undefined ? this.span.withContext(fn) : fn();
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Raw subscription from `client.receive()` — taps every inbound envelope plus
|
|
693
|
+
* the synthetic `qar.client.*` lifecycle events. The yielded type is
|
|
694
|
+
* `Envelope | ClientEvent`; consumers narrow on `kind` (the QAR `kind`
|
|
695
|
+
* discriminator stays disjoint from `qar.client.*`).
|
|
696
|
+
*/
|
|
697
|
+
export class RawSubscription {
|
|
698
|
+
onClose;
|
|
699
|
+
queue = new AsyncQueue();
|
|
700
|
+
constructor(onClose) {
|
|
701
|
+
this.onClose = onClose;
|
|
702
|
+
}
|
|
703
|
+
consider(env) {
|
|
704
|
+
this.queue.push(env);
|
|
705
|
+
// Raw taps don't claim envelopes — they always return false so task
|
|
706
|
+
// subscriptions still see them.
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
considerClient(ev) {
|
|
710
|
+
if (this.queue.isClosed)
|
|
711
|
+
return;
|
|
712
|
+
this.queue.push(ev);
|
|
713
|
+
}
|
|
714
|
+
fail(err) {
|
|
715
|
+
this.queue.fail(err);
|
|
716
|
+
this.onClose(this);
|
|
717
|
+
}
|
|
718
|
+
close() {
|
|
719
|
+
this.queue.close();
|
|
720
|
+
this.onClose(this);
|
|
721
|
+
}
|
|
722
|
+
next() {
|
|
723
|
+
return this.queue.next();
|
|
724
|
+
}
|
|
725
|
+
async return() {
|
|
726
|
+
if (!this.queue.isClosed) {
|
|
727
|
+
this.queue.close();
|
|
728
|
+
this.onClose(this);
|
|
729
|
+
}
|
|
730
|
+
return { value: undefined, done: true };
|
|
731
|
+
}
|
|
732
|
+
async throw(err) {
|
|
733
|
+
this.queue.close();
|
|
734
|
+
this.onClose(this);
|
|
735
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
736
|
+
}
|
|
737
|
+
[Symbol.asyncIterator]() {
|
|
738
|
+
return this;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/** Default cap on the paused-queue size. */
|
|
742
|
+
const DEFAULT_PAUSED_QUEUE_MAX = 100;
|
|
743
|
+
/**
|
|
744
|
+
* Marker `session_id` stamped on synthetic `envelope_parse_error` events
|
|
745
|
+
* the SDK generates when an inbound frame fails JSON-schema parsing.
|
|
746
|
+
* Never crosses the wire — consumers seeing it via `client.receive()`
|
|
747
|
+
* should recognize the zero pattern as "synthetic, not server-derived".
|
|
748
|
+
*
|
|
749
|
+
* The zero-UUID is not used as a wire-side cold-address fallback (cold
|
|
750
|
+
* cancel / continue / resubscribe / respond throw
|
|
751
|
+
* {@link QodoColdAddressError} locally before encoding). This constant
|
|
752
|
+
* is dedicated to the in-memory synthetic-event path.
|
|
753
|
+
*/
|
|
754
|
+
const SYNTHETIC_PARSE_ERROR_SESSION_ID = asSessionId('00000000-0000-0000-0000-000000000000');
|
|
755
|
+
/** Default reconnect policy — three attempts at 1s/2s/4s, then give up. */
|
|
756
|
+
const DEFAULT_RECONNECT_MAX_ATTEMPTS = 3;
|
|
757
|
+
const DEFAULT_RECONNECT_INITIAL_BACKOFF_MS = 1000;
|
|
758
|
+
const DEFAULT_RECONNECT_BACKOFF_MULTIPLIER = 2;
|
|
759
|
+
/**
|
|
760
|
+
* Outbound kinds the SDK throttles when the connection is in `flow.pause`
|
|
761
|
+
* state. `tool.response` is the server's blocked party — never throttled.
|
|
762
|
+
* `task.cancel` is user intent to bail — never throttled.
|
|
763
|
+
* `task.resubscribe` is a recovery path and stays unthrottled too: holding a
|
|
764
|
+
* resubscribe behind backpressure would defeat the point of replay.
|
|
765
|
+
*/
|
|
766
|
+
const THROTTLED_OUTBOUND_KINDS = new Set([
|
|
767
|
+
'task.start',
|
|
768
|
+
'task.continue',
|
|
769
|
+
]);
|
|
770
|
+
/**
|
|
771
|
+
* Live connection state. Owns the (replaceable) transport and the set of
|
|
772
|
+
* subscriptions; routes inbound envelopes to subscriptions via `consider`.
|
|
773
|
+
*
|
|
774
|
+
* **No connection-level `sessionId`.** Every `task.start` creates a NEW
|
|
775
|
+
* server-derived session, so an envelope's `session_id` is the TASK's
|
|
776
|
+
* session — not a property of the WebSocket. The SDK tracks
|
|
777
|
+
* `task_id → session_id` and `tool_call_id → session_id` here so that
|
|
778
|
+
* outbound ongoing envelopes can be stamped with the right session at
|
|
779
|
+
* encode time without coupling consumer code to per-task session
|
|
780
|
+
* bookkeeping.
|
|
781
|
+
*/
|
|
782
|
+
export class Connection {
|
|
783
|
+
factory;
|
|
784
|
+
url;
|
|
785
|
+
headers;
|
|
786
|
+
traceContext;
|
|
787
|
+
metrics;
|
|
788
|
+
subscriptions = new Set();
|
|
789
|
+
/**
|
|
790
|
+
* Map of `task_id → session_id` populated from inbound `task.started`
|
|
791
|
+
* envelopes. Read by {@link sendEnvelope} when encoding `task.continue`
|
|
792
|
+
* / `task.cancel` / `task.resubscribe` so each ongoing envelope
|
|
793
|
+
* carries the same server-derived `session_id` the server bound during
|
|
794
|
+
* admission — preventing multi-task `session_mismatch` without
|
|
795
|
+
* exposing per-task session bookkeeping on the public API.
|
|
796
|
+
*
|
|
797
|
+
* Lifetime: entries are added when `task.started` lands and PRUNED on
|
|
798
|
+
* `task.done` (the wire's terminal ack — task is fully finished). Any
|
|
799
|
+
* later `task.resubscribe` against the same `task_id` would be a
|
|
800
|
+
* cross-process recovery path that has no in-memory session anyway, so
|
|
801
|
+
* pruning doesn't regress that scenario. Cleared on transport close.
|
|
802
|
+
* The auto-reconnect/replay path uses LIVE (non-terminated)
|
|
803
|
+
* subscriptions and adds the session back via the server's replayed
|
|
804
|
+
* `task.started`, so it's unaffected.
|
|
805
|
+
*
|
|
806
|
+
* **Pre-admission invariant.** A live `TaskSubscription` MAY lack an
|
|
807
|
+
* entry in this map: between `tasks.start` writing its outbound
|
|
808
|
+
* `task.start` and the inbound `task.started` ack landing, the
|
|
809
|
+
* subscription is registered in {@link subscriptions} but the
|
|
810
|
+
* `session_id` is not yet pinned here. A WS drop during this window
|
|
811
|
+
* leaves the subscription in an unrecoverable state per the
|
|
812
|
+
* session-identity contract. {@link replayActiveTasks} pre-checks this
|
|
813
|
+
* map before attempting `task.resubscribe` and short-circuits to
|
|
814
|
+
* `sub.fail(QodoColdAddressError)` for un-pinned subs — see that
|
|
815
|
+
* method's JSDoc for the state-machine narrative.
|
|
816
|
+
*/
|
|
817
|
+
taskSessions = new Map();
|
|
818
|
+
/**
|
|
819
|
+
* Map of `tool_call_id → session_id` populated from inbound `tool.request`
|
|
820
|
+
* envelopes. Read by {@link sendEnvelope} when encoding `tool.response`,
|
|
821
|
+
* which carries `responses: ToolResponseItem[]` and inherits the parent
|
|
822
|
+
* `tool.request`'s session via the first response item's `tool_call_id`.
|
|
823
|
+
*
|
|
824
|
+
* Lifetime: entries are added when `tool.request` lands and PRUNED on
|
|
825
|
+
* EITHER (a) the outbound `tool.response` hits the wire (single-use
|
|
826
|
+
* — one request, one response), OR (b) the owning task terminates
|
|
827
|
+
* via `task.done` without the consumer ever responding (manual handler
|
|
828
|
+
* returned `undefined` to indicate async-respond-later, then the task
|
|
829
|
+
* timed out / canceled / completed without the response). Without (b)
|
|
830
|
+
* the map would accumulate orphan `tool_call_id` entries on long-lived
|
|
831
|
+
* connections. Cleared on transport close.
|
|
832
|
+
*/
|
|
833
|
+
toolCallSessions = new Map();
|
|
834
|
+
/**
|
|
835
|
+
* Reverse index of `session_id → Set<ToolCallId>`. Populated on
|
|
836
|
+
* inbound `tool.request`
|
|
837
|
+
* (one entry per call). Used to sweep {@link toolCallSessions} when
|
|
838
|
+
* the owning task terminates without a `tool.response` ever being
|
|
839
|
+
* sent for some of its outstanding calls — the manual-handler
|
|
840
|
+
* respond-later path explicitly allows this, and without the sweep
|
|
841
|
+
* the per-call map entries would survive forever on long-lived
|
|
842
|
+
* connections.
|
|
843
|
+
*
|
|
844
|
+
* Indexed by `session_id` (not `task_id`) because that's the
|
|
845
|
+
* dimension `tool.request` envelopes carry on the wire — the SDK
|
|
846
|
+
* derives the owning session via the envelope's `session_id` field,
|
|
847
|
+
* not via any task_id field on the payload.
|
|
848
|
+
*/
|
|
849
|
+
outstandingToolCallsBySession = new Map();
|
|
850
|
+
/**
|
|
851
|
+
* Reverse index of outbound `task.continue` message_id →
|
|
852
|
+
* `{ taskId, sessionId }` for envelopes
|
|
853
|
+
* that carried a caller-supplied cold-address `sessionId` override.
|
|
854
|
+
* Populated AFTER the wire write (or queued-drain) succeeds; used to
|
|
855
|
+
* identify which `taskSessions` entry to prune when the server rejects
|
|
856
|
+
* the override with `error { code: 'session_mismatch' }`.
|
|
857
|
+
*
|
|
858
|
+
* Without this index, a wrong cold-address override would pollute
|
|
859
|
+
* {@link taskSessions} (because `sendEnvelope`'s post-send write
|
|
860
|
+
* persists the consumer-supplied value as authoritative) and a
|
|
861
|
+
* subsequent no-override call for the same task would silently reuse
|
|
862
|
+
* the already-rejected session instead of raising
|
|
863
|
+
* {@link QodoColdAddressError}.
|
|
864
|
+
*
|
|
865
|
+
* **Generation safety:** the entry stores the sessionId of the
|
|
866
|
+
* override the offending message carried. Pruning is
|
|
867
|
+
* conditional: we delete `taskSessions[taskId]` ONLY when the
|
|
868
|
+
* authoritative entry still matches the rejected sessionId. If a
|
|
869
|
+
* later send for the same task superseded the override with a fresh
|
|
870
|
+
* (valid) sessionId, the rejection for the stale message must NOT
|
|
871
|
+
* blow away the newer authoritative entry. The reverse-map entry for
|
|
872
|
+
* the offending message_id is deleted unconditionally (it's a per-
|
|
873
|
+
* message tombstone; the message is finished one way or the other).
|
|
874
|
+
*
|
|
875
|
+
* Entries are pruned (a) on inbound `error { code: 'session_mismatch' }`
|
|
876
|
+
* for a known offending_message_id (with generation check), and (b)
|
|
877
|
+
* on inbound `task.done` for the matching task_id (terminal sweep).
|
|
878
|
+
* Cleared on transport-terminal cleanup.
|
|
879
|
+
*/
|
|
880
|
+
outboundOverrideRefs = new Map();
|
|
881
|
+
state = 'connected';
|
|
882
|
+
/** App-level backpressure flag. Flipped by inbound `flow.pause` / `flow.resume`. */
|
|
883
|
+
paused = false;
|
|
884
|
+
/**
|
|
885
|
+
* FIFO of pre-encoded JSON frames that arrived while paused. Drained in
|
|
886
|
+
* insertion order on `flow.resume`. Pre-encoding at queue-time keeps the
|
|
887
|
+
* drain path a thin pass-through to the transport.
|
|
888
|
+
*
|
|
889
|
+
* Each entry is tagged with the envelope's `messageId` so the owning
|
|
890
|
+
* `TaskSubscription` can retract its frame on early termination (abort or
|
|
891
|
+
* iterator break) — without that link, a queued `task.continue` would
|
|
892
|
+
* still go to the wire on `flow.resume` after the consumer has already
|
|
893
|
+
* canceled, producing `cancel → continue` reordering on the server.
|
|
894
|
+
*/
|
|
895
|
+
pausedQueue = [];
|
|
896
|
+
pausedQueueMax;
|
|
897
|
+
/** Reconnect policy. */
|
|
898
|
+
reconnectMaxAttempts;
|
|
899
|
+
reconnectInitialBackoffMs;
|
|
900
|
+
reconnectBackoffMultiplier;
|
|
901
|
+
/**
|
|
902
|
+
* Pending backoff sleeps. Each entry pairs the timer handle with the
|
|
903
|
+
* Promise's resolve callback so `disconnect()` mid-reconnect can both
|
|
904
|
+
* cancel the timer AND wake the loop up so it observes the new state on
|
|
905
|
+
* the next iteration — without the resolver, clearing the timer would
|
|
906
|
+
* leave the await pending forever.
|
|
907
|
+
*/
|
|
908
|
+
pendingSleeps = new Set();
|
|
909
|
+
/** The active transport. Mutable — replaced on every successful reconnect. */
|
|
910
|
+
transport;
|
|
911
|
+
/** Bound handler instance reused across every transport (initial + reconnects). */
|
|
912
|
+
handlers;
|
|
913
|
+
constructor(factory, url, headers, initialTransport, handlers, maxPausedQueueSize, reconnect, traceContext, metrics) {
|
|
914
|
+
this.factory = factory;
|
|
915
|
+
this.url = url;
|
|
916
|
+
this.headers = headers;
|
|
917
|
+
this.traceContext = traceContext;
|
|
918
|
+
this.metrics = metrics;
|
|
919
|
+
this.transport = initialTransport;
|
|
920
|
+
this.handlers = handlers;
|
|
921
|
+
// Treat <=0 as "fall back to default" — a zero/negative cap would lock out
|
|
922
|
+
// every throttled send the moment pause arrives, which is never what a
|
|
923
|
+
// consumer wanted. They should opt in explicitly with a positive integer.
|
|
924
|
+
this.pausedQueueMax =
|
|
925
|
+
maxPausedQueueSize !== undefined && maxPausedQueueSize > 0
|
|
926
|
+
? maxPausedQueueSize
|
|
927
|
+
: DEFAULT_PAUSED_QUEUE_MAX;
|
|
928
|
+
this.reconnectMaxAttempts = pickNonNegativeInt(reconnect?.maxAttempts, DEFAULT_RECONNECT_MAX_ATTEMPTS);
|
|
929
|
+
this.reconnectInitialBackoffMs = pickPositiveInt(reconnect?.initialBackoffMs, DEFAULT_RECONNECT_INITIAL_BACKOFF_MS);
|
|
930
|
+
this.reconnectBackoffMultiplier = pickPositiveNumber(reconnect?.backoffMultiplier, DEFAULT_RECONNECT_BACKOFF_MULTIPLIER);
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Construct + open a fresh connection through the factory. The connection
|
|
934
|
+
* captures the factory + url + headers so it can re-open on transport drops
|
|
935
|
+
* without the caller threading them through again.
|
|
936
|
+
*/
|
|
937
|
+
static async open(args) {
|
|
938
|
+
// No connection-level `sessionId` — every `task.start` creates a
|
|
939
|
+
// NEW server-derived session, so the connection holds no session at
|
|
940
|
+
// all. Outbound encodes stamp the right per-Task session_id from
|
|
941
|
+
// {@link Connection.taskSessions} / {@link Connection.toolCallSessions}.
|
|
942
|
+
//
|
|
943
|
+
// Handlers are constructed before the `Connection` instance because
|
|
944
|
+
// the factory needs them. The default `ws` transport can't fire
|
|
945
|
+
// `onMessage` until 'open' resolves, so the original `connection?.…`
|
|
946
|
+
// closure was safe with the bundled transport. Custom transports —
|
|
947
|
+
// an in-memory mock, a non-`ws` browser adapter, a synchronous-replay
|
|
948
|
+
// test double — can fire callbacks from inside the factory call,
|
|
949
|
+
// *before* the `Connection` is assigned. With the original code, those
|
|
950
|
+
// early frames silently no-op'd through the `?.` chain. We instead
|
|
951
|
+
// queue inbound events until the `Connection` is wired and drain them
|
|
952
|
+
// synchronously on the next tick, preserving FIFO order.
|
|
953
|
+
let connection = null;
|
|
954
|
+
const pending = [];
|
|
955
|
+
const handlers = {
|
|
956
|
+
onMessage: (data) => {
|
|
957
|
+
if (connection !== null)
|
|
958
|
+
connection.dispatch(data);
|
|
959
|
+
else
|
|
960
|
+
pending.push({ kind: 'message', data });
|
|
961
|
+
},
|
|
962
|
+
onError: (err) => {
|
|
963
|
+
if (connection !== null)
|
|
964
|
+
connection.transportFailed(err);
|
|
965
|
+
else
|
|
966
|
+
pending.push({ kind: 'error', err });
|
|
967
|
+
},
|
|
968
|
+
onClose: (code, reason) => {
|
|
969
|
+
if (connection !== null)
|
|
970
|
+
connection.transportClosed(code, reason);
|
|
971
|
+
else
|
|
972
|
+
pending.push({ kind: 'close', code, reason });
|
|
973
|
+
},
|
|
974
|
+
};
|
|
975
|
+
const transport = await args.factory({
|
|
976
|
+
url: args.url,
|
|
977
|
+
headers: args.headers,
|
|
978
|
+
handlers,
|
|
979
|
+
});
|
|
980
|
+
connection = new Connection(args.factory, args.url, args.headers, transport, handlers, args.maxPausedQueueSize, args.reconnect, args.traceContext, args.metrics);
|
|
981
|
+
// Drain anything the transport fired synchronously inside the factory
|
|
982
|
+
// call. Order matters: messages, errors, closes must replay in the
|
|
983
|
+
// exact sequence they arrived. Track the first terminal event so that
|
|
984
|
+
// if the replay moves the connection out of `connected`, we surface
|
|
985
|
+
// that as a rejection from `open()` rather than handing back an
|
|
986
|
+
// already-broken connection to `QodoClient.connect()` — which would
|
|
987
|
+
// otherwise resolve, wire sub-clients, and let the failure go
|
|
988
|
+
// unobserved.
|
|
989
|
+
let terminalReason;
|
|
990
|
+
for (const ev of pending) {
|
|
991
|
+
if (ev.kind === 'message') {
|
|
992
|
+
connection.dispatch(ev.data);
|
|
993
|
+
}
|
|
994
|
+
else if (ev.kind === 'error') {
|
|
995
|
+
connection.transportFailed(ev.err);
|
|
996
|
+
terminalReason ??= ev.err;
|
|
997
|
+
}
|
|
998
|
+
else {
|
|
999
|
+
connection.transportClosed(ev.code, ev.reason);
|
|
1000
|
+
terminalReason ??= new Error(`Transport closed before connect() returned (code=${ev.code}, reason=${ev.reason || '<none>'})`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (!connection.isOpen) {
|
|
1004
|
+
throw (terminalReason ??
|
|
1005
|
+
new Error('Transport closed before connect() returned (no reason captured)'));
|
|
1006
|
+
}
|
|
1007
|
+
return connection;
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Encode and send an outbound envelope. Returns the assigned `message_id` so
|
|
1011
|
+
* the caller can track the parent_message_id chain.
|
|
1012
|
+
*
|
|
1013
|
+
* Optionally accepts a pre-allocated `messageId` so subscriptions can be
|
|
1014
|
+
* registered against a known root before the envelope hits the wire — avoids
|
|
1015
|
+
* a race where an inbound reply could be dispatched before the subscription
|
|
1016
|
+
* is in place.
|
|
1017
|
+
*
|
|
1018
|
+
* If the connection is in `flow.pause` and `out.kind` is throttled
|
|
1019
|
+
* (`task.start` / `task.continue`), the encoded frame is enqueued and drained
|
|
1020
|
+
* once `flow.resume` arrives. The returned `messageId` is still allocated
|
|
1021
|
+
* synchronously so callers can register subscriptions against it before the
|
|
1022
|
+
* envelope hits the wire — same invariant as the unpaused path.
|
|
1023
|
+
*
|
|
1024
|
+
* Throws `QodoBackpressureError` when the queue is at its cap.
|
|
1025
|
+
*/
|
|
1026
|
+
sendEnvelope(out, opts) {
|
|
1027
|
+
if (!this.canSend()) {
|
|
1028
|
+
throw new Error('Connection is not open');
|
|
1029
|
+
}
|
|
1030
|
+
const messageId = opts?.messageId ?? asMessageId(uuidv7());
|
|
1031
|
+
const parentMessageId = opts?.parentMessageId;
|
|
1032
|
+
// Wire-shape split: `task.start` (a `_StartEnvelopeBase`)
|
|
1033
|
+
// does NOT carry `session_id` on the wire — the server derives it from
|
|
1034
|
+
// `(tenant_id, payload.idempotency_key)` during the ingress
|
|
1035
|
+
// bind-and-derive phase. QAR's Pydantic model has `extra="forbid"`, so
|
|
1036
|
+
// a stray `session_id` field would fail server-side parsing. Build the
|
|
1037
|
+
// wire view WITHOUT `session_id` for `task.start`; every other kind
|
|
1038
|
+
// (`_OngoingEnvelopeBase`) MUST carry the per-Task session — resolved
|
|
1039
|
+
// below via taskSessions / toolCallSessions / explicit override.
|
|
1040
|
+
const baseCommon = {
|
|
1041
|
+
envelope_version: 1,
|
|
1042
|
+
message_id: messageId,
|
|
1043
|
+
...(parentMessageId !== undefined ? { parent_message_id: parentMessageId } : {}),
|
|
1044
|
+
// Read the live trace context at emit-time so the active OTel span
|
|
1045
|
+
// (typically the per-public-API span the client is currently inside)
|
|
1046
|
+
// becomes the parent of QAR's server-side WS-upgrade span. Without
|
|
1047
|
+
// OTel configured, this is a fresh random 55-char traceparent — the
|
|
1048
|
+
// server's schema enforces a non-empty match, so an actually-empty
|
|
1049
|
+
// string would fail validation rather than continue the trace.
|
|
1050
|
+
trace_context: this.traceContext.current(),
|
|
1051
|
+
ts: new Date().toISOString(),
|
|
1052
|
+
};
|
|
1053
|
+
let json;
|
|
1054
|
+
if (out.kind === 'task.start') {
|
|
1055
|
+
json = JSON.stringify({
|
|
1056
|
+
...baseCommon,
|
|
1057
|
+
kind: 'task.start',
|
|
1058
|
+
payload: out.payload,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
else if (out.kind === 'task.forceResume') {
|
|
1062
|
+
// `task.forceResume` is a `_StartEnvelopeBase` shape: it operates
|
|
1063
|
+
// on the consumer's `idempotency_key` rather than a server-bound
|
|
1064
|
+
// `session_id`. QAR derives the session UUID server-side using the
|
|
1065
|
+
// same `uuidv5(QAR_NS_V1, tenant_id + ":" + idempotency_key)`
|
|
1066
|
+
// derivation as `task.start` admission, so a stray outbound
|
|
1067
|
+
// `session_id` would (a) be redundant and (b) fail Pydantic
|
|
1068
|
+
// `extra="forbid"` validation on the QAR side. Strip the field at
|
|
1069
|
+
// emit-time, matching the `task.start` path.
|
|
1070
|
+
json = JSON.stringify({
|
|
1071
|
+
...baseCommon,
|
|
1072
|
+
kind: 'task.forceResume',
|
|
1073
|
+
payload: out.payload,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
else {
|
|
1077
|
+
const sessionId = this.resolveOutboundSessionId(out, opts?.sessionId);
|
|
1078
|
+
const base = { ...baseCommon, session_id: sessionId };
|
|
1079
|
+
let envelope;
|
|
1080
|
+
switch (out.kind) {
|
|
1081
|
+
case 'task.continue':
|
|
1082
|
+
envelope = { ...base, kind: 'task.continue', payload: out.payload };
|
|
1083
|
+
break;
|
|
1084
|
+
case 'task.cancel':
|
|
1085
|
+
envelope = { ...base, kind: 'task.cancel', payload: out.payload };
|
|
1086
|
+
break;
|
|
1087
|
+
case 'task.resubscribe':
|
|
1088
|
+
envelope = { ...base, kind: 'task.resubscribe', payload: out.payload };
|
|
1089
|
+
break;
|
|
1090
|
+
case 'tool.response':
|
|
1091
|
+
envelope = { ...base, kind: 'tool.response', payload: out.payload };
|
|
1092
|
+
break;
|
|
1093
|
+
}
|
|
1094
|
+
json = JSON.stringify(envelope);
|
|
1095
|
+
}
|
|
1096
|
+
// Capture the cold-address override info BEFORE the queue/send
|
|
1097
|
+
// branches, but DON'T mutate
|
|
1098
|
+
// {@link taskSessions} yet — that write must wait for send-success
|
|
1099
|
+
// (or for drain-success on the queued path). Stamping the
|
|
1100
|
+
// authoritative map at this point would leave a stale entry behind
|
|
1101
|
+
// if `transport.send` throws, the paused-queue overflows, or the
|
|
1102
|
+
// connection drops mid-pause before the queued frame drains.
|
|
1103
|
+
//
|
|
1104
|
+
// Only `task.continue` actually NEEDS the post-send save: a
|
|
1105
|
+
// subscription started from `tasks.continue(..., { sessionId })`
|
|
1106
|
+
// can trigger an internal `task.cancel` through TWO paths that
|
|
1107
|
+
// both go through `Connection.sendEnvelope` WITHOUT re-threading
|
|
1108
|
+
// the option:
|
|
1109
|
+
// (a) TaskSubscription.onEarlyReturn — iterator break sends a
|
|
1110
|
+
// best-effort `task.cancel` (the consumer's iterator break
|
|
1111
|
+
// implies cancel intent).
|
|
1112
|
+
// (b) `TaskClient.bindAbort` — the consumer's AbortSignal fires
|
|
1113
|
+
// a `task.cancel` with reason `'abort signal'`.
|
|
1114
|
+
// Without the post-send save, BOTH paths would raise
|
|
1115
|
+
// `QodoColdAddressError` (the connection has no in-memory
|
|
1116
|
+
// session for this task_id). The save unblocks (a) directly;
|
|
1117
|
+
// (b) is double-protected by `bindAbort` forwarding the
|
|
1118
|
+
// original `opts.sessionId` to its own `sendEnvelope` call.
|
|
1119
|
+
// `task.cancel` and `task.resubscribe` don't spawn follow-up
|
|
1120
|
+
// internal sends (the former is terminal; the latter passes
|
|
1121
|
+
// `suppressEarlyReturnCancel: true`), so they don't need to
|
|
1122
|
+
// leave behind a sticky entry — keeping the map tighter means
|
|
1123
|
+
// a future no-override call for the same task_id correctly
|
|
1124
|
+
// raises `QodoColdAddressError`.
|
|
1125
|
+
const overrideTaskSave = opts?.sessionId !== undefined && out.kind === 'task.continue'
|
|
1126
|
+
? { taskId: out.payload.task_id, sessionId: opts.sessionId }
|
|
1127
|
+
: undefined;
|
|
1128
|
+
if (this.paused && THROTTLED_OUTBOUND_KINDS.has(out.kind)) {
|
|
1129
|
+
if (this.pausedQueue.length >= this.pausedQueueMax) {
|
|
1130
|
+
// Throw BEFORE mutating any authoritative state (taskSessions,
|
|
1131
|
+
// pausedQueue itself) so a queue-overflow leaves zero side
|
|
1132
|
+
// effects from this call.
|
|
1133
|
+
throw new QodoBackpressureError(this.pausedQueue.length, this.pausedQueueMax);
|
|
1134
|
+
}
|
|
1135
|
+
this.pausedQueue.push({
|
|
1136
|
+
messageId,
|
|
1137
|
+
json,
|
|
1138
|
+
...(overrideTaskSave !== undefined ? { overrideToSave: overrideTaskSave } : {}),
|
|
1139
|
+
});
|
|
1140
|
+
return messageId;
|
|
1141
|
+
}
|
|
1142
|
+
this.transport.send(json);
|
|
1143
|
+
// Persist the cold-address override
|
|
1144
|
+
// ONLY AFTER the wire write returned without throwing. A
|
|
1145
|
+
// transport-mid-call failure now leaves no stale entry in
|
|
1146
|
+
// {@link taskSessions}, so a subsequent no-override call for the
|
|
1147
|
+
// same task_id correctly raises `QodoColdAddressError` instead of
|
|
1148
|
+
// silently reusing the never-sent override.
|
|
1149
|
+
if (overrideTaskSave !== undefined) {
|
|
1150
|
+
this.taskSessions.set(overrideTaskSave.taskId, overrideTaskSave.sessionId);
|
|
1151
|
+
// Note the outbound
|
|
1152
|
+
// message_id with the sessionId it carried so an inbound
|
|
1153
|
+
// `session_mismatch` for this frame can prune the polluted
|
|
1154
|
+
// authoritative entry above — but ONLY when the entry has not
|
|
1155
|
+
// been superseded by a fresh override for the same task.
|
|
1156
|
+
this.outboundOverrideRefs.set(messageId, {
|
|
1157
|
+
taskId: overrideTaskSave.taskId,
|
|
1158
|
+
sessionId: overrideTaskSave.sessionId,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
// Stamp `resubscribes_sent_total` only after the wire write returned
|
|
1162
|
+
// without throwing — counting before send would over-count when the
|
|
1163
|
+
// transport closes mid-call and `send` raises. `task.resubscribe`
|
|
1164
|
+
// isn't in `THROTTLED_OUTBOUND_KINDS`, so it
|
|
1165
|
+
// never enters the paused-queue branch above; the only success path is
|
|
1166
|
+
// the direct send.
|
|
1167
|
+
if (out.kind === 'task.resubscribe') {
|
|
1168
|
+
this.metrics?.recordResubscribeSent();
|
|
1169
|
+
}
|
|
1170
|
+
// Prune both forward and reverse session-index maps after the
|
|
1171
|
+
// outbound `tool.response` lands on the wire — each `tool_call_id`
|
|
1172
|
+
// is single-use (one request → one response per call). Bounds the
|
|
1173
|
+
// maps on long-lived connections running many tool calls.
|
|
1174
|
+
if (out.kind === 'tool.response') {
|
|
1175
|
+
for (const r of out.payload.responses) {
|
|
1176
|
+
const tcid = r.tool_call_id;
|
|
1177
|
+
const sid = this.toolCallSessions.get(tcid);
|
|
1178
|
+
this.toolCallSessions.delete(tcid);
|
|
1179
|
+
if (sid !== undefined) {
|
|
1180
|
+
const outstanding = this.outstandingToolCallsBySession.get(sid);
|
|
1181
|
+
if (outstanding !== undefined) {
|
|
1182
|
+
outstanding.delete(tcid);
|
|
1183
|
+
if (outstanding.size === 0) {
|
|
1184
|
+
this.outstandingToolCallsBySession.delete(sid);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return messageId;
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Resolve the per-Task `session_id` for an outbound ongoing envelope.
|
|
1194
|
+
* Caller-supplied `override` wins (the dispatcher path passes the
|
|
1195
|
+
* captured inbound `session_id` directly, and the cold-address
|
|
1196
|
+
* public-API path passes the consumer-supplied option); otherwise look
|
|
1197
|
+
* up by the payload's task_id (`task.continue` / `task.cancel` /
|
|
1198
|
+
* `task.resubscribe`) or by the first response item's tool_call_id
|
|
1199
|
+
* (`tool.response`).
|
|
1200
|
+
*
|
|
1201
|
+
* When neither an override NOR an in-memory entry is available, throw
|
|
1202
|
+
* {@link QodoColdAddressError} synchronously. Throwing locally:
|
|
1203
|
+
*
|
|
1204
|
+
* - puts the cold-address remediation right in the error message
|
|
1205
|
+
* (consumer should pass `sessionId` from durable storage),
|
|
1206
|
+
* - saves a wire round-trip + the misleading `session_mismatch`
|
|
1207
|
+
* error message ("envelope session_id differs from connection
|
|
1208
|
+
* session" — true but unactionable),
|
|
1209
|
+
* - matches the doctrinal correct shape (make it right, not make
|
|
1210
|
+
* it work).
|
|
1211
|
+
*
|
|
1212
|
+
* The primary cross-task session-leak guard is the lookup itself when
|
|
1213
|
+
* admission HAS completed.
|
|
1214
|
+
*/
|
|
1215
|
+
resolveOutboundSessionId(out, override) {
|
|
1216
|
+
if (override !== undefined)
|
|
1217
|
+
return override;
|
|
1218
|
+
switch (out.kind) {
|
|
1219
|
+
case 'task.continue':
|
|
1220
|
+
case 'task.cancel':
|
|
1221
|
+
case 'task.resubscribe': {
|
|
1222
|
+
const taskId = out.payload.task_id;
|
|
1223
|
+
const sessionId = this.taskSessions.get(taskId);
|
|
1224
|
+
if (sessionId === undefined) {
|
|
1225
|
+
throw new QodoColdAddressError('task', taskId);
|
|
1226
|
+
}
|
|
1227
|
+
return sessionId;
|
|
1228
|
+
}
|
|
1229
|
+
case 'tool.response': {
|
|
1230
|
+
const first = out.payload.responses[0];
|
|
1231
|
+
// `responses` is validated non-empty by `ToolClient.respond` /
|
|
1232
|
+
// the auto-dispatch path; reach here only via direct internal
|
|
1233
|
+
// mis-use, so the throw is a hard invariant. Tag the empty case
|
|
1234
|
+
// explicitly to make the violated invariant visible.
|
|
1235
|
+
if (first === undefined) {
|
|
1236
|
+
throw new QodoColdAddressError('tool_call', '<empty-responses-array>');
|
|
1237
|
+
}
|
|
1238
|
+
const toolCallId = first.tool_call_id;
|
|
1239
|
+
const sessionId = this.toolCallSessions.get(toolCallId);
|
|
1240
|
+
if (sessionId === undefined) {
|
|
1241
|
+
throw new QodoColdAddressError('tool_call', toolCallId);
|
|
1242
|
+
}
|
|
1243
|
+
return sessionId;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Retract a queued throttled envelope by its `message_id`. Called by
|
|
1249
|
+
* `TaskSubscription` when it terminates early (abort signal, iterator
|
|
1250
|
+
* break, transport failure) so the consumer's cancel intent doesn't race
|
|
1251
|
+
* with their own queued frame on the next `flow.resume`.
|
|
1252
|
+
*
|
|
1253
|
+
* No-op if the messageId isn't queued (already drained, never queued, or
|
|
1254
|
+
* a different envelope kind that doesn't enter the throttle path).
|
|
1255
|
+
* Returns whether an entry was removed — informational only.
|
|
1256
|
+
*/
|
|
1257
|
+
dropQueued(messageId) {
|
|
1258
|
+
if (this.pausedQueue.length === 0)
|
|
1259
|
+
return false;
|
|
1260
|
+
const idx = this.pausedQueue.findIndex((entry) => entry.messageId === messageId);
|
|
1261
|
+
if (idx === -1)
|
|
1262
|
+
return false;
|
|
1263
|
+
this.pausedQueue.splice(idx, 1);
|
|
1264
|
+
return true;
|
|
1265
|
+
}
|
|
1266
|
+
/** Send a raw envelope verbatim — escape hatch. */
|
|
1267
|
+
sendRawEnvelope(env) {
|
|
1268
|
+
if (!this.canSend()) {
|
|
1269
|
+
throw new Error('Connection is not open');
|
|
1270
|
+
}
|
|
1271
|
+
this.transport.send(JSON.stringify(env));
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Look up the server-derived `session_id` for a known `task_id`.
|
|
1275
|
+
* Used by span recorders that want to stamp `qar.session_id`
|
|
1276
|
+
* on per-Task spans without coupling caller code to the per-Task lookup
|
|
1277
|
+
* map. Returns `undefined` when no `task.started` ack has arrived for
|
|
1278
|
+
* this `task_id` yet (the start path's span is opened pre-admission and
|
|
1279
|
+
* must tolerate this).
|
|
1280
|
+
*/
|
|
1281
|
+
getSessionForTask(taskId) {
|
|
1282
|
+
return this.taskSessions.get(taskId);
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Look up the `session_id` of the inbound `tool.request` a given
|
|
1286
|
+
* `tool_call_id` came from. Used by `ToolClient.respond` to stamp
|
|
1287
|
+
* its outbound `tool.response` span with the right session without
|
|
1288
|
+
* threading the value through every call site. Returns `undefined` when
|
|
1289
|
+
* no matching `tool.request` arrived (test-only path, or a malformed
|
|
1290
|
+
* consumer call).
|
|
1291
|
+
*/
|
|
1292
|
+
getSessionForToolCall(toolCallId) {
|
|
1293
|
+
return this.toolCallSessions.get(toolCallId);
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Run `fn` inside the span context of the live `TaskSubscription` that
|
|
1297
|
+
* OWNS `env` by lineage, so an inbound `tool.request` dispatched to the
|
|
1298
|
+
* consumer's handler inherits the originating turn's trace context (its
|
|
1299
|
+
* `qar.client.task.*` span). Without this, the fanout invokes the handler
|
|
1300
|
+
* under the bare WS-receive context and the SDK's `qar.client.tool.handler`
|
|
1301
|
+
* span — plus the consumer's outbound MCP/fetch traceparent — are orphaned
|
|
1302
|
+
* from the task trace.
|
|
1303
|
+
*
|
|
1304
|
+
* Ownership is the unique subscription whose {@link TaskSubscription.ownsLineage}
|
|
1305
|
+
* holds for `env` — its `parent_message_id` traces (through real
|
|
1306
|
+
* `parent_message_id` links) back to that subscription's own outbound root.
|
|
1307
|
+
* This is unambiguous even when same-task subscriptions overlap (a live
|
|
1308
|
+
* auto-paused `tasks.start` sub alongside an active `tasks.continue`): the
|
|
1309
|
+
* passive sub absorbs the active turn's stream into its routing chain via
|
|
1310
|
+
* the `task_id` fallback, but never into its ownership lineage, so it never
|
|
1311
|
+
* wins — at any tool-call depth (multi-round T1→D2→T2).
|
|
1312
|
+
*
|
|
1313
|
+
* Terminated subscriptions are skipped (a live task owns an in-flight tool
|
|
1314
|
+
* call). Falls through to `fn()` unchanged when no owner is found — a strict
|
|
1315
|
+
* no-op for non-OTel consumers, for a `tool.request` whose lineage root was
|
|
1316
|
+
* never sent by this process (cold `task.resubscribe` replay), and for an
|
|
1317
|
+
* owner with no span.
|
|
1318
|
+
*/
|
|
1319
|
+
runInOwningTaskContext(env, fn) {
|
|
1320
|
+
for (const sub of this.subscriptions) {
|
|
1321
|
+
if (sub instanceof TaskSubscription && !sub.isTerminated && sub.ownsLineage(env)) {
|
|
1322
|
+
return sub.runInTaskContext(fn);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
return fn();
|
|
1326
|
+
}
|
|
1327
|
+
/** Register a subscription. Caller is responsible for unregistering on close. */
|
|
1328
|
+
subscribe(sub) {
|
|
1329
|
+
this.subscriptions.add(sub);
|
|
1330
|
+
}
|
|
1331
|
+
unsubscribe(sub) {
|
|
1332
|
+
this.subscriptions.delete(sub);
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Tear down: close transport, fail all subscriptions cleanly. Idempotent.
|
|
1336
|
+
* Called by `QodoClient.disconnect()` and by the `onClose` transport handler
|
|
1337
|
+
* for clean closes. Mid-reconnect calls switch state to `disconnecting` so
|
|
1338
|
+
* the in-flight loop bails on the next tick.
|
|
1339
|
+
*/
|
|
1340
|
+
close() {
|
|
1341
|
+
if (this.state === 'disconnected' || this.state === 'failed')
|
|
1342
|
+
return;
|
|
1343
|
+
const wasReconnecting = this.state === 'reconnecting';
|
|
1344
|
+
this.state = wasReconnecting ? 'disconnecting' : 'disconnected';
|
|
1345
|
+
this.clearBackpressureState();
|
|
1346
|
+
this.clearTerminalState();
|
|
1347
|
+
this.clearPendingTimers();
|
|
1348
|
+
try {
|
|
1349
|
+
this.transport.close(1000, 'client disconnect');
|
|
1350
|
+
}
|
|
1351
|
+
catch {
|
|
1352
|
+
// Best-effort — transport may be wedged after a failure.
|
|
1353
|
+
}
|
|
1354
|
+
if (!wasReconnecting) {
|
|
1355
|
+
// No outstanding async work — close subs synchronously.
|
|
1356
|
+
for (const sub of [...this.subscriptions]) {
|
|
1357
|
+
sub.close();
|
|
1358
|
+
}
|
|
1359
|
+
this.subscriptions.clear();
|
|
1360
|
+
}
|
|
1361
|
+
// For wasReconnecting=true: the reconnect loop sees `disconnecting`
|
|
1362
|
+
// on its next iteration and walks the close path itself, including
|
|
1363
|
+
// sub cleanup. This avoids a race where two paths both try to close
|
|
1364
|
+
// the same subscriptions.
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Drop any queued envelopes and clear the paused flag. Called on every
|
|
1368
|
+
* tear-down path (clean close, transport error, transport close) — leaving
|
|
1369
|
+
* frames in the queue past disconnect would either leak memory or surface
|
|
1370
|
+
* stale outbound on a future connection that reused this object.
|
|
1371
|
+
*
|
|
1372
|
+
* Does NOT clear the per-Task / per-Tool-call session-id lookup maps —
|
|
1373
|
+
* those must survive the reconnect window so `replayActiveTasks` can
|
|
1374
|
+
* stamp the right `session_id` on each `task.resubscribe` envelope it
|
|
1375
|
+
* emits. Final cleanup of the session maps lives in
|
|
1376
|
+
* {@link clearTerminalState}, invoked from the actual tear-down paths.
|
|
1377
|
+
*/
|
|
1378
|
+
clearBackpressureState() {
|
|
1379
|
+
this.paused = false;
|
|
1380
|
+
this.pausedQueue.length = 0;
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* Drop the per-Task and per-Tool-call session-id lookup maps. Called
|
|
1384
|
+
* from terminal cleanup paths (`close`,
|
|
1385
|
+
* `cleanCloseAndCleanup`, `failHardAndCleanup`, `finalizeDisconnect`) so
|
|
1386
|
+
* a future `connect()` reusing the same `Connection` object starts with
|
|
1387
|
+
* an empty session-id table. Distinct from {@link clearBackpressureState}
|
|
1388
|
+
* because the reconnect path MUST keep these populated for replay.
|
|
1389
|
+
*/
|
|
1390
|
+
clearTerminalState() {
|
|
1391
|
+
this.taskSessions.clear();
|
|
1392
|
+
this.toolCallSessions.clear();
|
|
1393
|
+
this.outstandingToolCallsBySession.clear();
|
|
1394
|
+
this.outboundOverrideRefs.clear();
|
|
1395
|
+
}
|
|
1396
|
+
/** Whether the connection accepts outbound sends right now. */
|
|
1397
|
+
get isOpen() {
|
|
1398
|
+
return this.state === 'connected' && this.transport.isOpen;
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Whether the connection is mid-reconnect. `isOpen` is false during this
|
|
1402
|
+
* window; outbound sends throw. Tests + observability surfaces read this
|
|
1403
|
+
* to render a "reconnecting" indicator.
|
|
1404
|
+
*/
|
|
1405
|
+
get isReconnecting() {
|
|
1406
|
+
return this.state === 'reconnecting';
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Whether the server has signalled `flow.pause` and we have not yet seen
|
|
1410
|
+
* `flow.resume`. While `true`, throttled outbound kinds are queued.
|
|
1411
|
+
*/
|
|
1412
|
+
get isPaused() {
|
|
1413
|
+
return this.paused;
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* Snapshot of the paused-queue depth. Useful for tests and observability;
|
|
1417
|
+
* not a live counter (no subscription, no event).
|
|
1418
|
+
*/
|
|
1419
|
+
get pausedQueueDepth() {
|
|
1420
|
+
return this.pausedQueue.length;
|
|
1421
|
+
}
|
|
1422
|
+
/** Raw outbound predicate — connected AND transport is in OPEN state. */
|
|
1423
|
+
canSend() {
|
|
1424
|
+
return this.state === 'connected' && this.transport.isOpen;
|
|
1425
|
+
}
|
|
1426
|
+
dispatch(text) {
|
|
1427
|
+
if (this.state === 'disconnected' || this.state === 'failed')
|
|
1428
|
+
return;
|
|
1429
|
+
let env;
|
|
1430
|
+
try {
|
|
1431
|
+
env = parseEnvelope(text);
|
|
1432
|
+
}
|
|
1433
|
+
catch (err) {
|
|
1434
|
+
// Malformed inbound — synthesize an `error` envelope so consumers see it
|
|
1435
|
+
// rather than silently dropping the frame. The synthetic carries no
|
|
1436
|
+
// parent_message_id, so it won't claim any task subscription; raw taps
|
|
1437
|
+
// see it, task iterators don't.
|
|
1438
|
+
//
|
|
1439
|
+
// The synthetic never reaches the wire, but the `Envelope` shape
|
|
1440
|
+
// requires a `session_id`. Use the zero UUID as a
|
|
1441
|
+
// marker — the value is opaque to consumers (the only ones that
|
|
1442
|
+
// see this are raw taps via `client.receive()`) and the zero pattern
|
|
1443
|
+
// makes "parse-error synthetic" cheap to recognize in logs.
|
|
1444
|
+
const synthetic = {
|
|
1445
|
+
envelope_version: 1,
|
|
1446
|
+
message_id: asMessageId(uuidv7()),
|
|
1447
|
+
session_id: SYNTHETIC_PARSE_ERROR_SESSION_ID,
|
|
1448
|
+
trace_context: this.traceContext.current(),
|
|
1449
|
+
ts: new Date().toISOString(),
|
|
1450
|
+
kind: 'error',
|
|
1451
|
+
payload: {
|
|
1452
|
+
code: 'envelope_parse_error',
|
|
1453
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1454
|
+
},
|
|
1455
|
+
};
|
|
1456
|
+
this.fanout(synthetic);
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
// Capture per-Task `session_id` from the admission ack BEFORE
|
|
1460
|
+
// fanout so any subscription that synchronously re-enters
|
|
1461
|
+
// `sendEnvelope` (e.g. a tool-handler completing inside the same tick
|
|
1462
|
+
// that received `task.started`) sees the right session for outbound
|
|
1463
|
+
// ongoing envelopes. Same pattern for `tool.request` → `tool_call_id`
|
|
1464
|
+
// mapping, populated for every call in the batched payload.
|
|
1465
|
+
//
|
|
1466
|
+
// Pruning: a long-lived connection running
|
|
1467
|
+
// many sequential tasks / tool calls would accumulate entries
|
|
1468
|
+
// unboundedly without active eviction. Prune `taskSessions` on
|
|
1469
|
+
// `task.done` (the wire's terminal ack — task is fully finished;
|
|
1470
|
+
// post-done `task.resubscribe` for cross-pod recovery doesn't have
|
|
1471
|
+
// the session in-memory either way) and let `tool.response` send
|
|
1472
|
+
// prune `toolCallSessions` (each response item's `tool_call_id` is
|
|
1473
|
+
// the last reference). Reconnect/replay isn't affected because the
|
|
1474
|
+
// replay path uses `task.resubscribe` against a still-LIVE
|
|
1475
|
+
// subscription's `task_id`, which is added back to the map on the
|
|
1476
|
+
// server's replayed `task.started`.
|
|
1477
|
+
if (env.kind === 'task.started') {
|
|
1478
|
+
const payload = env.payload;
|
|
1479
|
+
this.taskSessions.set(payload.task_id, env.session_id);
|
|
1480
|
+
}
|
|
1481
|
+
else if (env.kind === 'task.force_resumed') {
|
|
1482
|
+
// `task.force_resumed` is the ack for `task.forceResume` — the
|
|
1483
|
+
// recovered session's existing `task_id` is on the payload, and
|
|
1484
|
+
// the inherited `session_id` is the derived UUID. Bind the
|
|
1485
|
+
// per-Task session map so a subsequent
|
|
1486
|
+
// `tasks.continue(taskId)` resolves the right session at
|
|
1487
|
+
// outbound encode time without forcing the consumer to thread
|
|
1488
|
+
// `sessionId` through every call.
|
|
1489
|
+
const payload = env.payload;
|
|
1490
|
+
this.taskSessions.set(payload.task_id, env.session_id);
|
|
1491
|
+
}
|
|
1492
|
+
else if (env.kind === 'task.done') {
|
|
1493
|
+
// Cast through `unknown` because the codegen payload's `task_id` is
|
|
1494
|
+
// the unbranded `string` shape; the SDK overlay brands it as
|
|
1495
|
+
// `TaskId` everywhere else but the inbound parse path doesn't
|
|
1496
|
+
// re-brand. Safe — the wire value IS a TaskId by invariant.
|
|
1497
|
+
const payload = env.payload;
|
|
1498
|
+
const taskId = payload.task_id;
|
|
1499
|
+
const sessionForTask = this.taskSessions.get(taskId);
|
|
1500
|
+
this.taskSessions.delete(taskId);
|
|
1501
|
+
// Sweep any outbound-override
|
|
1502
|
+
// refs pointing at this task — the task is terminal, no further
|
|
1503
|
+
// sends can need the message_id → {taskId, sessionId} lookup.
|
|
1504
|
+
for (const [mid, ref] of this.outboundOverrideRefs) {
|
|
1505
|
+
if (ref.taskId === taskId)
|
|
1506
|
+
this.outboundOverrideRefs.delete(mid);
|
|
1507
|
+
}
|
|
1508
|
+
// Sweep any outstanding tool_call_ids that were never responded
|
|
1509
|
+
// to before
|
|
1510
|
+
// the task terminated (manual handler returned `undefined` then
|
|
1511
|
+
// the task timed out / canceled / completed without a response).
|
|
1512
|
+
if (sessionForTask !== undefined) {
|
|
1513
|
+
const outstanding = this.outstandingToolCallsBySession.get(sessionForTask);
|
|
1514
|
+
if (outstanding !== undefined) {
|
|
1515
|
+
for (const tcid of outstanding) {
|
|
1516
|
+
this.toolCallSessions.delete(tcid);
|
|
1517
|
+
}
|
|
1518
|
+
this.outstandingToolCallsBySession.delete(sessionForTask);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
else if (env.kind === 'error') {
|
|
1523
|
+
// A `session_mismatch` error against an outbound that carried a
|
|
1524
|
+
// caller-supplied cold-address override means THAT override was
|
|
1525
|
+
// wrong. Prune the polluted entry in `taskSessions` so a
|
|
1526
|
+
// subsequent no-override call raises `QodoColdAddressError` —
|
|
1527
|
+
// BUT only when the authoritative entry still matches the
|
|
1528
|
+
// rejected session. If a later send superseded this override
|
|
1529
|
+
// with a newer (valid) sessionId for the same task, the stale
|
|
1530
|
+
// mismatch must NOT blow away the newer entry (generation
|
|
1531
|
+
// safety).
|
|
1532
|
+
//
|
|
1533
|
+
// The reverse-map tombstone for the offending message_id is
|
|
1534
|
+
// always deleted: that message is finished one way or the other.
|
|
1535
|
+
const errorPayload = env.payload;
|
|
1536
|
+
if (errorPayload.code === 'session_mismatch') {
|
|
1537
|
+
const offendingId = typeof errorPayload.offending_message_id === 'string'
|
|
1538
|
+
? errorPayload.offending_message_id
|
|
1539
|
+
: undefined;
|
|
1540
|
+
if (offendingId !== undefined) {
|
|
1541
|
+
const ref = this.outboundOverrideRefs.get(offendingId);
|
|
1542
|
+
if (ref !== undefined) {
|
|
1543
|
+
const authoritative = this.taskSessions.get(ref.taskId);
|
|
1544
|
+
if (authoritative === ref.sessionId) {
|
|
1545
|
+
// The override is STILL the authoritative entry — it
|
|
1546
|
+
// hasn't been superseded by a fresher send. Prune.
|
|
1547
|
+
this.taskSessions.delete(ref.taskId);
|
|
1548
|
+
}
|
|
1549
|
+
// The reverse-map entry is per-message; remove
|
|
1550
|
+
// unconditionally now that this message has resolved
|
|
1551
|
+
// (server rejection counts as resolution).
|
|
1552
|
+
this.outboundOverrideRefs.delete(offendingId);
|
|
1553
|
+
}
|
|
1554
|
+
// `tool.response` cleanup: even though the per-send prune
|
|
1555
|
+
// already removes the tool_call_id mapping post-send, a
|
|
1556
|
+
// wrong override on `tool.response` would have polluted
|
|
1557
|
+
// `toolCallSessions` before the prune ran (the wire send
|
|
1558
|
+
// succeeds before the prune; only the server's rejection
|
|
1559
|
+
// arrives later). The post-send prune already deletes the
|
|
1560
|
+
// entry on success, so by the time the session_mismatch
|
|
1561
|
+
// lands the entry is gone — nothing to clean. This branch
|
|
1562
|
+
// is here for symmetry with the task-id path; documented
|
|
1563
|
+
// so future changes don't reintroduce a leak.
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
else if (env.kind === 'tool.request') {
|
|
1568
|
+
const calls = env.payload.calls;
|
|
1569
|
+
let outstanding = this.outstandingToolCallsBySession.get(env.session_id);
|
|
1570
|
+
if (outstanding === undefined) {
|
|
1571
|
+
outstanding = new Set();
|
|
1572
|
+
this.outstandingToolCallsBySession.set(env.session_id, outstanding);
|
|
1573
|
+
}
|
|
1574
|
+
for (const c of calls) {
|
|
1575
|
+
const tcid = c.tool_call_id;
|
|
1576
|
+
this.toolCallSessions.set(tcid, env.session_id);
|
|
1577
|
+
outstanding.add(tcid);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
// App-level backpressure. Update internal state BEFORE fanout so
|
|
1581
|
+
// consumers reading `isPaused` from a TaskEvent handler see the new value.
|
|
1582
|
+
// For `flow.resume`, drain the queue first too — any throttled send the
|
|
1583
|
+
// consumer issues from the same handler then takes the unpaused fast path.
|
|
1584
|
+
if (env.kind === 'flow.pause') {
|
|
1585
|
+
this.paused = true;
|
|
1586
|
+
}
|
|
1587
|
+
else if (env.kind === 'flow.resume') {
|
|
1588
|
+
this.paused = false;
|
|
1589
|
+
this.drainPausedQueue();
|
|
1590
|
+
}
|
|
1591
|
+
this.fanout(env);
|
|
1592
|
+
// Surface `task.canceling` to task iterators as a
|
|
1593
|
+
// `qar.client.cancel_acked` synthetic client event. The envelope
|
|
1594
|
+
// itself still went through `fanout` so raw `client.receive()` taps
|
|
1595
|
+
// observe the wire frame; the synthetic event is what reaches
|
|
1596
|
+
// consumers iterating via `for await (const event of
|
|
1597
|
+
// client.tasks.start(...))` without expanding the `TaskEvent`
|
|
1598
|
+
// switch surface.
|
|
1599
|
+
if (env.kind === 'task.canceling') {
|
|
1600
|
+
this.dispatchCancelAcked(env);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* Translate an inbound `task.canceling` envelope into a typed
|
|
1605
|
+
* `ClientCancelAckedEvent` and route it to the subscription whose chain
|
|
1606
|
+
* (or `task_id`) matches. Scoped — not connection-wide — so a consumer
|
|
1607
|
+
* running multiple tasks only sees the ack for the task they're
|
|
1608
|
+
* iterating.
|
|
1609
|
+
*
|
|
1610
|
+
* Skips taps (`RawSubscription` already received the envelope through
|
|
1611
|
+
* `fanout`); fans into `TaskSubscription` (matching by `task_id` /
|
|
1612
|
+
* chain) and observer ports (no-op `considerClient`).
|
|
1613
|
+
*/
|
|
1614
|
+
dispatchCancelAcked(env) {
|
|
1615
|
+
const payload = env.payload;
|
|
1616
|
+
const event = {
|
|
1617
|
+
kind: 'qar.client.cancel_acked',
|
|
1618
|
+
task_id: payload.task_id,
|
|
1619
|
+
...(typeof payload.received_by_pod === 'string'
|
|
1620
|
+
? { receivedByPod: payload.received_by_pod }
|
|
1621
|
+
: {}),
|
|
1622
|
+
...(typeof payload.owning_pod === 'string'
|
|
1623
|
+
? { owningPod: payload.owning_pod }
|
|
1624
|
+
: {}),
|
|
1625
|
+
...(typeof payload.cancel_reason === 'string'
|
|
1626
|
+
? { cancelReason: payload.cancel_reason }
|
|
1627
|
+
: {}),
|
|
1628
|
+
...(typeof payload.expected_terminal_within_s === 'number'
|
|
1629
|
+
? { expectedTerminalWithinS: payload.expected_terminal_within_s }
|
|
1630
|
+
: {}),
|
|
1631
|
+
};
|
|
1632
|
+
const snapshot = [...this.subscriptions];
|
|
1633
|
+
for (const sub of snapshot) {
|
|
1634
|
+
if (sub instanceof TaskSubscription) {
|
|
1635
|
+
// Only route to the subscription that owns this task. Other
|
|
1636
|
+
// task subscriptions on the same connection should not see a
|
|
1637
|
+
// cancel-ack for a peer task they don't drive.
|
|
1638
|
+
if (sub.matchesEnvelope(env)) {
|
|
1639
|
+
sub.considerClient(event);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
else {
|
|
1643
|
+
// Observer ports + raw taps: pass through. `KindObserverPort.considerClient`
|
|
1644
|
+
// is a no-op; `RawSubscription` queues the event for the raw tap so
|
|
1645
|
+
// consumers tapping `client.receive()` see both the wire envelope
|
|
1646
|
+
// (via fanout) AND the typed client event on one iterator.
|
|
1647
|
+
sub.considerClient(event);
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Flush the paused queue in FIFO order. Stops if the transport closes
|
|
1653
|
+
* mid-drain (a `transport.send` after close would throw and lose the
|
|
1654
|
+
* remaining frames silently). Caller is responsible for ensuring `paused`
|
|
1655
|
+
* is `false` before invoking — this method does not reset the flag.
|
|
1656
|
+
*
|
|
1657
|
+
* Drain failure preserves queue state. The earlier shape ("slice +
|
|
1658
|
+
* clear + iterate") dropped every
|
|
1659
|
+
* REMAINING entry (including their `overrideToSave` metadata) when
|
|
1660
|
+
* `transport.send` threw mid-drain. The post-send-success invariant
|
|
1661
|
+
* says the override write is conditional on wire-success; an entry
|
|
1662
|
+
* that
|
|
1663
|
+
* never reaches the wire MUST stay in the queue so the next drain
|
|
1664
|
+
* (after the transport recovers and `flow.resume` arrives again,
|
|
1665
|
+
* or via the reconnect-loop's eventual retry) can replay it with
|
|
1666
|
+
* the same metadata intact.
|
|
1667
|
+
*
|
|
1668
|
+
* New shape: walk by INDEX without clearing the queue first.
|
|
1669
|
+
* - For each entry, send + commit override. If both succeed,
|
|
1670
|
+
* mark the index as drained.
|
|
1671
|
+
* - If `transport.send` throws OR `canSend()` flips mid-drain,
|
|
1672
|
+
* stop immediately, splice off only the successfully drained
|
|
1673
|
+
* prefix. Everything from the failing index onward stays in
|
|
1674
|
+
* `pausedQueue` for the next drain attempt.
|
|
1675
|
+
*
|
|
1676
|
+
* The naive `while (length > 0) shift()` form is O(n²) because each
|
|
1677
|
+
* `shift()` reindexes the whole array; with a user-configurable cap
|
|
1678
|
+
* the blocking time on a large drain shows up in the inbound-message
|
|
1679
|
+
* handler. Index-walk + single splice at the end keeps the drain O(n).
|
|
1680
|
+
*/
|
|
1681
|
+
drainPausedQueue() {
|
|
1682
|
+
if (this.pausedQueue.length === 0)
|
|
1683
|
+
return;
|
|
1684
|
+
let drainedCount = 0;
|
|
1685
|
+
try {
|
|
1686
|
+
for (let i = 0; i < this.pausedQueue.length; i += 1) {
|
|
1687
|
+
if (!this.canSend())
|
|
1688
|
+
break;
|
|
1689
|
+
const entry = this.pausedQueue[i];
|
|
1690
|
+
this.transport.send(entry.json);
|
|
1691
|
+
// Commit the cold-address override to {@link taskSessions} now
|
|
1692
|
+
// that the queued frame
|
|
1693
|
+
// actually landed on the wire. Doing this at queue-push time
|
|
1694
|
+
// would pollute the map with sessions that never reached the
|
|
1695
|
+
// server if the transport dropped mid-pause.
|
|
1696
|
+
if (entry.overrideToSave !== undefined) {
|
|
1697
|
+
this.taskSessions.set(entry.overrideToSave.taskId, entry.overrideToSave.sessionId);
|
|
1698
|
+
// Track the drained message_id → task_id (with sessionId for
|
|
1699
|
+
// generation-safety) so a subsequent `session_mismatch` can
|
|
1700
|
+
// prune the polluted entry only when it hasn't been
|
|
1701
|
+
// superseded.
|
|
1702
|
+
this.outboundOverrideRefs.set(entry.messageId, {
|
|
1703
|
+
taskId: entry.overrideToSave.taskId,
|
|
1704
|
+
sessionId: entry.overrideToSave.sessionId,
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
drainedCount += 1;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
finally {
|
|
1711
|
+
// Splice off ONLY the successfully drained prefix. Any entry
|
|
1712
|
+
// from `drainedCount` onward stays in the queue — its
|
|
1713
|
+
// `overrideToSave` metadata is preserved for the next drain.
|
|
1714
|
+
if (drainedCount > 0) {
|
|
1715
|
+
this.pausedQueue.splice(0, drainedCount);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
fanout(env) {
|
|
1720
|
+
// Snapshot — `consider` may unregister via `close`/`onClose`.
|
|
1721
|
+
const snapshot = [...this.subscriptions];
|
|
1722
|
+
for (const sub of snapshot) {
|
|
1723
|
+
sub.consider(env);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
broadcastClient(ev) {
|
|
1727
|
+
const snapshot = [...this.subscriptions];
|
|
1728
|
+
for (const sub of snapshot) {
|
|
1729
|
+
sub.considerClient(ev);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Wire-level error from the transport. If the user hasn't disconnected and
|
|
1734
|
+
* we have something worth replaying, kick off the reconnect loop. Otherwise
|
|
1735
|
+
* fail the subscriptions and shut down.
|
|
1736
|
+
*
|
|
1737
|
+
* Re-entrancy: a custom `WSTransport.close()` may synchronously invoke
|
|
1738
|
+
* `handlers.onClose` (the `WSTransport` interface places no async-vs-sync
|
|
1739
|
+
* contract on close-event timing — only that one fires). If we called
|
|
1740
|
+
* `disposeTransportSafely()` while `state` was still `'connected'`, the
|
|
1741
|
+
* synchronous `onClose` would re-enter `transportClosed`, see state still
|
|
1742
|
+
* `'connected'`, and start its own reconnect loop. Then this method would
|
|
1743
|
+
* return and start ANOTHER one. To prevent this, we transition state OUT
|
|
1744
|
+
* of `'connected'` before disposing — any re-entrant `transportClosed` /
|
|
1745
|
+
* `transportFailed` then sees the guard and returns early.
|
|
1746
|
+
*/
|
|
1747
|
+
transportFailed(err) {
|
|
1748
|
+
if (this.state !== 'connected')
|
|
1749
|
+
return;
|
|
1750
|
+
// Hold a guard state so any synchronous re-entry from `transport.close()`
|
|
1751
|
+
// sees a non-connected state and bails. We pick `'reconnecting'` directly
|
|
1752
|
+
// — `runReconnectLoop` would set it anyway, and the no-reconnect branch
|
|
1753
|
+
// overrides it via `failHardAndCleanup`.
|
|
1754
|
+
this.state = 'reconnecting';
|
|
1755
|
+
this.disposeTransportSafely();
|
|
1756
|
+
if (this.shouldAttemptReconnect()) {
|
|
1757
|
+
void this.runReconnectLoop(err.message);
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
this.failHardAndCleanup(err);
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* Transport closed. Clean codes (1000/1001/1005) end the subs cleanly even
|
|
1764
|
+
* if reconnect would otherwise be possible — the server told us to go.
|
|
1765
|
+
* Unexpected codes drop into the same reconnect loop as `transportFailed`.
|
|
1766
|
+
*
|
|
1767
|
+
* Re-entrancy: same guard as `transportFailed` — state transitions before
|
|
1768
|
+
* disposal so a synchronous `onClose` from `transport.close()` can't fire
|
|
1769
|
+
* a second reconnect loop. Clean closes use a separate guard state
|
|
1770
|
+
* (`'disconnecting'` → `'disconnected'`) for the same reason.
|
|
1771
|
+
*/
|
|
1772
|
+
transportClosed(code, reason) {
|
|
1773
|
+
if (this.state !== 'connected')
|
|
1774
|
+
return;
|
|
1775
|
+
const cleanClose = code === 1000 || code === 1001 || code === 1005;
|
|
1776
|
+
// Set guard state BEFORE the dispose. Pick the state we'll end up in:
|
|
1777
|
+
// `'disconnected'` for a clean close, `'reconnecting'` otherwise (or
|
|
1778
|
+
// overridden to `'failed'` when no reconnect is warranted).
|
|
1779
|
+
this.state = cleanClose ? 'disconnected' : 'reconnecting';
|
|
1780
|
+
this.disposeTransportSafely();
|
|
1781
|
+
if (cleanClose) {
|
|
1782
|
+
// We already set `state = 'disconnected'`; cleanCloseAndCleanup is
|
|
1783
|
+
// idempotent on that and just runs the cleanup half.
|
|
1784
|
+
this.cleanCloseAndCleanup();
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
if (this.shouldAttemptReconnect()) {
|
|
1788
|
+
void this.runReconnectLoop(`unexpected close (code=${code}, reason=${reason})`);
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
const err = new Error(`WebSocket closed unexpectedly (code=${code}, reason=${reason})`);
|
|
1792
|
+
this.failHardAndCleanup(err);
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Reconnect is worth attempting only when there's at least one in-flight
|
|
1796
|
+
* task subscription that can be replayed. A connection with no live tasks
|
|
1797
|
+
* and only raw taps gains nothing from a reconnect — the user can call
|
|
1798
|
+
* `connect()` fresh next time they need it.
|
|
1799
|
+
*/
|
|
1800
|
+
shouldAttemptReconnect() {
|
|
1801
|
+
if (this.reconnectMaxAttempts <= 0)
|
|
1802
|
+
return false;
|
|
1803
|
+
for (const sub of this.subscriptions) {
|
|
1804
|
+
if (sub instanceof TaskSubscription && !sub.isTerminated)
|
|
1805
|
+
return true;
|
|
1806
|
+
}
|
|
1807
|
+
return false;
|
|
1808
|
+
}
|
|
1809
|
+
/**
|
|
1810
|
+
* Reconnect loop. Backs off exponentially, retries up to `maxAttempts`. On
|
|
1811
|
+
* success: swap the transport, broadcast `qar.client.reconnected`, fire a
|
|
1812
|
+
* `task.resubscribe` per active task. On exhaustion: broadcast
|
|
1813
|
+
* `qar.client.reconnect_failed`, fail every subscription.
|
|
1814
|
+
*
|
|
1815
|
+
* Mid-loop user disconnect is honored — we check `state` after every async
|
|
1816
|
+
* boundary and bail with a clean close.
|
|
1817
|
+
*/
|
|
1818
|
+
async runReconnectLoop(initialCause) {
|
|
1819
|
+
this.state = 'reconnecting';
|
|
1820
|
+
this.clearBackpressureState();
|
|
1821
|
+
let cause = initialCause;
|
|
1822
|
+
let lastError;
|
|
1823
|
+
for (let attempt = 1; attempt <= this.reconnectMaxAttempts; attempt++) {
|
|
1824
|
+
const delayMs = this.reconnectInitialBackoffMs *
|
|
1825
|
+
Math.pow(this.reconnectBackoffMultiplier, attempt - 1);
|
|
1826
|
+
this.broadcastClient({
|
|
1827
|
+
kind: 'qar.client.reconnecting',
|
|
1828
|
+
attempt,
|
|
1829
|
+
delayMs,
|
|
1830
|
+
cause,
|
|
1831
|
+
});
|
|
1832
|
+
await this.sleepCancellable(delayMs);
|
|
1833
|
+
if (this.state !== 'reconnecting') {
|
|
1834
|
+
// User called `disconnect()` (state -> 'disconnecting') or something
|
|
1835
|
+
// else tore us down. Walk the cleanup path; subs are still alive
|
|
1836
|
+
// because we deferred their close in `Connection.close()` while
|
|
1837
|
+
// reconnecting was true.
|
|
1838
|
+
this.finalizeDisconnect();
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
try {
|
|
1842
|
+
const newTransport = await this.factory({
|
|
1843
|
+
url: this.url,
|
|
1844
|
+
headers: this.headers,
|
|
1845
|
+
handlers: this.handlers,
|
|
1846
|
+
});
|
|
1847
|
+
if (this.state !== 'reconnecting') {
|
|
1848
|
+
// Disconnected while the upgrade was in flight — close the new
|
|
1849
|
+
// transport so we don't leak it, then walk cleanup.
|
|
1850
|
+
try {
|
|
1851
|
+
newTransport.close(1000, 'client disconnect');
|
|
1852
|
+
}
|
|
1853
|
+
catch {
|
|
1854
|
+
// best-effort
|
|
1855
|
+
}
|
|
1856
|
+
this.finalizeDisconnect();
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
this.transport = newTransport;
|
|
1860
|
+
this.state = 'connected';
|
|
1861
|
+
this.broadcastClient({ kind: 'qar.client.reconnected', attempt });
|
|
1862
|
+
this.replayActiveTasks();
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
catch (err) {
|
|
1866
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1867
|
+
cause = lastError.message;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
// Max attempts exhausted — give up.
|
|
1871
|
+
this.broadcastClient({
|
|
1872
|
+
kind: 'qar.client.reconnect_failed',
|
|
1873
|
+
attempts: this.reconnectMaxAttempts,
|
|
1874
|
+
...(lastError !== undefined ? { lastError: lastError.message } : {}),
|
|
1875
|
+
});
|
|
1876
|
+
this.state = 'failed';
|
|
1877
|
+
const failure = new Error(`Reconnect failed after ${this.reconnectMaxAttempts} attempts` +
|
|
1878
|
+
(lastError !== undefined ? `: ${lastError.message}` : ''));
|
|
1879
|
+
for (const sub of [...this.subscriptions]) {
|
|
1880
|
+
sub.fail(failure);
|
|
1881
|
+
}
|
|
1882
|
+
this.subscriptions.clear();
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* For each active task subscription, send a `task.resubscribe` envelope
|
|
1886
|
+
* using its `lastSeenMessageId` as the anchor. The new envelope's id is
|
|
1887
|
+
* stitched into the subscription's chain so the server's replayed
|
|
1888
|
+
* envelopes route back transparently.
|
|
1889
|
+
*
|
|
1890
|
+
* **Active-subscription state machine.** A `TaskSubscription`
|
|
1891
|
+
* registered in `this.subscriptions` can be in one of three states at
|
|
1892
|
+
* any point in a connection's lifetime:
|
|
1893
|
+
*
|
|
1894
|
+
* 1. **pre-admission** — `tasks.start` sent its outbound envelope on
|
|
1895
|
+
* the wire and the SDK eagerly registered the subscription so
|
|
1896
|
+
* inbound routing can find it. The server-derived `session_id`
|
|
1897
|
+
* is NOT yet in {@link taskSessions} because no `task.started`
|
|
1898
|
+
* ack has been observed. `currentTaskId` IS set (derived from
|
|
1899
|
+
* `task.start.message_id` per the wire contract `task_id ==
|
|
1900
|
+
* task.start.message_id`).
|
|
1901
|
+
* 2. **admitted** — the `task.started` ack landed; the canonical
|
|
1902
|
+
* `task_id` is in `currentTaskId` and the matching `session_id`
|
|
1903
|
+
* is pinned in {@link taskSessions}. Resubscribe is fully
|
|
1904
|
+
* addressable.
|
|
1905
|
+
* 3. **terminated** — `task.done`, server-error, transport-fail,
|
|
1906
|
+
* or consumer-driven close has flipped `isTerminated` true.
|
|
1907
|
+
* `onClose` has already unsubscribed; subs in this state should
|
|
1908
|
+
* not be in the iterating snapshot.
|
|
1909
|
+
*
|
|
1910
|
+
* `replayActiveTasks` must handle states (1) and (2) explicitly.
|
|
1911
|
+
* State (1) is **unrecoverable** per the session-identity contract:
|
|
1912
|
+
* without an in-memory `session_id`, the SDK cannot construct a
|
|
1913
|
+
* wire-valid `task.resubscribe` for the server's pre-merge
|
|
1914
|
+
* bind-and-derive ingress (the resubscribe envelope MUST carry the
|
|
1915
|
+
* correct `session_id` field per the QAR wire schema).
|
|
1916
|
+
*
|
|
1917
|
+
* **Implementation note.** The implementation pre-checks the per-Task
|
|
1918
|
+
* session map BEFORE attempting the wire send. A state-(1) sub is
|
|
1919
|
+
* failed with a freshly-constructed `QodoColdAddressError` whose
|
|
1920
|
+
* stack starts cleanly inside `replayActiveTasks` — no wire
|
|
1921
|
+
* round-trip is attempted; the failure is local, typed, and
|
|
1922
|
+
* origin-traceable. An earlier implementation called
|
|
1923
|
+
* {@link sendEnvelope} first and relied on the synchronous throw
|
|
1924
|
+
* from {@link resolveOutboundSessionId} to route the failure into
|
|
1925
|
+
* `sub.fail(err)` — functionally correct but the thrown error
|
|
1926
|
+
* carried a misleading stack (the wire-send frames). The current
|
|
1927
|
+
* pre-check approach surfaces a typed local failure instead.
|
|
1928
|
+
* The consumer-facing API surface is unchanged: state-(1) subs still
|
|
1929
|
+
* surface `QodoColdAddressError` on their iterator. Lost-ack
|
|
1930
|
+
* idempotent retry (re-issuing the original `task.start` on the new
|
|
1931
|
+
* transport with the same idempotency-key) lives at the layer
|
|
1932
|
+
* above; the raw `Connection` ships the typed failure.
|
|
1933
|
+
*
|
|
1934
|
+
* Every `TaskSubscription` carries a known `task_id` from construction:
|
|
1935
|
+
* `tasks.continue` / `tasks.cancel` / `tasks.resubscribe` receive it from
|
|
1936
|
+
* the caller, and `tasks.start` derives it from the outbound
|
|
1937
|
+
* `task.start.message_id`. The `currentTaskId === undefined` case is
|
|
1938
|
+
* therefore unreachable here — we keep an invariant assertion rather
|
|
1939
|
+
* than the prior "in-flight task is unrecoverable" `sub.fail` path.
|
|
1940
|
+
*/
|
|
1941
|
+
replayActiveTasks() {
|
|
1942
|
+
for (const sub of [...this.subscriptions]) {
|
|
1943
|
+
if (!(sub instanceof TaskSubscription))
|
|
1944
|
+
continue;
|
|
1945
|
+
if (sub.isTerminated)
|
|
1946
|
+
continue;
|
|
1947
|
+
const taskId = sub.currentTaskId;
|
|
1948
|
+
if (taskId === undefined) {
|
|
1949
|
+
// Invariant violation — see method comment. Fail the subscription so
|
|
1950
|
+
// the consumer's iterator doesn't hang silently, but this path should
|
|
1951
|
+
// never execute in practice.
|
|
1952
|
+
const dead = sub;
|
|
1953
|
+
queueMicrotask(() => {
|
|
1954
|
+
if (!dead.isTerminated) {
|
|
1955
|
+
dead.fail(new Error('Invariant: TaskSubscription has no task_id at reconnect — task.start should derive task_id from the outbound message_id.'));
|
|
1956
|
+
}
|
|
1957
|
+
});
|
|
1958
|
+
continue;
|
|
1959
|
+
}
|
|
1960
|
+
// Pre-admission shortcut: a sub with a `task_id` (derived from
|
|
1961
|
+
// `task.start.message_id`) but NO pinned `session_id` in
|
|
1962
|
+
// {@link taskSessions} is in state (1) — `task.started` never
|
|
1963
|
+
// landed before the drop. Fail it directly with a typed
|
|
1964
|
+
// `QodoColdAddressError` whose stack originates here rather than
|
|
1965
|
+
// routing through the wire-encoder's throw. See the JSDoc above
|
|
1966
|
+
// for the rationale.
|
|
1967
|
+
if (this.taskSessions.get(taskId) === undefined) {
|
|
1968
|
+
sub.fail(new QodoColdAddressError('task', taskId));
|
|
1969
|
+
continue;
|
|
1970
|
+
}
|
|
1971
|
+
const sinceMessageId = sub.lastSeenMessageId;
|
|
1972
|
+
try {
|
|
1973
|
+
const messageId = this.sendEnvelope({
|
|
1974
|
+
kind: 'task.resubscribe',
|
|
1975
|
+
payload: {
|
|
1976
|
+
task_id: taskId,
|
|
1977
|
+
since_message_id: sinceMessageId ?? null,
|
|
1978
|
+
},
|
|
1979
|
+
});
|
|
1980
|
+
sub.attachOutboundMessageId(messageId);
|
|
1981
|
+
}
|
|
1982
|
+
catch (err) {
|
|
1983
|
+
// Defensive backstop. The pre-check above eliminates the
|
|
1984
|
+
// QodoColdAddressError case, but `sendEnvelope` can still raise
|
|
1985
|
+
// on (a) transport mid-call disconnects (rare — the upgrade
|
|
1986
|
+
// succeeded one tick ago) or (b) a {@link canSend} race where
|
|
1987
|
+
// the transport closed between upgrade and send. Surface the
|
|
1988
|
+
// failure to the one subscription and let the others continue.
|
|
1989
|
+
sub.fail(err instanceof Error ? err : new Error(String(err)));
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
cleanCloseAndCleanup() {
|
|
1994
|
+
this.state = 'disconnected';
|
|
1995
|
+
this.clearBackpressureState();
|
|
1996
|
+
this.clearTerminalState();
|
|
1997
|
+
this.clearPendingTimers();
|
|
1998
|
+
for (const sub of [...this.subscriptions]) {
|
|
1999
|
+
sub.close();
|
|
2000
|
+
}
|
|
2001
|
+
this.subscriptions.clear();
|
|
2002
|
+
}
|
|
2003
|
+
failHardAndCleanup(err) {
|
|
2004
|
+
this.state = 'failed';
|
|
2005
|
+
this.clearBackpressureState();
|
|
2006
|
+
this.clearTerminalState();
|
|
2007
|
+
this.clearPendingTimers();
|
|
2008
|
+
for (const sub of [...this.subscriptions]) {
|
|
2009
|
+
sub.fail(err);
|
|
2010
|
+
}
|
|
2011
|
+
this.subscriptions.clear();
|
|
2012
|
+
}
|
|
2013
|
+
/**
|
|
2014
|
+
* Walk the user-initiated disconnect path from inside the reconnect loop.
|
|
2015
|
+
* The subs are still registered (we deferred their close); end them
|
|
2016
|
+
* cleanly so `for await` consumers see `done: true` rather than an error.
|
|
2017
|
+
*/
|
|
2018
|
+
finalizeDisconnect() {
|
|
2019
|
+
this.state = 'disconnected';
|
|
2020
|
+
this.clearBackpressureState();
|
|
2021
|
+
this.clearTerminalState();
|
|
2022
|
+
this.clearPendingTimers();
|
|
2023
|
+
for (const sub of [...this.subscriptions]) {
|
|
2024
|
+
sub.close();
|
|
2025
|
+
}
|
|
2026
|
+
this.subscriptions.clear();
|
|
2027
|
+
}
|
|
2028
|
+
disposeTransportSafely() {
|
|
2029
|
+
try {
|
|
2030
|
+
this.transport.close();
|
|
2031
|
+
}
|
|
2032
|
+
catch {
|
|
2033
|
+
// see comments above — best-effort.
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
clearPendingTimers() {
|
|
2037
|
+
for (const entry of this.pendingSleeps) {
|
|
2038
|
+
clearTimeout(entry.timer);
|
|
2039
|
+
entry.resolve();
|
|
2040
|
+
}
|
|
2041
|
+
this.pendingSleeps.clear();
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* Sleep for `ms` milliseconds, cancellable by `clearPendingTimers`. Returns
|
|
2045
|
+
* even if the timer is cleared early — the caller checks `state` after the
|
|
2046
|
+
* await to decide whether to proceed. Cancelling resolves the Promise
|
|
2047
|
+
* rather than rejecting it: cancellation is a flow-control signal, not an
|
|
2048
|
+
* error, and the reconnect loop's post-await state check handles it.
|
|
2049
|
+
*/
|
|
2050
|
+
sleepCancellable(ms) {
|
|
2051
|
+
return new Promise((resolve) => {
|
|
2052
|
+
const entry = {
|
|
2053
|
+
timer: setTimeout(() => {
|
|
2054
|
+
this.pendingSleeps.delete(entry);
|
|
2055
|
+
resolve();
|
|
2056
|
+
}, ms),
|
|
2057
|
+
resolve,
|
|
2058
|
+
};
|
|
2059
|
+
this.pendingSleeps.add(entry);
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
const ENVELOPE_KINDS = new Set([
|
|
2064
|
+
'task.start',
|
|
2065
|
+
'task.started',
|
|
2066
|
+
'task.continue',
|
|
2067
|
+
'task.cancel',
|
|
2068
|
+
'task.canceling',
|
|
2069
|
+
'task.resubscribe',
|
|
2070
|
+
'task.delta',
|
|
2071
|
+
'task.done',
|
|
2072
|
+
// Cold-start primitive admit/ack pair. Inbound parser must accept
|
|
2073
|
+
// `task.force_resumed` (the ack) or it falls through to the synthetic
|
|
2074
|
+
// `envelope_parse_error` path and the `tasks.forceResume` Promise
|
|
2075
|
+
// hangs. `task.forceResume` is included for symmetry — it's a
|
|
2076
|
+
// client→server kind but server-side echo / loopback tests + raw
|
|
2077
|
+
// taps (`client.receive()`) still parse it.
|
|
2078
|
+
'task.forceResume',
|
|
2079
|
+
'task.force_resumed',
|
|
2080
|
+
'tool.request',
|
|
2081
|
+
'tool.response',
|
|
2082
|
+
'state.update',
|
|
2083
|
+
'agent.spawn',
|
|
2084
|
+
'bulletin.post',
|
|
2085
|
+
'artifact.add',
|
|
2086
|
+
'flow.pause',
|
|
2087
|
+
'flow.resume',
|
|
2088
|
+
'error',
|
|
2089
|
+
]);
|
|
2090
|
+
/**
|
|
2091
|
+
* Decode a wire frame into an `Envelope`. Throws if the JSON doesn't shape-match
|
|
2092
|
+
* the discriminated union — the connection turns these into a synthetic `error`
|
|
2093
|
+
* envelope so the frame isn't silently dropped.
|
|
2094
|
+
*/
|
|
2095
|
+
function parseEnvelope(text) {
|
|
2096
|
+
const raw = JSON.parse(text);
|
|
2097
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
2098
|
+
throw new Error('envelope: expected JSON object');
|
|
2099
|
+
}
|
|
2100
|
+
const obj = raw;
|
|
2101
|
+
if (typeof obj.kind !== 'string' || !ENVELOPE_KINDS.has(obj.kind)) {
|
|
2102
|
+
throw new Error(`envelope: unknown kind "${String(obj.kind)}"`);
|
|
2103
|
+
}
|
|
2104
|
+
if (typeof obj.message_id !== 'string') {
|
|
2105
|
+
throw new Error('envelope: missing message_id');
|
|
2106
|
+
}
|
|
2107
|
+
if (typeof obj.session_id !== 'string') {
|
|
2108
|
+
throw new Error('envelope: missing session_id');
|
|
2109
|
+
}
|
|
2110
|
+
if (typeof obj.ts !== 'string') {
|
|
2111
|
+
throw new Error('envelope: missing ts');
|
|
2112
|
+
}
|
|
2113
|
+
if (obj.envelope_version !== 1) {
|
|
2114
|
+
throw new Error(`envelope: unsupported envelope_version ${String(obj.envelope_version)}`);
|
|
2115
|
+
}
|
|
2116
|
+
if (typeof obj.payload !== 'object' || obj.payload === null) {
|
|
2117
|
+
throw new Error('envelope: missing payload');
|
|
2118
|
+
}
|
|
2119
|
+
// The shape passes the discriminator gate — we trust the rest. QAR's
|
|
2120
|
+
// server-side validators are the source of truth; any over-strict checks
|
|
2121
|
+
// here would surface as false-positive `envelope_parse_error` events for
|
|
2122
|
+
// forward-compatible additions.
|
|
2123
|
+
return raw;
|
|
2124
|
+
}
|
|
2125
|
+
const KINDS_WITH_TASK_ID = new Set([
|
|
2126
|
+
'task.started',
|
|
2127
|
+
'task.continue',
|
|
2128
|
+
'task.cancel',
|
|
2129
|
+
'task.canceling',
|
|
2130
|
+
'task.resubscribe',
|
|
2131
|
+
'task.delta',
|
|
2132
|
+
'task.done',
|
|
2133
|
+
// `task.force_resumed` carries `task_id` on its payload (the recovered
|
|
2134
|
+
// task's id). Membership here lets the `task_id` fallback in
|
|
2135
|
+
// {@link TaskSubscription.matches} ABSORB the ack — e.g. so a cold
|
|
2136
|
+
// `task.resubscribe` that replays a buffered `task.force_resumed` (foreign
|
|
2137
|
+
// parent) still seeds the subscription chain for the events that follow.
|
|
2138
|
+
// Absorption is all the fallback grants: whether the ack drives
|
|
2139
|
+
// admission-resolution / one-shot close is gated separately on a chain
|
|
2140
|
+
// match in {@link TaskSubscription.consider} (`matchedByChain`), so a
|
|
2141
|
+
// fallback-only match never collapses an unrelated in-flight stream. Same
|
|
2142
|
+
// contract applies to `task.started` above.
|
|
2143
|
+
'task.force_resumed',
|
|
2144
|
+
]);
|
|
2145
|
+
function envelopeHasTaskId(env) {
|
|
2146
|
+
return KINDS_WITH_TASK_ID.has(env.kind);
|
|
2147
|
+
}
|
|
2148
|
+
const TASK_EVENT_KINDS = new Set([
|
|
2149
|
+
'task.delta',
|
|
2150
|
+
'task.done',
|
|
2151
|
+
'tool.request',
|
|
2152
|
+
'state.update',
|
|
2153
|
+
'agent.spawn',
|
|
2154
|
+
'bulletin.post',
|
|
2155
|
+
'artifact.add',
|
|
2156
|
+
'flow.pause',
|
|
2157
|
+
'flow.resume',
|
|
2158
|
+
'error',
|
|
2159
|
+
]);
|
|
2160
|
+
function isTaskEvent(env) {
|
|
2161
|
+
return TASK_EVENT_KINDS.has(env.kind);
|
|
2162
|
+
}
|
|
2163
|
+
function pickPositiveInt(value, fallback) {
|
|
2164
|
+
if (value === undefined)
|
|
2165
|
+
return fallback;
|
|
2166
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1)
|
|
2167
|
+
return fallback;
|
|
2168
|
+
return value;
|
|
2169
|
+
}
|
|
2170
|
+
/**
|
|
2171
|
+
* Like {@link pickPositiveInt} but allows `0` — used for `reconnect.maxAttempts`
|
|
2172
|
+
* where `0` is a meaningful configuration ("don't reconnect, fail immediately
|
|
2173
|
+
* on transport drop"). Negatives, fractions, and non-integers still fall back.
|
|
2174
|
+
*/
|
|
2175
|
+
function pickNonNegativeInt(value, fallback) {
|
|
2176
|
+
if (value === undefined)
|
|
2177
|
+
return fallback;
|
|
2178
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0)
|
|
2179
|
+
return fallback;
|
|
2180
|
+
return value;
|
|
2181
|
+
}
|
|
2182
|
+
function pickPositiveNumber(value, fallback) {
|
|
2183
|
+
if (value === undefined)
|
|
2184
|
+
return fallback;
|
|
2185
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
2186
|
+
return fallback;
|
|
2187
|
+
return value;
|
|
2188
|
+
}
|
|
2189
|
+
//# sourceMappingURL=connection.js.map
|