@iislee/opencodex 2.11.0 → 2.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +109 -0
- package/README.md +117 -22
- package/bin/ocx.mjs +245 -88
- package/bin/package-main.mjs +1 -1
- package/gui/dist/assets/index-Be1sDpWw.css +1 -0
- package/gui/dist/assets/index-D4OKgUV4.js +114 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/commandcode-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +21 -10
- package/src/adapters/anthropic-output-schema.ts +137 -0
- package/src/adapters/anthropic.ts +475 -62
- package/src/adapters/base.ts +82 -7
- package/src/adapters/client-fingerprint.ts +18 -12
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
- package/src/adapters/command-code.ts +637 -0
- package/src/adapters/cursor/call-id.ts +44 -0
- package/src/adapters/cursor/catalog.ts +541 -0
- package/src/adapters/cursor/checkpoint-store.ts +308 -0
- package/src/adapters/cursor/cursor-errors.ts +144 -6
- package/src/adapters/cursor/discovery.ts +122 -14
- package/src/adapters/cursor/effort-map.ts +106 -4
- package/src/adapters/cursor/envelope-echo.ts +290 -0
- package/src/adapters/cursor/framing.ts +39 -0
- package/src/adapters/cursor/h2-pool.ts +123 -0
- package/src/adapters/cursor/http1-bidi.ts +361 -0
- package/src/adapters/cursor/images.ts +704 -0
- package/src/adapters/cursor/live-models.ts +180 -59
- package/src/adapters/cursor/live-transport.ts +623 -170
- package/src/adapters/cursor/message-mapper.ts +4 -1
- package/src/adapters/cursor/native-exec-common.ts +23 -2
- package/src/adapters/cursor/native-exec-desktop.ts +23 -0
- package/src/adapters/cursor/native-exec-fs.ts +10 -7
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +5 -3
- package/src/adapters/cursor/native-exec.ts +121 -14
- package/src/adapters/cursor/protobuf-events.ts +829 -11
- package/src/adapters/cursor/protobuf-request.ts +447 -75
- package/src/adapters/cursor/request-builder.ts +271 -35
- package/src/adapters/cursor/tool-definitions.ts +252 -12
- package/src/adapters/cursor/tool-result-normalize.ts +115 -0
- package/src/adapters/cursor/transport.ts +22 -0
- package/src/adapters/cursor/types.ts +48 -1
- package/src/adapters/cursor.ts +336 -39
- package/src/adapters/exec-tool-result-normalize.ts +99 -0
- package/src/adapters/google-antigravity-replay.ts +713 -41
- package/src/adapters/google-antigravity-wire.ts +48 -10
- package/src/adapters/google-errors.ts +44 -12
- package/src/adapters/google-http.ts +38 -10
- package/src/adapters/google-tool-schema.ts +80 -15
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +742 -86
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/image.ts +1 -1
- package/src/adapters/kiro-constants.ts +27 -0
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +18 -2
- package/src/adapters/kiro-tools.ts +65 -17
- package/src/adapters/kiro.ts +270 -73
- package/src/adapters/mimo-free.ts +17 -0
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +1377 -344
- package/src/adapters/openai-responses-url.ts +16 -0
- package/src/adapters/openai-responses.ts +1164 -64
- package/src/adapters/registry.ts +175 -0
- package/src/adapters/responses-tool-schema.ts +67 -0
- package/src/adapters/run-turn-queue.ts +36 -1
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +105 -21
- package/src/adapters/xai-tool-schema.ts +436 -0
- package/src/adapters/xai-web-search.ts +186 -0
- package/src/bridge.ts +512 -92
- package/src/chat/inbound.ts +57 -20
- package/src/chat/outbound.ts +97 -33
- package/src/claude/agents-inject.ts +40 -10
- package/src/claude/context-windows.ts +37 -14
- package/src/claude/desktop-3p.ts +243 -9
- package/src/claude/gateway-cache.ts +41 -4
- package/src/claude/inbound.ts +72 -3
- package/src/claude/model-info.ts +38 -15
- package/src/claude/outbound.ts +88 -21
- package/src/cli/access.ts +46 -3
- package/src/cli/account-api.ts +150 -19
- package/src/cli/account-auth.ts +33 -6
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +648 -28
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +97 -19
- package/src/cli/agent.ts +53 -2
- package/src/cli/alias.ts +66 -0
- package/src/cli/capabilities-command.ts +94 -0
- package/src/cli/capabilities.ts +496 -0
- package/src/cli/claude-agent-startup-sync.ts +73 -0
- package/src/cli/claude-desktop.ts +60 -15
- package/src/cli/claude.ts +129 -35
- package/src/cli/codex-log-guard-doctor.ts +103 -0
- package/src/cli/codex-shim-autorestore.ts +2 -0
- package/src/cli/codex-shim-readiness.ts +76 -0
- package/src/cli/combo.ts +8 -0
- package/src/cli/config-command.ts +74 -10
- package/src/cli/dispatch.ts +821 -0
- package/src/cli/doctor.ts +440 -56
- package/src/cli/ensure-desired-integrations.ts +152 -0
- package/src/cli/export-command.ts +46 -20
- package/src/cli/help.ts +34 -274
- package/src/cli/index.ts +417 -528
- package/src/cli/init.ts +5 -17
- package/src/cli/inspect.ts +230 -0
- package/src/cli/integrations.ts +120 -2
- package/src/cli/lab.ts +607 -0
- package/src/cli/launcher-context.ts +77 -0
- package/src/cli/minimax.ts +497 -0
- package/src/cli/models-runtime.ts +130 -2
- package/src/cli/models.ts +118 -16
- package/src/cli/observe.ts +123 -11
- package/src/cli/opencode.ts +4 -2
- package/src/cli/provider-runtime.ts +34 -3
- package/src/cli/provider.ts +25 -3
- package/src/cli/ready.ts +301 -0
- package/src/cli/registry.ts +476 -0
- package/src/cli/root.ts +86 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +57 -10
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +51 -4
- package/src/cli/storage.ts +234 -0
- package/src/cli/system-command.ts +16 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/cli/usage-report.ts +184 -0
- package/src/cli/v2.ts +137 -18
- package/src/cli/version-skew.ts +46 -0
- package/src/cli.ts +1 -1
- package/src/clients/config-export.ts +1442 -23
- package/src/clients/effective-status.ts +30 -2
- package/src/clients/probes/cline.ts +81 -0
- package/src/codex/account-label.ts +35 -1
- package/src/codex/account-lifecycle.ts +130 -13
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-pause.ts +2 -1
- package/src/codex/account-priority.ts +84 -0
- package/src/codex/account-store.ts +29 -2
- package/src/codex/account-usability.ts +25 -2
- package/src/codex/admission.ts +256 -0
- package/src/codex/affinity-debug.ts +162 -0
- package/src/codex/app-server-processes.ts +570 -107
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/auth-api.ts +893 -246
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/auth-context.ts +348 -32
- package/src/codex/autostart-health.ts +24 -1
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +86 -10
- package/src/codex/catalog/bundled.ts +331 -33
- package/src/codex/catalog/effort.ts +138 -32
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +555 -45
- package/src/codex/catalog/native-models.ts +75 -0
- package/src/codex/catalog/parsing.ts +269 -30
- package/src/codex/catalog/provider-fetch.ts +1626 -144
- package/src/codex/catalog/sync.ts +1576 -187
- package/src/codex/catalog-admission.ts +199 -0
- package/src/codex/catalog-refresh-status.ts +105 -0
- package/src/codex/catalog-write-serialization.ts +242 -0
- package/src/codex/catalog.ts +6 -3
- package/src/codex/codex-write-lock.ts +386 -0
- package/src/codex/convergence-types.ts +614 -0
- package/src/codex/convergence.ts +676 -0
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/desired-state.ts +230 -0
- package/src/codex/desktop-app-restart.ts +355 -0
- package/src/codex/features.ts +636 -39
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +436 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-manifest.ts +112 -0
- package/src/codex/history-migration-guardian.ts +30 -24
- package/src/codex/history-provider.ts +1016 -235
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +223 -0
- package/src/codex/inject-coordination.ts +373 -0
- package/src/codex/inject.ts +1114 -152
- package/src/codex/injected-marker.ts +37 -3
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +81 -0
- package/src/codex/journal.ts +66 -4
- package/src/codex/log-guard/inspect.ts +524 -0
- package/src/codex/log-guard/lock.ts +150 -0
- package/src/codex/log-guard/maintenance.ts +403 -0
- package/src/codex/log-guard/path-safety.ts +88 -0
- package/src/codex/log-guard/policy.ts +44 -0
- package/src/codex/log-guard/processes.ts +205 -0
- package/src/codex/log-guard/protection.ts +489 -0
- package/src/codex/log-guard/sqlite-errors.ts +9 -0
- package/src/codex/main-account-cache.ts +24 -0
- package/src/codex/main-account.ts +29 -1
- package/src/codex/management-convergence.ts +167 -0
- package/src/codex/model-cache.ts +56 -10
- package/src/codex/model-entitlements.ts +360 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +178 -0
- package/src/codex/native-main-lock-file.ts +162 -0
- package/src/codex/native-main-owner.ts +329 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1531 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +622 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +675 -0
- package/src/codex/paths.ts +80 -1
- package/src/codex/plan-from-token.ts +140 -0
- package/src/codex/plan.ts +40 -0
- package/src/codex/plugins-doctor.ts +1 -1
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/project-config-warnings.ts +20 -6
- package/src/codex/prompt-journal.ts +352 -0
- package/src/codex/prompt-layers.ts +1313 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/prompt-text-probe.ts +238 -0
- package/src/codex/quota-rejection.ts +298 -0
- package/src/codex/quota.ts +264 -21
- package/src/codex/refresh.ts +11 -2
- package/src/codex/reset-credit-recovery.ts +1044 -0
- package/src/codex/routing.ts +514 -94
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +1065 -31
- package/src/codex/subagent-model-fallback.ts +437 -43
- package/src/codex/sync.ts +191 -2
- package/src/codex/transition-state.ts +720 -0
- package/src/codex/upstream-host-health.ts +368 -0
- package/src/codex/user-identity.ts +557 -0
- package/src/codex/warmup.ts +187 -81
- package/src/codex/write-coordination.ts +114 -0
- package/src/combos/failover.ts +47 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/request.ts +32 -0
- package/src/combos/types.ts +91 -9
- package/src/compatibility/index.ts +26 -0
- package/src/compatibility/manifest.ts +253 -0
- package/src/compatibility/openai-responses.ts +121 -0
- package/src/config/atomic-write.ts +219 -0
- package/src/config/paths.ts +40 -0
- package/src/config/process-state.ts +309 -0
- package/src/config/provider-name.ts +24 -0
- package/src/config/provider-validation.ts +177 -0
- package/src/config/rebase-provenance.ts +68 -0
- package/src/config.ts +1713 -709
- package/src/generated/compatibility-version.json +3324 -0
- package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
- package/src/grok/inject.ts +16 -5
- package/src/grok/inspect.ts +45 -0
- package/src/grok/sync.ts +2 -2
- package/src/images/loop.ts +157 -33
- package/src/images/plan.ts +24 -13
- package/src/integrations/config-io.ts +269 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/mutation-flight.ts +71 -0
- package/src/integrations/native/ownership-preflight.ts +202 -0
- package/src/integrations/omp-yaml-source.ts +358 -0
- package/src/integrations/owned-refresh.ts +74 -0
- package/src/integrations/ownership-policy.ts +160 -0
- package/src/integrations/ownership.ts +155 -0
- package/src/integrations/registry.ts +166 -0
- package/src/integrations/serialize.ts +314 -0
- package/src/integrations/state.ts +433 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer-lock.ts +98 -0
- package/src/integrations/writer.ts +715 -0
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/automation/budgets.ts +78 -0
- package/src/lab/automation/config-persistence.ts +256 -0
- package/src/lab/automation/constants.ts +39 -0
- package/src/lab/automation/cooldown.ts +103 -0
- package/src/lab/automation/dispatch.ts +211 -0
- package/src/lab/automation/index.ts +13 -0
- package/src/lab/automation/orchestrator.ts +499 -0
- package/src/lab/automation/persistence.ts +512 -0
- package/src/lab/automation/planner.ts +371 -0
- package/src/lab/automation/policy.ts +136 -0
- package/src/lab/automation/queue.ts +191 -0
- package/src/lab/automation/recovery.ts +24 -0
- package/src/lab/automation/route-context.ts +21 -0
- package/src/lab/automation/run-key.ts +44 -0
- package/src/lab/automation/runs-query.ts +34 -0
- package/src/lab/automation/types.ts +160 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +64 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +164 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +68 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +214 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +229 -0
- package/src/lab/events/validate.ts +781 -0
- package/src/lab/fabric/constants.ts +40 -0
- package/src/lab/fabric/executor.ts +492 -0
- package/src/lab/fabric/index.ts +80 -0
- package/src/lab/fabric/manifest.ts +222 -0
- package/src/lab/fabric/observe.ts +489 -0
- package/src/lab/fabric/patch.ts +79 -0
- package/src/lab/fabric/producer-child.ts +139 -0
- package/src/lab/fabric/producer-isolate.ts +276 -0
- package/src/lab/fabric/producer-protocol.ts +61 -0
- package/src/lab/fabric/scratch.ts +439 -0
- package/src/lab/fabric/subject.ts +106 -0
- package/src/lab/fabric/types.ts +134 -0
- package/src/lab/fabric/verifier.ts +98 -0
- package/src/lab/index.ts +54 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +310 -0
- package/src/lab/ledger/store.ts +532 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +336 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +118 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +301 -0
- package/src/lab/observe/from-live.ts +117 -0
- package/src/lab/paths.ts +153 -0
- package/src/lab/projection/rebuild.ts +495 -0
- package/src/lab/projection/schema.ts +135 -0
- package/src/lab/projection/verdicts.ts +474 -0
- package/src/lab/projection/verification.ts +412 -0
- package/src/lab/public/bundle.ts +217 -0
- package/src/lab/public/community-authority.ts +175 -0
- package/src/lab/public/community-files.ts +29 -0
- package/src/lab/public/community.ts +479 -0
- package/src/lab/public/file-safety.ts +155 -0
- package/src/lab/public/ids.ts +26 -0
- package/src/lab/public/index.ts +16 -0
- package/src/lab/public/mutation-lock.ts +424 -0
- package/src/lab/public/operator.ts +353 -0
- package/src/lab/public/origin-purge.ts +79 -0
- package/src/lab/public/origin.ts +203 -0
- package/src/lab/public/privacy.ts +143 -0
- package/src/lab/public/private-file.ts +261 -0
- package/src/lab/public/project.ts +124 -0
- package/src/lab/public/purge-test-fault.ts +21 -0
- package/src/lab/public/purge.ts +223 -0
- package/src/lab/public/registry.ts +44 -0
- package/src/lab/public/revocation.ts +252 -0
- package/src/lab/public/signature.ts +243 -0
- package/src/lab/public/storage.ts +105 -0
- package/src/lab/public/strict-json.ts +206 -0
- package/src/lab/public/time.ts +26 -0
- package/src/lab/public/types.ts +172 -0
- package/src/lab/public/validate.ts +391 -0
- package/src/lab/query/catalog.ts +101 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/freshness.ts +53 -0
- package/src/lab/query/index.ts +45 -0
- package/src/lab/query/latest-observation.ts +59 -0
- package/src/lab/query/passive-production.ts +159 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +112 -0
- package/src/lab/subject/protocol-subject.ts +80 -0
- package/src/lab/subject/route-subject.ts +74 -0
- package/src/lib/admin-secrets.ts +24 -0
- package/src/lib/app-owned-memory-stores.ts +22 -0
- package/src/lib/bounded-body.ts +167 -11
- package/src/lib/bun-runtime.ts +125 -12
- package/src/lib/bun-stream-caps.ts +13 -9
- package/src/lib/codex-restart-contract.ts +120 -0
- package/src/lib/config-ownership.ts +26 -2
- package/src/lib/destination-policy.ts +65 -1
- package/src/lib/errors.ts +80 -5
- package/src/lib/fabric-task-execution-authority.ts +7 -0
- package/src/lib/fabric-task-host.ts +29 -0
- package/src/lib/lab-activation.ts +223 -0
- package/src/lib/lab-live-execution-authority.ts +13 -0
- package/src/lib/lab-live-host.ts +30 -0
- package/src/lib/lab-live-pinned-sender.ts +56 -0
- package/src/lib/lab-live-route-production.ts +130 -0
- package/src/lib/lab-passive-linker-registration.ts +26 -0
- package/src/lib/local-management-attestation.ts +51 -0
- package/src/lib/local-management-capability.ts +100 -0
- package/src/lib/local-provider-reload-contract.ts +100 -0
- package/src/lib/optional-shutdown-hooks.ts +57 -0
- package/src/lib/package-tree-integrity.ts +101 -0
- package/src/lib/pinned-http.ts +145 -26
- package/src/lib/process-control.ts +6 -2
- package/src/lib/provider-outbound.ts +49 -9
- package/src/lib/redact.ts +419 -3
- package/src/lib/self-launch-argv.ts +15 -0
- package/src/lib/server-resource-ownership.ts +71 -0
- package/src/lib/service-secrets.ts +15 -0
- package/src/lib/shadow-call.ts +35 -4
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/state-store-registrations.ts +10 -2
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/token-estimate.ts +19 -2
- package/src/lib/tool-argument-integers.ts +253 -0
- package/src/lib/translator-budget.ts +44 -0
- package/src/lib/upstream-http-version.ts +57 -0
- package/src/lib/upstream-reachability.ts +95 -0
- package/src/lib/upstream-retry.ts +156 -3
- package/src/lib/windows-atomic-replace.ts +155 -0
- package/src/lib/windows-elevation.ts +70 -2
- package/src/lib/windows-secret-acl.ts +409 -69
- package/src/lib/windows-service-wrappers.ts +72 -0
- package/src/lib/windows-text.ts +106 -0
- package/src/lib/windows-user-principal.ts +341 -0
- package/src/lib/winsw.ts +33 -5
- package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
- package/src/oauth/account-import/index.ts +15 -0
- package/src/oauth/account-import/parser.ts +83 -0
- package/src/oauth/account-import/registry.ts +18 -0
- package/src/oauth/account-import/service.ts +75 -0
- package/src/oauth/account-import/types.ts +91 -0
- package/src/oauth/anthropic.ts +12 -1
- package/src/oauth/callback-server.ts +30 -4
- package/src/oauth/chatgpt.ts +12 -1
- package/src/oauth/cline.ts +203 -0
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/cursor.ts +46 -5
- package/src/oauth/generic-account-failover.ts +231 -0
- package/src/oauth/google-antigravity.ts +35 -3
- package/src/oauth/health.ts +20 -12
- package/src/oauth/index.ts +462 -71
- package/src/oauth/key-providers.ts +25 -0
- package/src/oauth/kimi.ts +25 -3
- package/src/oauth/kiro.ts +95 -6
- package/src/oauth/local-token-detect.ts +13 -2
- package/src/oauth/log.ts +3 -1
- package/src/oauth/login-cli.ts +88 -28
- package/src/oauth/nous.ts +798 -0
- package/src/oauth/open-browser-choice.ts +26 -0
- package/src/oauth/store.ts +133 -26
- package/src/oauth/token-guardian.ts +9 -3
- package/src/oauth/types.ts +15 -0
- package/src/pi/models.ts +2 -2
- package/src/providers/alibaba-region-migration.ts +1 -1
- package/src/providers/antigravity-models.ts +521 -31
- package/src/providers/auto-compact-budget.ts +65 -0
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/codex-capacity.ts +292 -0
- package/src/providers/command-code-efforts.ts +176 -0
- package/src/providers/context-cap.ts +26 -8
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/default-aliases.ts +65 -0
- package/src/providers/derive.ts +281 -6
- package/src/providers/fastwire.ts +507 -0
- package/src/providers/free-directory.ts +10 -7
- package/src/providers/google-vertex-location.ts +14 -0
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/label.ts +35 -2
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +115 -22
- package/src/providers/model-presets.ts +119 -0
- package/src/providers/model-rename-migration.ts +255 -0
- package/src/providers/model-rename-startup.ts +28 -0
- package/src/providers/new-model-policy.ts +146 -0
- package/src/providers/openai-sidecar.ts +72 -4
- package/src/providers/openai-tier-startup.ts +31 -2
- package/src/providers/openai-tiers.ts +119 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +30 -0
- package/src/providers/quota.ts +1379 -40
- package/src/providers/registry.ts +1545 -112
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +277 -0
- package/src/providers/slug-codec.ts +94 -6
- package/src/providers/static-model-discovery.ts +86 -0
- package/src/providers/xai-responses-opt-in.ts +15 -0
- package/src/providers/xai-transport.ts +32 -4
- package/src/reasoning-effort.ts +68 -3
- package/src/responses/apply-patch-envelope.ts +63 -0
- package/src/responses/code-mode-helper-compat.ts +50 -0
- package/src/responses/compaction.ts +26 -1
- package/src/responses/custom-tool-compat.ts +384 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/namespace-tool-compat.ts +435 -0
- package/src/responses/parser.ts +225 -38
- package/src/responses/provider-continuation.ts +98 -0
- package/src/responses/provider-opaque-metadata.ts +73 -0
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +504 -0
- package/src/responses/schema.ts +12 -2
- package/src/responses/spill-store.ts +75 -10
- package/src/responses/state.ts +659 -31
- package/src/responses/thought-signature-replay.ts +364 -0
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/responses/truncated-stop-reason.ts +60 -0
- package/src/responses/turn-termination.ts +107 -0
- package/src/router.ts +405 -31
- package/src/routing/analytics.ts +379 -0
- package/src/routing/capability.ts +244 -0
- package/src/routing/compatibility/assemble.ts +73 -0
- package/src/routing/compatibility/behavior.ts +278 -0
- package/src/routing/compatibility/catalog.ts +99 -0
- package/src/routing/compatibility/endpoint.ts +52 -0
- package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
- package/src/routing/compatibility/policy.ts +181 -0
- package/src/routing/compatibility/provider-slot.ts +56 -0
- package/src/routing/compatibility/reader.ts +110 -0
- package/src/routing/compatibility/subject.ts +191 -0
- package/src/routing/compatibility/types.ts +64 -0
- package/src/routing/compatibility/version.ts +104 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +495 -0
- package/src/routing/health.ts +412 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +605 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +547 -0
- package/src/routing/quota.ts +155 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +776 -0
- package/src/server/adapter-resolve.ts +2 -29
- package/src/server/auth-cors.ts +289 -48
- package/src/server/background-lifecycle.ts +182 -0
- package/src/server/chat-completions.ts +154 -72
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +445 -0
- package/src/server/claude-messages.ts +160 -44
- package/src/server/direct-local-http.ts +347 -0
- package/src/server/effort-policy.ts +18 -0
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +94 -12
- package/src/server/index.ts +1021 -190
- package/src/server/lifecycle.ts +336 -14
- package/src/server/live.ts +136 -17
- package/src/server/local-management-read-client.ts +90 -0
- package/src/server/local-provider-reload-client.ts +137 -0
- package/src/server/management/agent-settings-routes.ts +433 -116
- package/src/server/management/api-key-usage.ts +31 -5
- package/src/server/management/body.ts +6 -0
- package/src/server/management/codex-prompt-routes.ts +570 -0
- package/src/server/management/combo-routes.ts +64 -25
- package/src/server/management/config-routes.ts +511 -51
- package/src/server/management/context.ts +89 -2
- package/src/server/management/integration-routes.ts +498 -0
- package/src/server/management/lab-automation-routes.ts +206 -0
- package/src/server/management/lab-routes.ts +563 -0
- package/src/server/management/logs-usage-routes.ts +127 -52
- package/src/server/management/model-routes.ts +461 -133
- package/src/server/management/model-rows.ts +167 -0
- package/src/server/management/native-integration-routes.ts +770 -0
- package/src/server/management/oauth-account-routes.ts +90 -8
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +836 -159
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/route-registry.ts +332 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +380 -0
- package/src/server/management/shared.ts +27 -11
- package/src/server/management/sidebar-routes.ts +47 -31
- package/src/server/management/storage-log-guard-routes.ts +186 -0
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +289 -33
- package/src/server/management/system-routes.ts +77 -0
- package/src/server/management/usage-summary-cache.ts +9 -1
- package/src/server/management/vision-sidecar-options.ts +167 -0
- package/src/server/management/web-search-sidecar-options.ts +120 -0
- package/src/server/management-api.ts +126 -23
- package/src/server/management-auth.ts +220 -5
- package/src/server/passive-route-linker.ts +66 -0
- package/src/server/port-reclaim.ts +1 -1
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +161 -10
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +221 -54
- package/src/server/relay.ts +479 -87
- package/src/server/request-decompress.ts +113 -6
- package/src/server/request-log-conversation.ts +51 -0
- package/src/server/request-log.ts +298 -31
- package/src/server/responses/agent-task-recovery-cache.ts +143 -0
- package/src/server/responses/agent-task-recovery.ts +465 -0
- package/src/server/responses/codex-auth-error.ts +55 -0
- package/src/server/responses/collaboration.ts +239 -36
- package/src/server/responses/combo-stream-preflight.ts +178 -0
- package/src/server/responses/compact.ts +444 -72
- package/src/server/responses/core.ts +3579 -436
- package/src/server/responses/empty-completion-guard.ts +333 -0
- package/src/server/responses/encrypted-payload.ts +62 -39
- package/src/server/responses/fetch-helpers.ts +89 -99
- package/src/server/responses/input-admission.ts +187 -0
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/passthrough-error.ts +33 -9
- package/src/server/responses/policy-fallback.ts +178 -0
- package/src/server/responses/responses-field-backfill.ts +343 -0
- package/src/server/responses/terminal-guard.ts +26 -5
- package/src/server/responses/upstream-error.ts +5 -0
- package/src/server/responses/ws-upstream.ts +381 -0
- package/src/server/responses-custom-tool-repair.ts +363 -0
- package/src/server/responses-item-id-repair.ts +54 -6
- package/src/server/responses-json-events.ts +90 -0
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/responses-terminal-repair.ts +363 -0
- package/src/server/responses-tool-search-repair.ts +267 -0
- package/src/server/responses-undeclared-tool-guard.ts +376 -0
- package/src/server/responses.ts +18 -2
- package/src/server/search.ts +78 -13
- package/src/server/sse-frame-buffer.ts +319 -0
- package/src/server/sse-payload-rewrite.ts +110 -22
- package/src/server/startup-action-control.ts +8 -1
- package/src/server/startup-health-cache.ts +19 -1
- package/src/server/system-env.ts +80 -9
- package/src/server/ws-bridge.ts +53 -40
- package/src/service-manager-probe.ts +892 -0
- package/src/service.ts +1153 -94
- package/src/sidecar/auth.ts +92 -0
- package/src/sidecar/candidates.ts +83 -0
- package/src/storage/cleanup.ts +2 -2
- package/src/storage/policy-job.ts +14 -4
- package/src/storage/policy.ts +88 -23
- package/src/storage/scanner.ts +1 -1
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +83 -9
- package/src/tray/windows.ts +43 -16
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +911 -0
- package/src/types/provider.ts +614 -0
- package/src/types/request.ts +397 -0
- package/src/types/tools.ts +236 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +107 -1237
- package/src/update/index.ts +37 -23
- package/src/update/job.ts +445 -68
- package/src/update/notify.ts +12 -6
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/update/transactional-install.d.mts +22 -0
- package/src/update/transactional-install.mjs +266 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +287 -16
- package/src/usage/log.ts +666 -50
- package/src/usage/summary.ts +388 -17
- package/src/usage/user-cost-overlay-reconciler.ts +313 -0
- package/src/usage/user-cost-overlays.ts +314 -0
- package/src/vision/anthropic-describe.ts +10 -6
- package/src/vision/backends.ts +97 -0
- package/src/vision/describe.ts +27 -16
- package/src/vision/eligibility.ts +250 -0
- package/src/vision/index.ts +238 -24
- package/src/vision/reasoning.ts +55 -0
- package/src/vision/routed-describe.ts +175 -0
- package/src/vision/timeout-bounds.ts +9 -0
- package/src/web-search/anthropic-executor.ts +13 -7
- package/src/web-search/backends.ts +108 -0
- package/src/web-search/exa-executor.ts +119 -0
- package/src/web-search/executor.ts +21 -6
- package/src/web-search/gemini-executor.ts +141 -0
- package/src/web-search/index.ts +150 -15
- package/src/web-search/loop.ts +284 -54
- package/src/web-search/parse.ts +125 -30
- package/src/web-search/sources.ts +60 -0
- package/src/web-search/xai-executor.ts +219 -0
- package/gui/dist/assets/index-DTpMHS4F.js +0 -67
- package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
- package/src/cli/internal-dispatch.ts +0 -20
package/src/responses/state.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, rmSync, unlinkSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, rmSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { uptime } from "node:os";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
|
-
import { atomicWriteFileAsync, getConfigDir } from "../config";
|
|
4
|
+
import { atomicWriteFileAsync, getConfigDir, resolveWriteTarget } from "../config";
|
|
4
5
|
import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
|
|
5
6
|
import type { OcxProviderContinuationState } from "../types";
|
|
6
7
|
import {
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
readResponseSpill,
|
|
10
11
|
recoverOrphanedResponseSpills,
|
|
11
12
|
responseSpillDirectory,
|
|
13
|
+
responseSpillPayloadCap,
|
|
12
14
|
type ResponseSpillRef,
|
|
13
15
|
writeResponseSpillDurably,
|
|
14
16
|
} from "./spill-store";
|
|
@@ -16,6 +18,12 @@ import {
|
|
|
16
18
|
const MAX_STORED_RESPONSES = 1_000;
|
|
17
19
|
const RESPONSE_TTL_MS = 60 * 60 * 1_000;
|
|
18
20
|
const SNAPSHOT_DEBOUNCE_MS = 2_000;
|
|
21
|
+
/** Snapshot size below which the debounce stays at its base value. */
|
|
22
|
+
const SNAPSHOT_DEBOUNCE_SCALE_FROM_BYTES = 1 * 1024 * 1024;
|
|
23
|
+
/** Ceiling for the stretched debounce. Continuation state is only read after a
|
|
24
|
+
* restart, and a graceful shutdown flushes, so the exposure a longer debounce adds
|
|
25
|
+
* is bounded by a hard kill — paid against rewriting the whole snapshot every 2 s. */
|
|
26
|
+
const SNAPSHOT_DEBOUNCE_MAX_MS = 30_000;
|
|
19
27
|
/** In-memory high-water byte cap across all entries. Forced store:false retention (kiro/cursor
|
|
20
28
|
* continuation chains) stores the full expanded input each turn — ~quadratic bytes per chain —
|
|
21
29
|
* so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */
|
|
@@ -23,16 +31,34 @@ export const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
|
23
31
|
/** Legacy snapshot selection only. Spill demotion is governed solely by the RAM cap above. */
|
|
24
32
|
const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
|
|
25
33
|
const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
|
|
34
|
+
/** Refuse-to-parse ceiling for an existing snapshot file (above the 24 MiB write
|
|
35
|
+
* bound, so anything we wrote ourselves always loads; guards against externally
|
|
36
|
+
* planted or pre-cap unbounded files being parsed whole). */
|
|
37
|
+
const SNAPSHOT_FILE_MAX_BYTES = 32 * 1024 * 1024;
|
|
26
38
|
const STALE_TEMP_GRACE_MS = 15 * 60 * 1_000;
|
|
27
39
|
const STALE_TEMP_MAX_ENTRIES = 4_096;
|
|
28
40
|
const STALE_TEMP_MAX_CLEANUPS = 512;
|
|
41
|
+
/** Absorbs `os.uptime()` granularity only. It is deliberately NOT the safety margin:
|
|
42
|
+
* the unconditional 15-minute grace above is (see the boot floor in the scan loop). */
|
|
43
|
+
const BOOT_FLOOR_SKEW_MS = 60 * 1_000;
|
|
44
|
+
/** Per-tick budget for the periodic reclaim. Smaller than the startup budget because the
|
|
45
|
+
* periodic pass runs synchronously on the serving process's event loop every 60 s. */
|
|
46
|
+
const PERIODIC_TEMP_MAX_ENTRIES = 512;
|
|
47
|
+
const PERIODIC_TEMP_MAX_CLEANUPS = 64;
|
|
48
|
+
/** Wall-clock ceiling for one periodic scan. An entry cap bounds syscalls, not time: on a
|
|
49
|
+
* network-mounted config dir each `lstat` can cost 10-20 ms, which would stall in-flight
|
|
50
|
+
* streams. Reclaim is idempotent, so a truncated tick simply resumes on the next one. */
|
|
51
|
+
const PERIODIC_TEMP_SCAN_DEADLINE_MS = 25;
|
|
29
52
|
const RESPONSE_STATE_TEMP_NAME = /^responses-state\.json\.ocx\.(\d+)\.(\d+)\.tmp$/;
|
|
30
53
|
const MAX_SNAPSHOT_REWRITE_ATTEMPTS = 4;
|
|
31
54
|
|
|
32
55
|
interface ResidentResponseState {
|
|
33
56
|
kind: "resident";
|
|
34
57
|
createdAt: number;
|
|
58
|
+
clientThreadId?: string;
|
|
35
59
|
items: unknown[];
|
|
60
|
+
/** Index in `items` where provider output begins; see clientCarriedPrefixLength. */
|
|
61
|
+
providerOutputStart?: number;
|
|
36
62
|
providers?: OcxProviderContinuationState;
|
|
37
63
|
sizeBytes: number;
|
|
38
64
|
}
|
|
@@ -40,6 +66,9 @@ interface ResidentResponseState {
|
|
|
40
66
|
interface SpilledResponseState {
|
|
41
67
|
kind: "spill";
|
|
42
68
|
createdAt: number;
|
|
69
|
+
clientThreadId?: string;
|
|
70
|
+
/** Mirrors the spilled payload boundary so a spilled entry keeps its anchor. */
|
|
71
|
+
providerOutputStart?: number;
|
|
43
72
|
providers?: OcxProviderContinuationState;
|
|
44
73
|
spill: ResponseSpillRef;
|
|
45
74
|
sizeBytes: number;
|
|
@@ -56,17 +85,69 @@ type ResidentInput = Omit<ResidentResponseState, "kind" | "sizeBytes">;
|
|
|
56
85
|
|
|
57
86
|
export type PreviousResponseReplayFailure = {
|
|
58
87
|
code: "previous_response_not_found";
|
|
59
|
-
reason: "spill_missing" | "spill_corrupt" | "spill_failed";
|
|
88
|
+
reason: "spill_missing" | "spill_corrupt" | "spill_failed" | "spill_too_large";
|
|
60
89
|
};
|
|
61
90
|
|
|
62
91
|
const states = new Map<string, StoredResponseState>();
|
|
92
|
+
const replayScopeMismatches = new WeakSet<object>();
|
|
63
93
|
let storedResponseBytes = 0;
|
|
64
94
|
let residentResponseBytes = 0;
|
|
65
95
|
let oldestResidentId: string | undefined;
|
|
66
96
|
let oldestResidentAt: number | null = null;
|
|
67
97
|
let byteCapOverride: number | null = null;
|
|
68
98
|
let stateRevision = 0;
|
|
99
|
+
/** Byte length and digest of the last snapshot actually written, for the
|
|
100
|
+
* identical-payload skip and the size-scaled debounce. The payload itself is not
|
|
101
|
+
* retained: at the 24 MiB bound that would double the snapshot's memory cost. */
|
|
102
|
+
let lastSnapshotBytes = 0;
|
|
103
|
+
let lastSnapshotDigest: string | null = null;
|
|
104
|
+
// The resolved file the digest above describes. Keeping it means a config-dir
|
|
105
|
+
// change or a retargeted symlink is a miss rather than a false "unchanged".
|
|
106
|
+
let lastSnapshotTarget: string | null = null;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Is the snapshot on disk still byte-for-byte what we last wrote?
|
|
110
|
+
*
|
|
111
|
+
* The cached digest proves what this process wrote, not what is there now. Size is
|
|
112
|
+
* checked first so the common mismatch costs a `stat`, and the content comparison
|
|
113
|
+
* only runs when the size already agrees. Any read failure answers "no" and the
|
|
114
|
+
* caller rewrites — the safe direction.
|
|
115
|
+
*/
|
|
116
|
+
async function snapshotOnDiskMatches(path: string, payload: string, payloadBytes: number): Promise<boolean> {
|
|
117
|
+
try {
|
|
118
|
+
const file = Bun.file(path);
|
|
119
|
+
if (file.size !== payloadBytes) return false;
|
|
120
|
+
if (await file.text() !== payload) return false;
|
|
121
|
+
// Content matching is not the whole invariant. This file holds persisted request
|
|
122
|
+
// and response bodies, and `atomicWriteFileAsync` writes it owner-only; the
|
|
123
|
+
// unconditional rewrite used to restore that on every mutation. Skipping without
|
|
124
|
+
// checking would let a broadened mode persist indefinitely, so treat a widened
|
|
125
|
+
// file as "does not match" and let the caller rewrite it through the hardening
|
|
126
|
+
// path. POSIX only — Windows ACLs are re-applied by that same write path.
|
|
127
|
+
if (process.platform !== "win32") {
|
|
128
|
+
const mode = statSync(path).mode & 0o777;
|
|
129
|
+
if (mode !== 0o600) return false;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
69
136
|
const spillCounters = { writes: 0, writeFailures: 0, readFailures: 0 };
|
|
137
|
+
/**
|
|
138
|
+
* Admission-boundary observability (test-visible). directSpills: oversized
|
|
139
|
+
* candidates routed straight to durable spill without a resident stay or
|
|
140
|
+
* unrelated demotion. oversizedDrops: candidates above the single-spill
|
|
141
|
+
* payload ceiling, tombstoned instead of retained. snapshotOversizedRefusals:
|
|
142
|
+
* snapshot files refused before parse.
|
|
143
|
+
*/
|
|
144
|
+
const admissionCounters = { directSpills: 0, oversizedDrops: 0, snapshotOversizedRefusals: 0 };
|
|
145
|
+
let replayScopeMismatchDrops = 0;
|
|
146
|
+
|
|
147
|
+
/** Test-only: admission-boundary counters (proves the new paths fire). */
|
|
148
|
+
export function responseAdmissionCountersForTests(): Readonly<typeof admissionCounters> {
|
|
149
|
+
return admissionCounters;
|
|
150
|
+
}
|
|
70
151
|
// Superseded spill generations awaiting a durable snapshot before unlink
|
|
71
152
|
// (review C1-1: unlinking at swap time races a crash against the debounced
|
|
72
153
|
// snapshot — the reloaded OLD stub would point at a deleted file).
|
|
@@ -106,7 +187,9 @@ function measureResidentEntry(id: string, entry: ResidentInput): ResidentRespons
|
|
|
106
187
|
const sizeBytes = serializedBytes({
|
|
107
188
|
responseId: id,
|
|
108
189
|
createdAt: entry.createdAt,
|
|
190
|
+
...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}),
|
|
109
191
|
items: entry.items,
|
|
192
|
+
...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}),
|
|
110
193
|
...(entry.providers ? { providers: entry.providers } : {}),
|
|
111
194
|
});
|
|
112
195
|
return sizeBytes === null ? null : { kind: "resident", ...entry, sizeBytes };
|
|
@@ -177,12 +260,28 @@ function deleteEntry(id: string, options: { deleteSpill?: boolean } = {}): void
|
|
|
177
260
|
if (options.deleteSpill !== false) deleteOwnedSpills(existing);
|
|
178
261
|
}
|
|
179
262
|
|
|
180
|
-
function replaceWithSpillFailure(
|
|
263
|
+
function replaceWithSpillFailure(
|
|
264
|
+
id: string,
|
|
265
|
+
expected?: StoredResponseState,
|
|
266
|
+
options: { deferSpillUnlink?: boolean } = {},
|
|
267
|
+
): void {
|
|
181
268
|
const existing = states.get(id);
|
|
182
269
|
if (expected && existing !== expected) return;
|
|
183
270
|
const failed = tombstone(id, expected?.createdAt ?? existing?.createdAt ?? now());
|
|
184
271
|
if (replaceMapEntry(id, failed, expected)) {
|
|
185
|
-
if (existing)
|
|
272
|
+
if (existing) {
|
|
273
|
+
if (options.deferSpillUnlink && existing.kind === "spill") {
|
|
274
|
+
// Crash consistency (same rule as replaceSpillEntryAtomically): the old
|
|
275
|
+
// durable snapshot still references this generation until the tombstone
|
|
276
|
+
// itself is durable — queue the unlink for the next stable persist.
|
|
277
|
+
pendingSpillUnlinks.push(existing.spill);
|
|
278
|
+
while (pendingSpillUnlinks.length > PENDING_SPILL_UNLINKS_MAX) {
|
|
279
|
+
deleteResponseSpill(pendingSpillUnlinks.shift()!);
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
deleteOwnedSpills(existing);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
186
285
|
}
|
|
187
286
|
}
|
|
188
287
|
|
|
@@ -190,6 +289,7 @@ function swapResidentForSpill(id: string, expected: ResidentResponseState, ref:
|
|
|
190
289
|
const base: Omit<SpilledResponseState, "sizeBytes"> = {
|
|
191
290
|
kind: "spill",
|
|
192
291
|
createdAt: expected.createdAt,
|
|
292
|
+
...(expected.clientThreadId ? { clientThreadId: expected.clientThreadId } : {}),
|
|
193
293
|
...(expected.providers ? { providers: expected.providers } : {}),
|
|
194
294
|
spill: ref,
|
|
195
295
|
};
|
|
@@ -210,12 +310,16 @@ function replaceSpillEntryAtomically(
|
|
|
210
310
|
try {
|
|
211
311
|
const ref = writeResponseSpillDurably(id, {
|
|
212
312
|
createdAt: candidate.createdAt,
|
|
313
|
+
...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
|
|
213
314
|
items: candidate.items,
|
|
315
|
+
...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}),
|
|
214
316
|
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
215
317
|
});
|
|
216
318
|
const base: Omit<SpilledResponseState, "sizeBytes"> = {
|
|
217
319
|
kind: "spill",
|
|
218
320
|
createdAt: candidate.createdAt,
|
|
321
|
+
...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
|
|
322
|
+
...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}),
|
|
219
323
|
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
220
324
|
spill: ref,
|
|
221
325
|
};
|
|
@@ -236,7 +340,9 @@ function replaceSpillEntryAtomically(
|
|
|
236
340
|
}
|
|
237
341
|
} catch {
|
|
238
342
|
spillCounters.writeFailures += 1;
|
|
239
|
-
|
|
343
|
+
// deferSpillUnlink: the durable snapshot may still reference the old
|
|
344
|
+
// generation; deleting it now would strand the old stub after a crash.
|
|
345
|
+
replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
|
|
240
346
|
}
|
|
241
347
|
}
|
|
242
348
|
|
|
@@ -251,6 +357,11 @@ function setResidentEntry(id: string, entry: ResidentInput): void {
|
|
|
251
357
|
pruneResponses();
|
|
252
358
|
return;
|
|
253
359
|
}
|
|
360
|
+
if (candidate.sizeBytes > byteCap()) {
|
|
361
|
+
admitOversizedCandidate(id, candidate, expected);
|
|
362
|
+
pruneResponses();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
254
365
|
if (expected?.kind === "spill") {
|
|
255
366
|
replaceSpillEntryAtomically(id, expected, candidate);
|
|
256
367
|
pruneResponses();
|
|
@@ -260,9 +371,76 @@ function setResidentEntry(id: string, entry: ResidentInput): void {
|
|
|
260
371
|
pruneResponses();
|
|
261
372
|
}
|
|
262
373
|
|
|
263
|
-
|
|
374
|
+
/**
|
|
375
|
+
* Admission boundary for candidates that can never fit as resident (larger
|
|
376
|
+
* than the whole resident-map cap). Writes them DIRECTLY to durable spill and
|
|
377
|
+
* installs only the stub — the oversized candidate never becomes resident and
|
|
378
|
+
* no unrelated resident is demoted to make room for it. Candidates above the
|
|
379
|
+
* single-spill payload ceiling are tombstoned instead: retaining a spill the
|
|
380
|
+
* replay ceiling would refuse to read is write-only waste.
|
|
381
|
+
*/
|
|
382
|
+
function admitOversizedCandidate(
|
|
383
|
+
id: string,
|
|
384
|
+
candidate: ResidentResponseState,
|
|
385
|
+
expected?: StoredResponseState,
|
|
386
|
+
): void {
|
|
387
|
+
if (candidate.sizeBytes > responseSpillPayloadCap()) {
|
|
388
|
+
admissionCounters.oversizedDrops += 1;
|
|
389
|
+
replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
const ref = writeResponseSpillDurably(id, {
|
|
394
|
+
createdAt: candidate.createdAt,
|
|
395
|
+
...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
|
|
396
|
+
items: candidate.items,
|
|
397
|
+
...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}),
|
|
398
|
+
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
399
|
+
});
|
|
400
|
+
// Enforce the ceiling against the REAL envelope: the spill payload adds
|
|
401
|
+
// the {version, responseId, ...} wrapper, so a candidate within the
|
|
402
|
+
// wrapper's size of the cap would otherwise be retained unreadably.
|
|
403
|
+
if (ref.payloadBytes > responseSpillPayloadCap()) {
|
|
404
|
+
deleteResponseSpill(ref);
|
|
405
|
+
admissionCounters.oversizedDrops += 1;
|
|
406
|
+
replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const base: Omit<SpilledResponseState, "sizeBytes"> = {
|
|
410
|
+
kind: "spill",
|
|
411
|
+
createdAt: candidate.createdAt,
|
|
412
|
+
...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
|
|
413
|
+
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
414
|
+
spill: ref,
|
|
415
|
+
};
|
|
416
|
+
const next: SpilledResponseState = { ...base, sizeBytes: stubSize(id, base) };
|
|
417
|
+
if (!replaceMapEntry(id, next, expected)) {
|
|
418
|
+
deleteResponseSpill(ref);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
spillCounters.writes += 1;
|
|
422
|
+
admissionCounters.directSpills += 1;
|
|
423
|
+
noteStubSwapForTest();
|
|
424
|
+
if (expected?.kind === "spill") {
|
|
425
|
+
// Same deferred-unlink rule as replaceSpillEntryAtomically: the new stub
|
|
426
|
+
// is durable only after the debounced snapshot, so the old generation
|
|
427
|
+
// stays until a stable persist drains the queue.
|
|
428
|
+
pendingSpillUnlinks.push(expected.spill);
|
|
429
|
+
while (pendingSpillUnlinks.length > PENDING_SPILL_UNLINKS_MAX) {
|
|
430
|
+
deleteResponseSpill(pendingSpillUnlinks.shift()!);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
} catch {
|
|
434
|
+
spillCounters.writeFailures += 1;
|
|
435
|
+
replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Replay provenance must stay proxy-private: a WeakMap distinguishes replayed history from the
|
|
264
440
|
// newly appended input suffix without adding an unknown field that native passthrough could send
|
|
265
|
-
// upstream. The parser uses this boundary to acknowledge historical compaction markers exactly
|
|
441
|
+
// upstream. The parser uses this boundary to acknowledge historical compaction markers exactly
|
|
442
|
+
// once. It records the boundary whether the proxy prepended the history or the client already
|
|
443
|
+
// carried it — the boundary is the same either way, and only its provenance differs.
|
|
266
444
|
const replayedInputPrefixLengths = new WeakMap<object, number>();
|
|
267
445
|
const replayFailures = new WeakMap<object, PreviousResponseReplayFailure>();
|
|
268
446
|
let loaded = false;
|
|
@@ -282,6 +460,7 @@ function snapshotPath(): string {
|
|
|
282
460
|
|
|
283
461
|
interface LegacySnapshotState {
|
|
284
462
|
createdAt?: unknown;
|
|
463
|
+
clientThreadId?: unknown;
|
|
285
464
|
items?: unknown;
|
|
286
465
|
providers?: OcxProviderContinuationState;
|
|
287
466
|
conversationId?: unknown;
|
|
@@ -302,11 +481,26 @@ function loadSnapshotEntry(id: string, value: unknown): void {
|
|
|
302
481
|
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
303
482
|
const rec = value as LegacySnapshotState & { kind?: unknown; spill?: unknown };
|
|
304
483
|
if (typeof rec.createdAt !== "number" || !Number.isFinite(rec.createdAt)) return;
|
|
484
|
+
const clientThreadId = typeof rec.clientThreadId === "string" && rec.clientThreadId.trim().length > 0
|
|
485
|
+
? rec.clientThreadId.trim()
|
|
486
|
+
: undefined;
|
|
487
|
+
// A malformed boundary degrades to "never skip" rather than to a bad index: an untrusted
|
|
488
|
+
// snapshot must not be able to authorize dropping conversation history.
|
|
489
|
+
const anchorFor = (itemCount: number): number | undefined => {
|
|
490
|
+
const raw = (rec as { providerOutputStart?: unknown }).providerOutputStart;
|
|
491
|
+
return Number.isSafeInteger(raw) && (raw as number) >= 0 && (raw as number) <= itemCount
|
|
492
|
+
? raw as number
|
|
493
|
+
: undefined;
|
|
494
|
+
};
|
|
305
495
|
if (rec.kind === "spill") {
|
|
306
496
|
if (!isSpillRef(rec.spill)) return;
|
|
307
497
|
const base: Omit<SpilledResponseState, "sizeBytes"> = {
|
|
308
498
|
kind: "spill",
|
|
309
499
|
createdAt: rec.createdAt,
|
|
500
|
+
...(clientThreadId ? { clientThreadId } : {}),
|
|
501
|
+
// Item count is unknown until materialization, so accept any non-negative integer
|
|
502
|
+
// here; the spill payload validator re-checks it against the real array.
|
|
503
|
+
...(anchorFor(Number.MAX_SAFE_INTEGER) !== undefined ? { providerOutputStart: anchorFor(Number.MAX_SAFE_INTEGER) } : {}),
|
|
310
504
|
...(rec.providers ? { providers: rec.providers } : {}),
|
|
311
505
|
spill: rec.spill,
|
|
312
506
|
};
|
|
@@ -331,11 +525,23 @@ function loadSnapshotEntry(id: string, value: unknown): void {
|
|
|
331
525
|
: undefined);
|
|
332
526
|
const resident = measureResidentEntry(id, {
|
|
333
527
|
createdAt: rec.createdAt,
|
|
528
|
+
...(clientThreadId ? { clientThreadId } : {}),
|
|
334
529
|
items: rec.items,
|
|
530
|
+
...(anchorFor(rec.items.length) !== undefined ? { providerOutputStart: anchorFor(rec.items.length) } : {}),
|
|
335
531
|
...(providers ? { providers } : {}),
|
|
336
532
|
});
|
|
337
|
-
if (resident)
|
|
338
|
-
|
|
533
|
+
if (!resident) {
|
|
534
|
+
replaceMapEntry(id, tombstone(id, rec.createdAt));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
// Same admission boundary as live writes: an oversized snapshot row goes
|
|
538
|
+
// straight to spill (or tombstone above the payload ceiling) instead of
|
|
539
|
+
// entering the resident map and demoting unrelated rows on the first prune.
|
|
540
|
+
if (resident.sizeBytes > byteCap()) {
|
|
541
|
+
admitOversizedCandidate(id, resident, undefined);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
replaceMapEntry(id, resident);
|
|
339
545
|
}
|
|
340
546
|
|
|
341
547
|
export interface ResponseStateTempRecoveryResult {
|
|
@@ -343,19 +549,37 @@ export interface ResponseStateTempRecoveryResult {
|
|
|
343
549
|
removed: number;
|
|
344
550
|
failed: number;
|
|
345
551
|
bytesRemoved: number;
|
|
552
|
+
/** Entries that passed EVERY gate and would be reclaimed. In a dry run nothing is
|
|
553
|
+
* unlinked, so this is the only honest count to show an operator: `matched` is
|
|
554
|
+
* incremented before the file-type, age, boot-floor, and liveness gates. */
|
|
555
|
+
eligible: number;
|
|
556
|
+
/** Total size of the `eligible` entries. */
|
|
557
|
+
eligibleBytes: number;
|
|
558
|
+
/** The scan stopped on a budget (entry cap, cleanup cap, or deadline) rather than reaching
|
|
559
|
+
* the end of the directory, so the counts below describe a prefix of the backlog and not
|
|
560
|
+
* the backlog. `eligible > removed + failed` cannot express this: outside a dry run every
|
|
561
|
+
* eligible entry is unlinked or failed on the same iteration, so the two are always equal
|
|
562
|
+
* and a comparison between them is dead code. */
|
|
563
|
+
truncated: boolean;
|
|
346
564
|
}
|
|
347
565
|
|
|
348
566
|
interface ResponseStateTempRecoveryIO {
|
|
349
567
|
now: () => number;
|
|
568
|
+
/** Approximate epoch ms of the current boot; see the boot floor in the scan loop. */
|
|
569
|
+
bootTime: () => number;
|
|
350
570
|
list: (dir: string) => Iterable<string>;
|
|
351
571
|
inspect: (path: string) => { isFile: boolean; mtimeMs: number; size: number };
|
|
352
572
|
isProcessAlive: (pid: number) => boolean;
|
|
353
573
|
unlink: (path: string) => void;
|
|
354
574
|
}
|
|
355
575
|
|
|
356
|
-
type ResponseStateTempRecoveryOptions = Partial<ResponseStateTempRecoveryIO> & {
|
|
576
|
+
export type ResponseStateTempRecoveryOptions = Partial<ResponseStateTempRecoveryIO> & {
|
|
357
577
|
maxEntries?: number;
|
|
358
578
|
maxCleanups?: number;
|
|
579
|
+
/** Wall-clock ceiling for the scan, or null/undefined for no deadline (startup path). */
|
|
580
|
+
deadlineMs?: number | null;
|
|
581
|
+
/** Report only: apply every gate, count what would be reclaimed, unlink nothing. */
|
|
582
|
+
dryRun?: boolean;
|
|
359
583
|
};
|
|
360
584
|
|
|
361
585
|
function processIsAlive(pid: number): boolean {
|
|
@@ -372,6 +596,7 @@ function processIsAlive(pid: number): boolean {
|
|
|
372
596
|
|
|
373
597
|
const responseStateTempRecoveryIO: ResponseStateTempRecoveryIO = {
|
|
374
598
|
now: Date.now,
|
|
599
|
+
bootTime: () => Date.now() - uptime() * 1_000,
|
|
375
600
|
list: function* list(dir) {
|
|
376
601
|
const handle = opendirSync(dir);
|
|
377
602
|
try {
|
|
@@ -398,26 +623,56 @@ export function recoverStaleResponseStateTemps(
|
|
|
398
623
|
dir = getConfigDir(),
|
|
399
624
|
options: ResponseStateTempRecoveryOptions = {},
|
|
400
625
|
): ResponseStateTempRecoveryResult {
|
|
401
|
-
const {
|
|
626
|
+
const {
|
|
627
|
+
maxEntries = STALE_TEMP_MAX_ENTRIES,
|
|
628
|
+
maxCleanups = STALE_TEMP_MAX_CLEANUPS,
|
|
629
|
+
deadlineMs = null,
|
|
630
|
+
dryRun = false,
|
|
631
|
+
...overrides
|
|
632
|
+
} = options;
|
|
402
633
|
const io = { ...responseStateTempRecoveryIO, ...overrides };
|
|
403
634
|
const result: ResponseStateTempRecoveryResult = {
|
|
404
635
|
matched: 0,
|
|
405
636
|
removed: 0,
|
|
406
637
|
failed: 0,
|
|
407
638
|
bytesRemoved: 0,
|
|
639
|
+
eligible: 0,
|
|
640
|
+
eligibleBytes: 0,
|
|
641
|
+
truncated: false,
|
|
408
642
|
};
|
|
643
|
+
const startedAt = io.now();
|
|
644
|
+
// One probe per scan, not one per entry. A non-finite or future-dated boot is anomalous, and
|
|
645
|
+
// clamping it to "now" would be the WORST response: the floor would then retire the liveness
|
|
646
|
+
// probe for every file older than the skew, which is every file past the grace. Disable it
|
|
647
|
+
// instead -- an absent floor only costs a missed reclaim, never a wrong one.
|
|
648
|
+
const rawBoot = io.bootTime();
|
|
649
|
+
const bootMs = Number.isFinite(rawBoot) && rawBoot <= startedAt ? rawBoot : Number.NEGATIVE_INFINITY;
|
|
409
650
|
let names: Iterable<string>;
|
|
410
651
|
try { names = io.list(dir); } catch { return result; }
|
|
411
652
|
let iterator: Iterator<string>;
|
|
412
653
|
try { iterator = names[Symbol.iterator](); } catch { return result; }
|
|
413
654
|
let scanned = 0;
|
|
655
|
+
// Every early exit runs through this. The production `list` is a generator that closes its
|
|
656
|
+
// directory handle in a `finally`, and a `finally` does NOT run when the consumer simply
|
|
657
|
+
// stops calling `next()` -- only `return()` resumes the generator to completion. Breaking
|
|
658
|
+
// out of the loop directly therefore leaked one directory handle per truncated scan, and the
|
|
659
|
+
// periodic reclaim truncates on purpose (entry cap, cleanup cap, deadline), so on a slow
|
|
660
|
+
// filesystem that is a leak per tick, forever.
|
|
661
|
+
const stopScan = (): ResponseStateTempRecoveryResult => {
|
|
662
|
+
try { iterator.return?.(); } catch { /* closing is best-effort; never fail a reclaim on it */ }
|
|
663
|
+
return result;
|
|
664
|
+
};
|
|
414
665
|
for (;;) {
|
|
415
666
|
let next: IteratorResult<string>;
|
|
416
667
|
try { next = iterator.next(); } catch { return result; }
|
|
417
668
|
if (next.done) break;
|
|
418
669
|
const name = next.value;
|
|
419
670
|
scanned += 1;
|
|
420
|
-
|
|
671
|
+
// A dry run performs no cleanups, so bounding it by the cleanup budget would truncate
|
|
672
|
+
// the very report an operator uses to size the problem.
|
|
673
|
+
if (scanned > maxEntries) { result.truncated = true; return stopScan(); }
|
|
674
|
+
if (!dryRun && result.removed + result.failed >= maxCleanups) { result.truncated = true; return stopScan(); }
|
|
675
|
+
if (deadlineMs !== null && io.now() - startedAt > deadlineMs) { result.truncated = true; return stopScan(); }
|
|
421
676
|
const match = RESPONSE_STATE_TEMP_NAME.exec(name);
|
|
422
677
|
if (!match) continue;
|
|
423
678
|
result.matched += 1;
|
|
@@ -428,13 +683,34 @@ export function recoverStaleResponseStateTemps(
|
|
|
428
683
|
let file: ReturnType<ResponseStateTempRecoveryIO["inspect"]>;
|
|
429
684
|
try { file = io.inspect(path); } catch { continue; }
|
|
430
685
|
if (!file.isFile || io.now() - file.mtimeMs < STALE_TEMP_GRACE_MS) continue;
|
|
431
|
-
|
|
686
|
+
// Boot floor. After a reboot the original writer's pid is routinely reused, which makes
|
|
687
|
+
// the liveness skip PERMANENT: the 15-minute grace above is a lower bound and never
|
|
688
|
+
// expires it, so the file is skipped on every future pass forever. A temp older than
|
|
689
|
+
// this boot cannot be owned by the pid we would probe, so the probe is vacuous and we
|
|
690
|
+
// retire it. This does NOT claim the file is provably dead: under a shared-volume
|
|
691
|
+
// container, suspend-excluding uptime, or a network config dir the computed boot can
|
|
692
|
+
// land after the real one. The unconditional 15-minute grace above remains the safety
|
|
693
|
+
// floor, and this process's own temps are never touched.
|
|
694
|
+
const predatesBoot = file.mtimeMs < bootMs - BOOT_FLOOR_SKEW_MS;
|
|
695
|
+
if (pid === process.pid) continue;
|
|
696
|
+
if (!predatesBoot && io.isProcessAlive(pid)) continue;
|
|
697
|
+
|
|
698
|
+
result.eligible += 1;
|
|
699
|
+
result.eligibleBytes += file.size;
|
|
700
|
+
if (dryRun) continue;
|
|
432
701
|
|
|
433
702
|
try {
|
|
434
703
|
io.unlink(path);
|
|
435
704
|
result.removed += 1;
|
|
436
705
|
result.bytesRemoved += file.size;
|
|
437
|
-
} catch {
|
|
706
|
+
} catch (error) {
|
|
707
|
+
// Another proxy sharing this config dir may have won the race. A file that is already
|
|
708
|
+
// gone is reclaimed, not a failure -- reporting it as one would surface "in use or
|
|
709
|
+
// locked" to an operator for a file nobody holds.
|
|
710
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
711
|
+
result.removed += 1;
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
438
714
|
// Locked files remain for a later startup. Do not truncate by path: a same-user
|
|
439
715
|
// replacement could turn that fallback into an arbitrary symlink-target write.
|
|
440
716
|
result.failed += 1;
|
|
@@ -443,6 +719,23 @@ export function recoverStaleResponseStateTemps(
|
|
|
443
719
|
return result;
|
|
444
720
|
}
|
|
445
721
|
|
|
722
|
+
/**
|
|
723
|
+
* Literal config dir plus the snapshot's resolved dir. Atomic writes place their temp beside
|
|
724
|
+
* the RESOLVED target, so a symlinked snapshot (dotfiles-managed config dir) strands temps in
|
|
725
|
+
* the link's real directory where a scan of the literal dir would never see them. The two
|
|
726
|
+
* collapse to one when nothing is symlinked.
|
|
727
|
+
*/
|
|
728
|
+
function responseStateSweepDirectories(): Set<string> {
|
|
729
|
+
const path = snapshotPath();
|
|
730
|
+
let resolvedDir = dirname(path);
|
|
731
|
+
try {
|
|
732
|
+
resolvedDir = dirname(resolveWriteTarget(path));
|
|
733
|
+
} catch {
|
|
734
|
+
/* unresolvable link: sweep the literal dir only */
|
|
735
|
+
}
|
|
736
|
+
return new Set([dirname(path), resolvedDir]);
|
|
737
|
+
}
|
|
738
|
+
|
|
446
739
|
/**
|
|
447
740
|
* Best-effort disk snapshot so previous_response_id chains survive a proxy restart (the
|
|
448
741
|
* dominant expansion-miss cause: an in-memory-only store dies with the process, and the next
|
|
@@ -454,18 +747,43 @@ function ensureLoaded(): void {
|
|
|
454
747
|
if (loaded) return;
|
|
455
748
|
loaded = true;
|
|
456
749
|
const path = snapshotPath();
|
|
750
|
+
// Atomic writes place their temp beside the RESOLVED target, so a symlinked
|
|
751
|
+
// snapshot (dotfiles-managed config dir) strands temps in the link's real
|
|
752
|
+
// directory where a scan of the literal config dir would never see them.
|
|
753
|
+
// Both locations are swept; they collapse to one when nothing is symlinked.
|
|
754
|
+
// resolveWriteTarget refuses a dangling link; snapshot loading stays independent.
|
|
755
|
+
let resolvedDir = dirname(path);
|
|
457
756
|
try {
|
|
458
|
-
|
|
757
|
+
resolvedDir = dirname(resolveWriteTarget(path));
|
|
459
758
|
} catch {
|
|
460
|
-
/*
|
|
759
|
+
/* unresolvable link: sweep the literal dir only */
|
|
760
|
+
}
|
|
761
|
+
for (const dir of new Set([dirname(path), resolvedDir])) {
|
|
762
|
+
try {
|
|
763
|
+
recoverStaleResponseStateTemps(dir);
|
|
764
|
+
} catch {
|
|
765
|
+
/* best-effort cleanup only; snapshot loading must remain independent */
|
|
766
|
+
}
|
|
461
767
|
}
|
|
462
768
|
try {
|
|
463
769
|
if (existsSync(path)) {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
770
|
+
// Bound the read BEFORE parse: the 24 MiB write cap constrains snapshots
|
|
771
|
+
// this process wrote, not a pre-existing oversized file. statSync follows
|
|
772
|
+
// symlinks deliberately — readFileSync below follows them too, so the
|
|
773
|
+
// size gate must measure the same target the read would.
|
|
774
|
+
const stat = statSync(path);
|
|
775
|
+
if (!stat.isFile()) {
|
|
776
|
+
// Symlink to a FIFO/device (e.g. /dev/zero): reading would block or
|
|
777
|
+
// return unbounded input. Only regular files are ever parsed.
|
|
778
|
+
} else if (stat.size > SNAPSHOT_FILE_MAX_BYTES) {
|
|
779
|
+
admissionCounters.snapshotOversizedRefusals += 1;
|
|
780
|
+
} else {
|
|
781
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
|
|
782
|
+
if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.states)) {
|
|
783
|
+
for (const entry of raw.states) {
|
|
784
|
+
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") continue;
|
|
785
|
+
loadSnapshotEntry(entry[0], entry[1]);
|
|
786
|
+
}
|
|
469
787
|
}
|
|
470
788
|
}
|
|
471
789
|
}
|
|
@@ -504,16 +822,47 @@ async function writeBoundedSnapshot(path: string): Promise<SnapshotWriteOutcome>
|
|
|
504
822
|
persistable = smallState;
|
|
505
823
|
}
|
|
506
824
|
const persistEntry: [string, unknown] = [id, persistable];
|
|
507
|
-
|
|
825
|
+
// UTF-8 bytes, not UTF-16 code units: multibyte items otherwise slip
|
|
826
|
+
// past both snapshot caps at up to 2x the intended size.
|
|
827
|
+
const size = Buffer.byteLength(JSON.stringify(persistEntry), "utf8");
|
|
508
828
|
if (state.kind === "resident" && size > SNAPSHOT_ENTRY_MAX_BYTES) continue;
|
|
509
829
|
if (total + size > SNAPSHOT_TOTAL_MAX_BYTES) break;
|
|
510
830
|
total += size;
|
|
511
831
|
entries.push(persistEntry);
|
|
512
832
|
}
|
|
513
833
|
entries.reverse();
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
834
|
+
const payload = JSON.stringify({ version: 2, states: entries });
|
|
835
|
+
const payloadBytes = Buffer.byteLength(payload, "utf8");
|
|
836
|
+
const payloadDigest = Bun.hash(payload).toString(36);
|
|
837
|
+
// A mutation does not always change what gets persisted: entries past the
|
|
838
|
+
// per-entry or total byte bound are dropped from the selection, and spill
|
|
839
|
+
// demotion moves bytes out of it. Re-writing a byte-identical 24 MiB file
|
|
840
|
+
// buys nothing, so compare first — but the cached digest describes what THIS
|
|
841
|
+
// process last wrote, which is not the same claim as "that is what is on disk
|
|
842
|
+
// now". A second proxy sharing the home, or anything that rewrites the file
|
|
843
|
+
// in place, leaves the digest describing bytes that are gone. Before every
|
|
844
|
+
// release-of-a-write, the previous behaviour rewrote unconditionally and so
|
|
845
|
+
// repaired that silently; skipping without checking would turn a repaired
|
|
846
|
+
// snapshot into a lost one at the next restart.
|
|
847
|
+
//
|
|
848
|
+
// Verify against the file itself, keyed to the resolved target so a retargeted
|
|
849
|
+
// symlink is also a miss. Reading back a matching-size file costs far less
|
|
850
|
+
// than the atomic replace it avoids, and only happens when the digest already
|
|
851
|
+
// matched — the amplification this fixes is the repeated WRITE, not the read.
|
|
852
|
+
const unchanged = lastSnapshotDigest !== null
|
|
853
|
+
&& payloadDigest === lastSnapshotDigest
|
|
854
|
+
&& payloadBytes === lastSnapshotBytes
|
|
855
|
+
&& lastSnapshotTarget === resolveWriteTarget(path)
|
|
856
|
+
&& existsSync(path)
|
|
857
|
+
&& await snapshotOnDiskMatches(path, payload, payloadBytes);
|
|
858
|
+
if (!unchanged) {
|
|
859
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
860
|
+
try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
|
|
861
|
+
await atomicWriteFileAsync(path, payload);
|
|
862
|
+
lastSnapshotDigest = payloadDigest;
|
|
863
|
+
lastSnapshotBytes = payloadBytes;
|
|
864
|
+
lastSnapshotTarget = resolveWriteTarget(path);
|
|
865
|
+
}
|
|
517
866
|
persistAttemptHookForTests?.();
|
|
518
867
|
if (revision === stateRevision) return "stable";
|
|
519
868
|
}
|
|
@@ -532,11 +881,26 @@ function drainPendingSpillUnlinks(): void {
|
|
|
532
881
|
}
|
|
533
882
|
}
|
|
534
883
|
|
|
884
|
+
/**
|
|
885
|
+
* Debounce scaled by the size of the last snapshot written.
|
|
886
|
+
*
|
|
887
|
+
* The whole snapshot is re-serialized and atomically replaced on every flush, so at
|
|
888
|
+
* the 24 MiB bound a fixed 2 s debounce is up to ~12 MB/s of write amplification for
|
|
889
|
+
* state nothing reads until the next start (#2460). Small snapshots keep the base
|
|
890
|
+
* cadence; the stretch is linear in size and clamped, so the write rate is roughly
|
|
891
|
+
* flat instead of growing with the file.
|
|
892
|
+
*/
|
|
893
|
+
function snapshotDebounceMs(): number {
|
|
894
|
+
if (lastSnapshotBytes <= SNAPSHOT_DEBOUNCE_SCALE_FROM_BYTES) return SNAPSHOT_DEBOUNCE_MS;
|
|
895
|
+
const scaled = Math.round(SNAPSHOT_DEBOUNCE_MS * (lastSnapshotBytes / SNAPSHOT_DEBOUNCE_SCALE_FROM_BYTES));
|
|
896
|
+
return Math.min(scaled, SNAPSHOT_DEBOUNCE_MAX_MS);
|
|
897
|
+
}
|
|
898
|
+
|
|
535
899
|
function schedulePersistAt(path: string, replace = false): void {
|
|
536
900
|
if (persistTimer && !replace) return;
|
|
537
901
|
if (persistTimer) clearTimeout(persistTimer);
|
|
538
902
|
pendingPersistPath = path;
|
|
539
|
-
persistTimer = setTimeout(() => { void persistNow(path); },
|
|
903
|
+
persistTimer = setTimeout(() => { void persistNow(path); }, snapshotDebounceMs());
|
|
540
904
|
(persistTimer as { unref?: () => void }).unref?.();
|
|
541
905
|
}
|
|
542
906
|
|
|
@@ -584,6 +948,94 @@ function inputItems(input: unknown): unknown[] {
|
|
|
584
948
|
return [input];
|
|
585
949
|
}
|
|
586
950
|
|
|
951
|
+
/** Hard cap for canonicalizing ANY item. Past it, the item is not comparable. */
|
|
952
|
+
const REPLAY_FINGERPRINT_MAX_BYTES = 8 * 1024;
|
|
953
|
+
/** Depth ceiling so a pathologically nested item cannot blow the canonicalizer. */
|
|
954
|
+
const REPLAY_FINGERPRINT_MAX_DEPTH = 64;
|
|
955
|
+
|
|
956
|
+
let replayOverlapSkips = 0;
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Canonical, order-stable fingerprint for one input item, or null when the item cannot be
|
|
960
|
+
* compared safely.
|
|
961
|
+
*
|
|
962
|
+
* Byte-counted DURING the walk rather than serialize-then-measure: a tool result can be
|
|
963
|
+
* megabytes and this runs on the request path, so the point of the cap is to stop early,
|
|
964
|
+
* not to discover afterwards that we should have. Object keys are sorted so two
|
|
965
|
+
* semantically identical items cannot differ by key order alone.
|
|
966
|
+
*
|
|
967
|
+
* The cap applies to EVERY item. An `id`/`call_id` is additional occurrence evidence, never
|
|
968
|
+
* a substitute for content equality, so an over-cap identified tool item is non-comparable
|
|
969
|
+
* exactly like an over-cap message.
|
|
970
|
+
*/
|
|
971
|
+
function replayItemFingerprint(item: unknown): string | null {
|
|
972
|
+
const out: string[] = [];
|
|
973
|
+
let bytes = 0;
|
|
974
|
+
const push = (text: string): boolean => {
|
|
975
|
+
bytes += Buffer.byteLength(text, "utf8");
|
|
976
|
+
if (bytes > REPLAY_FINGERPRINT_MAX_BYTES) return false;
|
|
977
|
+
out.push(text);
|
|
978
|
+
return true;
|
|
979
|
+
};
|
|
980
|
+
const walk = (value: unknown, depth: number): boolean => {
|
|
981
|
+
if (depth > REPLAY_FINGERPRINT_MAX_DEPTH) return false;
|
|
982
|
+
if (value === null || typeof value !== "object") return push(JSON.stringify(value) ?? "null");
|
|
983
|
+
if (Array.isArray(value)) {
|
|
984
|
+
if (!push("[")) return false;
|
|
985
|
+
for (const element of value) {
|
|
986
|
+
if (!walk(element, depth + 1)) return false;
|
|
987
|
+
if (!push(",")) return false;
|
|
988
|
+
}
|
|
989
|
+
return push("]");
|
|
990
|
+
}
|
|
991
|
+
if (!push("{")) return false;
|
|
992
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
993
|
+
if (!push(JSON.stringify(key))) return false;
|
|
994
|
+
if (!walk((value as Record<string, unknown>)[key], depth + 1)) return false;
|
|
995
|
+
if (!push(",")) return false;
|
|
996
|
+
}
|
|
997
|
+
return push("}");
|
|
998
|
+
};
|
|
999
|
+
return walk(item, 0) ? out.join("") : null;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Non-empty provider-issued `id`/`call_id` on an item, else null. */
|
|
1003
|
+
function providerIssuedIdentity(item: unknown): string | null {
|
|
1004
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
|
|
1005
|
+
const record = item as { id?: unknown; call_id?: unknown };
|
|
1006
|
+
for (const candidate of [record.id, record.call_id]) {
|
|
1007
|
+
if (typeof candidate === "string" && candidate.trim().length > 0) return candidate;
|
|
1008
|
+
}
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Number of leading stored items the client already carries verbatim, or 0.
|
|
1014
|
+
*
|
|
1015
|
+
* Requires an exact ordered run: every stored item must match the client input item at the
|
|
1016
|
+
* same index. Any not-comparable item aborts to 0 — skipping just that item could align two
|
|
1017
|
+
* different occurrences and manufacture a false positive, and a false positive here deletes
|
|
1018
|
+
* real conversation history.
|
|
1019
|
+
*
|
|
1020
|
+
* Known gap (FU-2): stored input can contain proxy-injected guidance the client never saw,
|
|
1021
|
+
* and ids repaired after recording. Those sessions do not match here and expand as before.
|
|
1022
|
+
*/
|
|
1023
|
+
function clientCarriedPrefixLength(stored: readonly unknown[], clientInput: readonly unknown[]): number {
|
|
1024
|
+
if (stored.length === 0 || clientInput.length < stored.length) return 0;
|
|
1025
|
+
for (let index = 0; index < stored.length; index += 1) {
|
|
1026
|
+
const storedPrint = replayItemFingerprint(stored[index]);
|
|
1027
|
+
if (storedPrint === null) return 0;
|
|
1028
|
+
const clientPrint = replayItemFingerprint(clientInput[index]);
|
|
1029
|
+
if (clientPrint === null || storedPrint !== clientPrint) return 0;
|
|
1030
|
+
}
|
|
1031
|
+
return stored.length;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Test-only: replay prepends skipped because the client already carried the history. */
|
|
1035
|
+
export function replayOverlapSkipsForTests(): number {
|
|
1036
|
+
return replayOverlapSkips;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
587
1039
|
function pruneResponses(at = now()): void {
|
|
588
1040
|
for (const [id, state] of states) {
|
|
589
1041
|
if (at - state.createdAt > RESPONSE_TTL_MS) deleteEntry(id);
|
|
@@ -607,7 +1059,9 @@ function pruneResponses(at = now()): void {
|
|
|
607
1059
|
try {
|
|
608
1060
|
const ref = writeResponseSpillDurably(oldestId, {
|
|
609
1061
|
createdAt: entry.createdAt,
|
|
1062
|
+
...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}),
|
|
610
1063
|
items: entry.items,
|
|
1064
|
+
...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}),
|
|
611
1065
|
...(entry.providers ? { providers: entry.providers } : {}),
|
|
612
1066
|
});
|
|
613
1067
|
if (swapResidentForSpill(oldestId, entry, ref)) spillCounters.writes += 1;
|
|
@@ -630,6 +1084,64 @@ export function sweepExpiredResponseStates(at = now()): number {
|
|
|
630
1084
|
return removed;
|
|
631
1085
|
}
|
|
632
1086
|
|
|
1087
|
+
/**
|
|
1088
|
+
* Periodic disk reclaim for abandoned atomic-write temps.
|
|
1089
|
+
*
|
|
1090
|
+
* `ensureLoaded` sweeps once per process, at load, BEFORE that process writes anything:
|
|
1091
|
+
* every `schedulePersist` site is downstream of it. So a process that abandons a temp has
|
|
1092
|
+
* already had its only look, the 15-minute grace hides the temp its predecessor's crash
|
|
1093
|
+
* just produced, and `maxCleanups` caps a single pass below a large backlog. A restart
|
|
1094
|
+
* loop therefore accumulates monotonically. Repeating the reclaim on a timer fixes all
|
|
1095
|
+
* three: the grace expires into a later tick and the per-pass cap becomes a per-tick rate.
|
|
1096
|
+
*
|
|
1097
|
+
* Registered on the sweeper's LIVENESS tick, not the TTL tick: `sweepExpiredOnWrite` puts
|
|
1098
|
+
* `sweepExpired` on hot write paths, and a directory scan does not belong there.
|
|
1099
|
+
*/
|
|
1100
|
+
export function reclaimAbandonedResponseStateTemps(
|
|
1101
|
+
options: ResponseStateTempRecoveryOptions = {},
|
|
1102
|
+
): ResponseStateTempRecoveryResult {
|
|
1103
|
+
const total: ResponseStateTempRecoveryResult = {
|
|
1104
|
+
matched: 0, removed: 0, failed: 0, bytesRemoved: 0, eligible: 0, eligibleBytes: 0, truncated: false,
|
|
1105
|
+
};
|
|
1106
|
+
// The try encloses responseStateSweepDirectories() deliberately: recoverStaleResponseStateTemps
|
|
1107
|
+
// already swallows its own enumeration failures, so a catch around only that call would be
|
|
1108
|
+
// unreachable. snapshotPath()/getConfigDir() are the paths that can genuinely throw.
|
|
1109
|
+
try {
|
|
1110
|
+
for (const dir of responseStateSweepDirectories()) {
|
|
1111
|
+
const result = recoverStaleResponseStateTemps(dir, options);
|
|
1112
|
+
total.matched += result.matched;
|
|
1113
|
+
total.removed += result.removed;
|
|
1114
|
+
total.failed += result.failed;
|
|
1115
|
+
total.bytesRemoved += result.bytesRemoved;
|
|
1116
|
+
total.eligible += result.eligible;
|
|
1117
|
+
total.eligibleBytes += result.eligibleBytes;
|
|
1118
|
+
// Truncation anywhere makes the whole total a prefix.
|
|
1119
|
+
total.truncated ||= result.truncated;
|
|
1120
|
+
}
|
|
1121
|
+
} catch {
|
|
1122
|
+
/* best-effort: disk reclaim must never destabilize the caller */
|
|
1123
|
+
}
|
|
1124
|
+
return total;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Report-only counterpart for `ocx doctor`: applies every selection gate and unlinks
|
|
1129
|
+
* nothing. It runs the SAME predicate as the reclaim, so the report and the subsequent
|
|
1130
|
+
* removal cannot disagree about which files are reclaimable.
|
|
1131
|
+
*/
|
|
1132
|
+
export function inspectAbandonedResponseStateTemps(): ResponseStateTempRecoveryResult {
|
|
1133
|
+
return reclaimAbandonedResponseStateTemps({ dryRun: true });
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** Sweeper adapter: narrows the reclaim to the `() => number` the liveness tick expects. */
|
|
1137
|
+
export function sweepAbandonedResponseStateTemps(): number {
|
|
1138
|
+
return reclaimAbandonedResponseStateTemps({
|
|
1139
|
+
maxEntries: PERIODIC_TEMP_MAX_ENTRIES,
|
|
1140
|
+
maxCleanups: PERIODIC_TEMP_MAX_CLEANUPS,
|
|
1141
|
+
deadlineMs: PERIODIC_TEMP_SCAN_DEADLINE_MS,
|
|
1142
|
+
}).removed;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
633
1145
|
export function responseContinuationRetainedStoreSnapshot(): RetainedStoreSnapshot {
|
|
634
1146
|
return {
|
|
635
1147
|
count: states.size,
|
|
@@ -648,7 +1160,9 @@ export function evictOldestResponseContinuationForBudget(): number {
|
|
|
648
1160
|
try {
|
|
649
1161
|
const ref = writeResponseSpillDurably(id, {
|
|
650
1162
|
createdAt: entry.createdAt,
|
|
1163
|
+
...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}),
|
|
651
1164
|
items: entry.items,
|
|
1165
|
+
...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}),
|
|
652
1166
|
...(entry.providers ? { providers: entry.providers } : {}),
|
|
653
1167
|
});
|
|
654
1168
|
if (swapResidentForSpill(id, entry, ref)) spillCounters.writes += 1;
|
|
@@ -676,7 +1190,11 @@ function materializeEntry(
|
|
|
676
1190
|
spillCounters.readFailures += 1;
|
|
677
1191
|
const failure: PreviousResponseReplayFailure = {
|
|
678
1192
|
code: "previous_response_not_found",
|
|
679
|
-
reason: result.reason === "missing"
|
|
1193
|
+
reason: result.reason === "missing"
|
|
1194
|
+
? "spill_missing"
|
|
1195
|
+
: result.reason === "too_large"
|
|
1196
|
+
? "spill_too_large"
|
|
1197
|
+
: "spill_corrupt",
|
|
680
1198
|
};
|
|
681
1199
|
replaceWithSpillFailure(id, entry);
|
|
682
1200
|
schedulePersist();
|
|
@@ -684,7 +1202,11 @@ function materializeEntry(
|
|
|
684
1202
|
}
|
|
685
1203
|
const state = measureResidentEntry(id, {
|
|
686
1204
|
createdAt: result.payload.createdAt,
|
|
1205
|
+
...(result.payload.clientThreadId ? { clientThreadId: result.payload.clientThreadId } : {}),
|
|
687
1206
|
items: result.payload.items,
|
|
1207
|
+
...(result.payload.providerOutputStart !== undefined
|
|
1208
|
+
? { providerOutputStart: result.payload.providerOutputStart }
|
|
1209
|
+
: {}),
|
|
688
1210
|
...(result.payload.providers ? { providers: result.payload.providers } : {}),
|
|
689
1211
|
});
|
|
690
1212
|
if (!state) {
|
|
@@ -696,7 +1218,16 @@ function materializeEntry(
|
|
|
696
1218
|
return { ok: true, state };
|
|
697
1219
|
}
|
|
698
1220
|
|
|
699
|
-
|
|
1221
|
+
function normalizedClientThreadId(value: unknown): string | undefined {
|
|
1222
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function withoutPreviousResponseId(request: Record<string, unknown>): Record<string, unknown> {
|
|
1226
|
+
const { previous_response_id: _previousResponseId, ...freshRequest } = request;
|
|
1227
|
+
return freshRequest;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
export function expandPreviousResponseInput(body: unknown, clientThreadId?: string): unknown {
|
|
700
1231
|
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
|
701
1232
|
const request = body as Record<string, unknown>;
|
|
702
1233
|
const previousId = typeof request.previous_response_id === "string" ? request.previous_response_id : undefined;
|
|
@@ -710,6 +1241,49 @@ export function expandPreviousResponseInput(body: unknown): unknown {
|
|
|
710
1241
|
replayFailures.set(request, materialized.failure);
|
|
711
1242
|
return body;
|
|
712
1243
|
}
|
|
1244
|
+
const requestThreadId = normalizedClientThreadId(clientThreadId);
|
|
1245
|
+
const storedThreadId = normalizedClientThreadId(materialized.state.clientThreadId);
|
|
1246
|
+
// A Codex task must never inherit another task's continuation, nor a legacy unscoped entry.
|
|
1247
|
+
// Unscoped callers retain backward-compatible replay only with other unscoped entries.
|
|
1248
|
+
if (requestThreadId !== storedThreadId) {
|
|
1249
|
+
const freshRequest = withoutPreviousResponseId(request);
|
|
1250
|
+
replayScopeMismatches.add(freshRequest);
|
|
1251
|
+
replayScopeMismatchDrops += 1;
|
|
1252
|
+
return freshRequest;
|
|
1253
|
+
}
|
|
1254
|
+
// The client already replayed this history verbatim. Prepending the stored copy would
|
|
1255
|
+
// double it, and the doubled turn is stored again, so the next turn triples (#1412 saw
|
|
1256
|
+
// 127k of real context reach 1.3M tokens this way).
|
|
1257
|
+
//
|
|
1258
|
+
// Three conditions, all required. The run must cover the whole stored entry; it must reach
|
|
1259
|
+
// the provider-output region; and some matched item in that region must carry a
|
|
1260
|
+
// provider-issued id. The last one is the load-bearing part: content equality alone proves
|
|
1261
|
+
// two items look alike, not that they are the same occurrence, so a client that merely
|
|
1262
|
+
// repeats its own message would otherwise authorize a skip that deletes real history.
|
|
1263
|
+
// There is no invariant that provider output always carries ids, so an entry whose output
|
|
1264
|
+
// has none simply never skips.
|
|
1265
|
+
{
|
|
1266
|
+
const clientInput = inputItems(request.input);
|
|
1267
|
+
const stored = materialized.state.items;
|
|
1268
|
+
const anchor = materialized.state.providerOutputStart;
|
|
1269
|
+
const carried = clientCarriedPrefixLength(stored, clientInput);
|
|
1270
|
+
if (
|
|
1271
|
+
carried === stored.length
|
|
1272
|
+
&& anchor !== undefined
|
|
1273
|
+
&& carried > anchor
|
|
1274
|
+
&& stored.slice(anchor, carried).some(item => providerIssuedIdentity(item) !== null)
|
|
1275
|
+
) {
|
|
1276
|
+
replayOverlapSkips += 1;
|
|
1277
|
+
// Keep previous_response_id: Kiro and Cursor recover their conversation ids from it
|
|
1278
|
+
// (kiro-wire.ts, cursor/request-builder.ts). Only the concatenation is skipped.
|
|
1279
|
+
const unchanged = { ...request };
|
|
1280
|
+
// Same provenance boundary a real expansion would record, so the replayed prefix does
|
|
1281
|
+
// not re-acknowledge historical compaction markers (parser.ts) and stays visible to
|
|
1282
|
+
// guidance de-duplication (collaboration.ts).
|
|
1283
|
+
replayedInputPrefixLengths.set(unchanged, carried);
|
|
1284
|
+
return unchanged;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
713
1287
|
const expanded = {
|
|
714
1288
|
...request,
|
|
715
1289
|
input: [...materialized.state.items, ...inputItems(request.input)],
|
|
@@ -729,6 +1303,22 @@ export function previousResponseReplayPrefixLength(body: unknown): number {
|
|
|
729
1303
|
return replayedInputPrefixLengths.get(body) ?? 0;
|
|
730
1304
|
}
|
|
731
1305
|
|
|
1306
|
+
/** Copy proxy-private replay provenance to an internal clone with the same materialized input. */
|
|
1307
|
+
export function copyPreviousResponseReplayProvenance(source: unknown, target: unknown): void {
|
|
1308
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) return;
|
|
1309
|
+
if (!target || typeof target !== "object" || Array.isArray(target)) return;
|
|
1310
|
+
const prefixLength = replayedInputPrefixLengths.get(source);
|
|
1311
|
+
if (!prefixLength) return;
|
|
1312
|
+
const input = (target as { input?: unknown }).input;
|
|
1313
|
+
if (!Array.isArray(input) || prefixLength > input.length) return;
|
|
1314
|
+
replayedInputPrefixLengths.set(target, prefixLength);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/** True when a stale or foreign previous_response_id was removed from this exact request body. */
|
|
1318
|
+
export function previousResponseScopeMismatch(body: unknown): boolean {
|
|
1319
|
+
return !!body && typeof body === "object" && replayScopeMismatches.has(body as object);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
732
1322
|
export function previousResponseConversationId(responseId: string | undefined): string | undefined {
|
|
733
1323
|
return previousResponseProviderState(responseId)?.cursor?.conversationId;
|
|
734
1324
|
}
|
|
@@ -754,6 +1344,7 @@ export interface ResponseStateMetrics {
|
|
|
754
1344
|
spillWrites: number;
|
|
755
1345
|
spillWriteFailures: number;
|
|
756
1346
|
spillReadFailures: number;
|
|
1347
|
+
replayScopeMismatchDrops: number;
|
|
757
1348
|
}
|
|
758
1349
|
|
|
759
1350
|
/**
|
|
@@ -796,6 +1387,7 @@ export function responseStateMetrics(): ResponseStateMetrics {
|
|
|
796
1387
|
spillWrites: spillCounters.writes,
|
|
797
1388
|
spillWriteFailures: spillCounters.writeFailures,
|
|
798
1389
|
spillReadFailures: spillCounters.readFailures,
|
|
1390
|
+
replayScopeMismatchDrops,
|
|
799
1391
|
};
|
|
800
1392
|
}
|
|
801
1393
|
|
|
@@ -803,14 +1395,36 @@ export function responseStateMetrics(): ResponseStateMetrics {
|
|
|
803
1395
|
* Cache completed output and max_output_tokens partial output for previous_response_id replay.
|
|
804
1396
|
* Content-filtered incomplete and failed output are not authoritative replay history.
|
|
805
1397
|
*/
|
|
1398
|
+
/**
|
|
1399
|
+
* Request bodies that must never enter the continuation cache.
|
|
1400
|
+
*
|
|
1401
|
+
* The cache is persisted to `responses-state.json`, so anything recorded here reaches disk.
|
|
1402
|
+
* Encrypted-agent-task recovery decrypts task text into the request body and promises
|
|
1403
|
+
* in-memory, TTL-bounded retention; recording that body would put the plaintext on disk with
|
|
1404
|
+
* no TTL and break the promise.
|
|
1405
|
+
*
|
|
1406
|
+
* A WeakSet rather than a body field on purpose: `_rawBody` is serialized verbatim by the
|
|
1407
|
+
* native passthrough, so any marker written into the body itself would be sent upstream.
|
|
1408
|
+
* Marking is enforced once here rather than at each call site, because every recording path
|
|
1409
|
+
* (streaming, non-streaming, passthrough, forced) funnels through `rememberResponseState` —
|
|
1410
|
+
* a new call site cannot reintroduce the leak by forgetting a guard.
|
|
1411
|
+
*/
|
|
1412
|
+
const nonPersistableBodies = new WeakSet<object>();
|
|
1413
|
+
|
|
1414
|
+
/** Bar this exact request body from the continuation cache, and therefore from disk. */
|
|
1415
|
+
export function markBodyNonPersistable(body: unknown): void {
|
|
1416
|
+
if (body && typeof body === "object") nonPersistableBodies.add(body as object);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
806
1419
|
export function rememberResponseState(
|
|
807
1420
|
requestBody: unknown,
|
|
808
1421
|
response: { id?: unknown; output?: unknown; status?: unknown; incomplete_details?: unknown },
|
|
809
1422
|
providerState?: OcxProviderContinuationState | string,
|
|
810
|
-
opts?: { force?: boolean },
|
|
1423
|
+
opts?: { force?: boolean; clientThreadId?: string },
|
|
811
1424
|
): void {
|
|
812
1425
|
if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) return;
|
|
813
1426
|
const request = requestBody as Record<string, unknown>;
|
|
1427
|
+
if (nonPersistableBodies.has(request)) return;
|
|
814
1428
|
// `force` bypasses only the store:false skip: Codex sends `store:false` on every non-Azure
|
|
815
1429
|
// HTTP request (and WS inherits it), yet its WS turns still chain with previous_response_id.
|
|
816
1430
|
// The passthrough branch records with force so those chains can be expanded locally; the
|
|
@@ -832,9 +1446,18 @@ export function rememberResponseState(
|
|
|
832
1446
|
return !!item && typeof item === "object" && (item as { type?: unknown }).type === "function_call";
|
|
833
1447
|
});
|
|
834
1448
|
}
|
|
1449
|
+
const clientThreadId = normalizedClientThreadId(opts?.clientThreadId);
|
|
1450
|
+
// Compute the normalized array once and reuse it for both fields, so the recorded
|
|
1451
|
+
// boundary can never disagree with the items it indexes.
|
|
1452
|
+
const requestItems = inputItems(request.input);
|
|
835
1453
|
setResidentEntry(response.id, {
|
|
836
1454
|
createdAt: now(),
|
|
837
|
-
|
|
1455
|
+
...(clientThreadId ? { clientThreadId } : {}),
|
|
1456
|
+
items: [...requestItems, ...response.output],
|
|
1457
|
+
// Where response.output begins. A replay skip requires a matched item at or past this
|
|
1458
|
+
// index that also carries a provider-issued id — position alone proves only that an item
|
|
1459
|
+
// sits on the provider side, not that the provider authored it.
|
|
1460
|
+
providerOutputStart: requestItems.length,
|
|
838
1461
|
// Always preserve the Cursor conversation id so the next tool-result turn can continue the SAME
|
|
839
1462
|
// Cursor conversation (multi-turn continuation). Separately track whether Cursor's own
|
|
840
1463
|
// checkpoint/cache is safe to reuse: a turn that ended with a pending client tool call produced an
|
|
@@ -879,7 +1502,12 @@ export function clearResponseStateMemoryForTests(): void {
|
|
|
879
1502
|
spillCounters.writes = 0;
|
|
880
1503
|
spillCounters.writeFailures = 0;
|
|
881
1504
|
spillCounters.readFailures = 0;
|
|
1505
|
+
replayScopeMismatchDrops = 0;
|
|
1506
|
+
replayOverlapSkips = 0;
|
|
882
1507
|
persistAttemptHookForTests = null;
|
|
1508
|
+
lastSnapshotBytes = 0;
|
|
1509
|
+
lastSnapshotDigest = null;
|
|
1510
|
+
lastSnapshotTarget = null;
|
|
883
1511
|
loaded = false;
|
|
884
1512
|
}
|
|
885
1513
|
|