@yansigit/opencodex 2.31.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/LICENSE +21 -0
- package/README.md +303 -0
- package/assets/architecture.png +0 -0
- package/assets/banner.png +0 -0
- package/assets/claude-code-models.gif +0 -0
- package/assets/codex-app-picker.png +0 -0
- package/bin/ocx.mjs +587 -0
- package/bin/package-main.mjs +9 -0
- package/gui/dist/assets/index-BNJ7r4Gd.js +102 -0
- package/gui/dist/assets/index-CGoDO3uO.css +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/icons.svg +24 -0
- package/gui/dist/index.html +25 -0
- package/gui/dist/logo.png +0 -0
- package/gui/dist/provider-icons/alibaba-color.svg +1 -0
- package/gui/dist/provider-icons/antigravity-color.svg +1 -0
- package/gui/dist/provider-icons/claude-color.svg +1 -0
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/commandcode-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -0
- package/gui/dist/provider-icons/cursor-color.svg +2 -0
- package/gui/dist/provider-icons/deepseek-color.svg +1 -0
- package/gui/dist/provider-icons/discord.svg +1 -0
- package/gui/dist/provider-icons/firepass-color.svg +1 -0
- package/gui/dist/provider-icons/fireworks-color.svg +1 -0
- package/gui/dist/provider-icons/gemini-color.svg +1 -0
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
- package/gui/dist/provider-icons/grok.svg +1 -0
- package/gui/dist/provider-icons/groq-color.svg +1 -0
- package/gui/dist/provider-icons/huggingface-color.svg +1 -0
- package/gui/dist/provider-icons/kimi-color.svg +1 -0
- package/gui/dist/provider-icons/kiro-color.svg +15 -0
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
- package/gui/dist/provider-icons/mistral-color.svg +1 -0
- package/gui/dist/provider-icons/moonshot-color.svg +1 -0
- package/gui/dist/provider-icons/nvidia-color.svg +1 -0
- package/gui/dist/provider-icons/ollama-color.svg +1 -0
- package/gui/dist/provider-icons/openai.svg +1 -0
- package/gui/dist/provider-icons/opencode.svg +2 -0
- package/gui/dist/provider-icons/openrouter-color.svg +1 -0
- package/gui/dist/provider-icons/pi.svg +21 -0
- package/gui/dist/provider-icons/qianfan-color.svg +1 -0
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
- package/gui/dist/provider-icons/telegram.svg +1 -0
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/vllm-color.svg +1 -0
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
- package/package.json +108 -0
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic-image-guard.ts +251 -0
- package/src/adapters/anthropic-image-normalize.ts +518 -0
- package/src/adapters/anthropic-output-schema.ts +137 -0
- package/src/adapters/anthropic.ts +1327 -0
- package/src/adapters/azure.ts +36 -0
- package/src/adapters/base.ts +121 -0
- package/src/adapters/client-fingerprint.ts +65 -0
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
- package/src/adapters/command-code.ts +601 -0
- package/src/adapters/cursor/arg-codec.ts +38 -0
- package/src/adapters/cursor/arg-normalize.ts +104 -0
- package/src/adapters/cursor/checkpoint-store.ts +303 -0
- package/src/adapters/cursor/cursor-errors.ts +288 -0
- package/src/adapters/cursor/discovery.ts +333 -0
- package/src/adapters/cursor/effort-map.ts +151 -0
- package/src/adapters/cursor/exec-policy.ts +88 -0
- package/src/adapters/cursor/framing.ts +250 -0
- package/src/adapters/cursor/gen/agent_pb.ts +15274 -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/kv-store.ts +52 -0
- package/src/adapters/cursor/live-models.ts +269 -0
- package/src/adapters/cursor/live-smoke-gate.ts +41 -0
- package/src/adapters/cursor/live-transport.ts +1653 -0
- package/src/adapters/cursor/mcp-config.ts +42 -0
- package/src/adapters/cursor/mcp-manager.ts +333 -0
- package/src/adapters/cursor/message-mapper.ts +49 -0
- package/src/adapters/cursor/native-exec-common.ts +76 -0
- package/src/adapters/cursor/native-exec-desktop.ts +184 -0
- package/src/adapters/cursor/native-exec-fs.ts +332 -0
- package/src/adapters/cursor/native-exec-mcp.ts +153 -0
- package/src/adapters/cursor/native-exec-network.ts +43 -0
- package/src/adapters/cursor/native-exec-shell.ts +547 -0
- package/src/adapters/cursor/native-exec-tools.ts +118 -0
- package/src/adapters/cursor/native-exec.ts +663 -0
- package/src/adapters/cursor/protobuf-events.ts +1381 -0
- package/src/adapters/cursor/protobuf-request.ts +1032 -0
- package/src/adapters/cursor/request-builder.ts +461 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/tool-definitions.ts +735 -0
- package/src/adapters/cursor/tool-result-normalize.ts +92 -0
- package/src/adapters/cursor/transport-retry.ts +132 -0
- package/src/adapters/cursor/transport.ts +79 -0
- package/src/adapters/cursor/types.ts +79 -0
- package/src/adapters/cursor.ts +322 -0
- package/src/adapters/google-antigravity-hosts.ts +48 -0
- package/src/adapters/google-antigravity-replay.ts +827 -0
- package/src/adapters/google-antigravity-tools.ts +105 -0
- package/src/adapters/google-antigravity-wire.ts +141 -0
- package/src/adapters/google-errors.ts +92 -0
- package/src/adapters/google-http.ts +460 -0
- package/src/adapters/google-tool-schema.ts +238 -0
- package/src/adapters/google-truncation.ts +24 -0
- package/src/adapters/google-wire-compiler.ts +232 -0
- package/src/adapters/google.ts +1377 -0
- package/src/adapters/identity.ts +77 -0
- package/src/adapters/image.ts +23 -0
- package/src/adapters/kiro-constants.ts +16 -0
- package/src/adapters/kiro-errors.ts +208 -0
- package/src/adapters/kiro-events.ts +197 -0
- package/src/adapters/kiro-images.ts +129 -0
- package/src/adapters/kiro-retry.ts +312 -0
- package/src/adapters/kiro-thinking.ts +112 -0
- package/src/adapters/kiro-tool-fallback.ts +36 -0
- package/src/adapters/kiro-tools.ts +224 -0
- package/src/adapters/kiro-truncation.ts +33 -0
- package/src/adapters/kiro-wire.ts +129 -0
- package/src/adapters/kiro.ts +1936 -0
- package/src/adapters/mimo-free.ts +280 -0
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +1980 -0
- package/src/adapters/openai-responses-url.ts +16 -0
- package/src/adapters/openai-responses.ts +1911 -0
- package/src/adapters/registry.ts +175 -0
- package/src/adapters/responses-tool-schema.ts +67 -0
- package/src/adapters/run-turn-queue.ts +114 -0
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +154 -0
- package/src/adapters/upstream-http-error.ts +48 -0
- package/src/adapters/xai-web-search.ts +185 -0
- package/src/bridge.ts +1986 -0
- package/src/chat/inbound.ts +319 -0
- package/src/chat/outbound.ts +821 -0
- package/src/claude/agents-inject.ts +266 -0
- package/src/claude/alias.ts +149 -0
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/context-windows.ts +205 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p-paths.ts +84 -0
- package/src/claude/desktop-3p.ts +615 -0
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/gateway-cache.ts +107 -0
- package/src/claude/inbound-debug.ts +163 -0
- package/src/claude/inbound.ts +578 -0
- package/src/claude/model-info.ts +174 -0
- package/src/claude/outbound.ts +926 -0
- package/src/cli/access.ts +108 -0
- package/src/cli/account-api.ts +302 -0
- package/src/cli/account-auth.ts +250 -0
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +737 -0
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +299 -0
- package/src/cli/agent-driven.ts +70 -0
- package/src/cli/agent.ts +290 -0
- package/src/cli/catalog-prewarm.ts +27 -0
- package/src/cli/claude-agent-startup-sync.ts +73 -0
- package/src/cli/claude-desktop.ts +213 -0
- package/src/cli/claude.ts +355 -0
- package/src/cli/codex-log-guard-doctor.ts +103 -0
- package/src/cli/codex-shim-autorestore.ts +47 -0
- package/src/cli/codex-shim-readiness.ts +76 -0
- package/src/cli/combo.ts +127 -0
- package/src/cli/config-command.ts +209 -0
- package/src/cli/debug.ts +228 -0
- package/src/cli/dispatch.ts +585 -0
- package/src/cli/doctor.ts +1202 -0
- package/src/cli/ensure-desired-integrations.ts +152 -0
- package/src/cli/export-command.ts +213 -0
- package/src/cli/help.ts +101 -0
- package/src/cli/index.ts +973 -0
- package/src/cli/init.ts +211 -0
- package/src/cli/integrations.ts +260 -0
- package/src/cli/interactive-confirm.ts +133 -0
- 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 +245 -0
- package/src/cli/models.ts +422 -0
- package/src/cli/observe.ts +206 -0
- package/src/cli/opencode.ts +588 -0
- package/src/cli/provider-replit.ts +232 -0
- package/src/cli/provider-runtime.ts +179 -0
- package/src/cli/provider.ts +492 -0
- package/src/cli/ready.ts +301 -0
- package/src/cli/registry.ts +422 -0
- package/src/cli/replit-gateway-key-input.ts +138 -0
- package/src/cli/root.ts +86 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +328 -0
- package/src/cli/star-prompt.ts +211 -0
- package/src/cli/status-oauth.ts +78 -0
- package/src/cli/status.ts +328 -0
- package/src/cli/system-command.ts +112 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +199 -0
- package/src/cli/v2.ts +268 -0
- package/src/cli.ts +10 -0
- package/src/clients/config-export.ts +1704 -0
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-label.ts +47 -0
- package/src/codex/account-lifecycle.ts +172 -0
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +195 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/account-runtime-state.ts +31 -0
- package/src/codex/account-store.ts +544 -0
- package/src/codex/account-usability.ts +43 -0
- package/src/codex/admission.ts +256 -0
- package/src/codex/affinity-debug.ts +162 -0
- package/src/codex/agent-roles-sync.ts +225 -0
- package/src/codex/agent-roles.ts +238 -0
- package/src/codex/app-server-processes.ts +1143 -0
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/auth-api.ts +2147 -0
- package/src/codex/auth-collision.ts +109 -0
- package/src/codex/auth-context.ts +665 -0
- package/src/codex/autostart-health.ts +156 -0
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +436 -0
- package/src/codex/catalog/bundled.ts +549 -0
- package/src/codex/catalog/effort.ts +446 -0
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +664 -0
- package/src/codex/catalog/native-models.ts +72 -0
- package/src/codex/catalog/parsing.ts +650 -0
- package/src/codex/catalog/provider-fetch.ts +2064 -0
- package/src/codex/catalog/sync.ts +1883 -0
- 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 +14 -0
- package/src/codex/codex-write-lock.ts +384 -0
- package/src/codex/convergence-types.ts +614 -0
- package/src/codex/convergence.ts +651 -0
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/data/upstream-models.json +830 -0
- package/src/codex/desired-state.ts +230 -0
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/features.ts +1566 -0
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +407 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-migration-guardian.ts +108 -0
- package/src/codex/history-provider.ts +979 -0
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +220 -0
- package/src/codex/home.ts +206 -0
- package/src/codex/inject-coordination.ts +290 -0
- package/src/codex/inject.ts +1733 -0
- package/src/codex/injected-marker.ts +106 -0
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +80 -0
- package/src/codex/journal.ts +225 -0
- package/src/codex/log-guard/inspect.ts +506 -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 +56 -0
- package/src/codex/main-account.ts +68 -0
- package/src/codex/management-convergence.ts +167 -0
- package/src/codex/model-cache.ts +273 -0
- package/src/codex/model-entitlements.ts +353 -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 +492 -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 +682 -0
- package/src/codex/paths.ts +144 -0
- package/src/codex/plan-from-token.ts +140 -0
- package/src/codex/plan.ts +40 -0
- package/src/codex/plugins-doctor.ts +242 -0
- package/src/codex/pool-rotation.ts +295 -0
- package/src/codex/project-config-warnings.ts +425 -0
- package/src/codex/prompt-journal.ts +352 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +298 -0
- package/src/codex/quota.ts +573 -0
- package/src/codex/refresh.ts +62 -0
- package/src/codex/reset-credit-recovery.ts +1044 -0
- package/src/codex/routing.ts +1888 -0
- package/src/codex/runtime.ts +659 -0
- package/src/codex/shim.ts +2170 -0
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +784 -0
- package/src/codex/sync.ts +319 -0
- package/src/codex/transition-state.ts +612 -0
- package/src/codex/upstream-host-health.ts +368 -0
- package/src/codex/user-identity.ts +557 -0
- package/src/codex/warmup.ts +298 -0
- package/src/codex/websocket-registry.ts +100 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/combos/failover.ts +160 -0
- package/src/combos/index.ts +45 -0
- package/src/combos/request.ts +94 -0
- package/src/combos/resolve.ts +232 -0
- package/src/combos/types.ts +398 -0
- package/src/config/provider-name.ts +24 -0
- package/src/config.ts +4041 -0
- package/src/fork/register.ts +3 -0
- package/src/generated/compatibility-version.json +3116 -0
- package/src/generated/model-metadata.ts +106 -0
- package/src/github/star-state.ts +203 -0
- package/src/grok/inject.ts +530 -0
- package/src/grok/inspect.ts +45 -0
- package/src/grok/status.ts +121 -0
- package/src/grok/sync.ts +66 -0
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +149 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +955 -0
- package/src/images/plan.ts +143 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/index.ts +22 -0
- 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.ts +111 -0
- package/src/integrations/registry.ts +159 -0
- package/src/integrations/serialize.ts +314 -0
- package/src/integrations/state.ts +361 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer-lock.ts +98 -0
- package/src/integrations/writer.ts +691 -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/abort.ts +146 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +195 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bounded-body.ts +346 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +184 -0
- package/src/lib/bun-stream-caps.ts +130 -0
- package/src/lib/codex-restart-contract.ts +120 -0
- package/src/lib/config-ownership.ts +364 -0
- package/src/lib/crash-guard.ts +344 -0
- package/src/lib/debug-log-buffer.ts +83 -0
- package/src/lib/debug-settings.ts +108 -0
- package/src/lib/debug.ts +31 -0
- package/src/lib/destination-policy.ts +380 -0
- package/src/lib/errors.ts +406 -0
- package/src/lib/eventstream-decoder.ts +253 -0
- package/src/lib/fabric-task-execution-authority.ts +7 -0
- package/src/lib/fabric-task-host.ts +29 -0
- package/src/lib/gcp-adc.ts +341 -0
- package/src/lib/injection-debug-log.ts +58 -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/open-url.ts +25 -0
- package/src/lib/optional-shutdown-hooks.ts +57 -0
- package/src/lib/pinned-http.ts +270 -0
- package/src/lib/privacy.ts +20 -0
- package/src/lib/process-control.ts +168 -0
- package/src/lib/provider-outbound.ts +210 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/redact.ts +521 -0
- package/src/lib/retry-after.ts +55 -0
- package/src/lib/self-launch-argv.ts +15 -0
- package/src/lib/server-resource-ownership.ts +71 -0
- package/src/lib/service-secrets.ts +25 -0
- package/src/lib/shadow-call.ts +61 -0
- package/src/lib/sidecar-tracker.ts +52 -0
- package/src/lib/sse-decoder.ts +364 -0
- package/src/lib/state-store-registrations.ts +119 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/token-estimate.ts +86 -0
- package/src/lib/tool-argument-integers.ts +202 -0
- package/src/lib/translator-budget.ts +400 -0
- package/src/lib/upstream-http-version.ts +57 -0
- package/src/lib/upstream-reachability.ts +95 -0
- package/src/lib/upstream-retry.ts +392 -0
- package/src/lib/win-exec.ts +115 -0
- package/src/lib/win-paths.ts +68 -0
- package/src/lib/windows-atomic-replace.ts +156 -0
- package/src/lib/windows-elevation.ts +773 -0
- package/src/lib/windows-secret-acl.ts +854 -0
- 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 +403 -0
- 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-routing.ts +594 -0
- package/src/oauth/anthropic.ts +188 -0
- package/src/oauth/antigravity-routing.ts +151 -0
- package/src/oauth/callback-server.ts +300 -0
- package/src/oauth/chatgpt.ts +161 -0
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/cursor.ts +252 -0
- package/src/oauth/github-copilot.ts +428 -0
- package/src/oauth/google-antigravity.ts +262 -0
- package/src/oauth/health.ts +407 -0
- package/src/oauth/index.ts +1504 -0
- package/src/oauth/key-providers.ts +124 -0
- package/src/oauth/kimi.ts +227 -0
- package/src/oauth/kiro-credentials.ts +726 -0
- package/src/oauth/kiro.ts +621 -0
- package/src/oauth/local-token-detect.ts +130 -0
- package/src/oauth/log.ts +50 -0
- package/src/oauth/login-cli.ts +223 -0
- package/src/oauth/nous.ts +798 -0
- package/src/oauth/pkce.ts +15 -0
- package/src/oauth/store.ts +728 -0
- package/src/oauth/token-guardian.ts +309 -0
- package/src/oauth/types.ts +62 -0
- package/src/oauth/xai.ts +241 -0
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +156 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/antigravity-models.ts +695 -0
- package/src/providers/antigravity-quota.ts +216 -0
- package/src/providers/api-keys.ts +140 -0
- package/src/providers/base-url-choices.ts +74 -0
- package/src/providers/codex-capacity.ts +292 -0
- package/src/providers/command-code-efforts.ts +144 -0
- package/src/providers/context-cap.ts +82 -0
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/derive.ts +586 -0
- package/src/providers/fastwire.ts +501 -0
- package/src/providers/free-directory.ts +187 -0
- package/src/providers/github-copilot-transport.ts +56 -0
- package/src/providers/google-vertex-location.ts +14 -0
- package/src/providers/key-failover.ts +271 -0
- package/src/providers/kiro-models.ts +67 -0
- package/src/providers/label.ts +19 -0
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +449 -0
- package/src/providers/model-rename-migration.ts +255 -0
- package/src/providers/model-rename-startup.ts +28 -0
- package/src/providers/openai-sidecar.ts +243 -0
- package/src/providers/openai-tier-startup.ts +56 -0
- package/src/providers/openai-tiers.ts +423 -0
- package/src/providers/openai-virtual-models.ts +83 -0
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/openrouter-routing.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +185 -0
- package/src/providers/quota.ts +2345 -0
- package/src/providers/registry.ts +2918 -0
- package/src/providers/replit/constants.ts +27 -0
- package/src/providers/replit/derive.ts +85 -0
- package/src/providers/replit/headers.ts +28 -0
- package/src/providers/replit/origin.ts +55 -0
- package/src/providers/replit/pair-install-response.ts +72 -0
- package/src/providers/replit/probe.ts +199 -0
- package/src/providers/replit/setup.ts +350 -0
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +277 -0
- package/src/providers/slug-codec.ts +103 -0
- 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 +148 -0
- package/src/reasoning-effort.ts +183 -0
- package/src/responses/compaction.ts +142 -0
- package/src/responses/custom-tool-compat.ts +266 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/namespace-tool-compat.ts +355 -0
- package/src/responses/parser.ts +838 -0
- package/src/responses/provider-continuation.ts +98 -0
- package/src/responses/provider-opaque-metadata.ts +73 -0
- package/src/responses/reasoning-envelope.ts +60 -0
- package/src/responses/reasoning-replay-cache.ts +426 -0
- package/src/responses/schema.ts +165 -0
- package/src/responses/spill-store.ts +459 -0
- package/src/responses/state.ts +1433 -0
- package/src/responses/thought-signature-replay.ts +347 -0
- package/src/responses/tool-groups.ts +19 -0
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/responses/truncated-stop-reason.ts +60 -0
- package/src/router.ts +761 -0
- package/src/routing/analytics.ts +378 -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 +145 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +776 -0
- package/src/server/adapter-resolve.ts +53 -0
- package/src/server/auth-cors.ts +751 -0
- package/src/server/background-lifecycle.ts +182 -0
- package/src/server/chat-completions.ts +442 -0
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +426 -0
- package/src/server/claude-messages.ts +1030 -0
- package/src/server/direct-local-http.ts +347 -0
- package/src/server/effort-policy.ts +190 -0
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/gui-static.ts +152 -0
- package/src/server/image-retry.ts +42 -0
- package/src/server/images.ts +568 -0
- package/src/server/index.ts +1813 -0
- package/src/server/lifecycle.ts +498 -0
- package/src/server/live.ts +717 -0
- 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 +1433 -0
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/api-key-usage.ts +193 -0
- package/src/server/management/body.ts +41 -0
- package/src/server/management/combo-routes.ts +263 -0
- package/src/server/management/config-routes.ts +835 -0
- package/src/server/management/context.ts +113 -0
- 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 +586 -0
- package/src/server/management/model-routes.ts +560 -0
- package/src/server/management/model-rows.ts +163 -0
- package/src/server/management/native-integration-routes.ts +769 -0
- package/src/server/management/oauth-account-routes.ts +637 -0
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +1033 -0
- package/src/server/management/replit-provider-routes.ts +86 -0
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +385 -0
- package/src/server/management/shared.ts +286 -0
- package/src/server/management/sidebar-routes.ts +106 -0
- 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 +435 -0
- package/src/server/management/system-routes.ts +194 -0
- package/src/server/management/usage-summary-cache.ts +94 -0
- 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 +314 -0
- package/src/server/management-auth.ts +482 -0
- package/src/server/memory-watchdog.ts +156 -0
- package/src/server/passive-route-linker.ts +66 -0
- package/src/server/port-reclaim.ts +307 -0
- package/src/server/ports.ts +156 -0
- package/src/server/proxy-liveness.ts +328 -0
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +353 -0
- package/src/server/relay.ts +1209 -0
- package/src/server/request-decompress.ts +239 -0
- package/src/server/request-log-conversation.ts +168 -0
- package/src/server/request-log.ts +1259 -0
- 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/collaboration.ts +551 -0
- package/src/server/responses/compact.ts +771 -0
- package/src/server/responses/core.ts +5389 -0
- package/src/server/responses/empty-completion-guard.ts +276 -0
- package/src/server/responses/encrypted-payload.ts +331 -0
- package/src/server/responses/fetch-helpers.ts +232 -0
- package/src/server/responses/input-admission.ts +185 -0
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/passthrough-error.ts +78 -0
- package/src/server/responses/policy-fallback.ts +178 -0
- package/src/server/responses/responses-field-backfill.ts +251 -0
- package/src/server/responses/terminal-guard.ts +251 -0
- package/src/server/responses/upstream-error.ts +53 -0
- package/src/server/responses/ws-upstream.ts +308 -0
- package/src/server/responses-custom-tool-repair.ts +282 -0
- package/src/server/responses-image-gen-repair.ts +132 -0
- package/src/server/responses-item-id-repair.ts +272 -0
- 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 +342 -0
- package/src/server/responses-tool-search-repair.ts +267 -0
- package/src/server/responses-undeclared-tool-guard.ts +153 -0
- package/src/server/responses.ts +25 -0
- package/src/server/search.ts +201 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +263 -0
- package/src/server/startup-action-control.ts +315 -0
- package/src/server/startup-health-cache.ts +131 -0
- package/src/server/system-env.ts +484 -0
- package/src/server/windows-tcp-drop.ts +184 -0
- package/src/server/windows-tray-control.ts +41 -0
- package/src/server/ws-bridge.ts +472 -0
- package/src/service-manager-probe.ts +892 -0
- package/src/service.ts +3575 -0
- package/src/sidecar/auth.ts +92 -0
- package/src/sidecar/candidates.ts +83 -0
- package/src/stall-timeout.ts +20 -0
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +3085 -0
- package/src/storage/policy-job.ts +457 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +59 -0
- package/src/storage/policy.ts +527 -0
- package/src/storage/restore-job.ts +299 -0
- package/src/storage/restore-worker.ts +58 -0
- package/src/storage/scanner.ts +238 -0
- package/src/storage/storage-mutation-coordinator.ts +139 -0
- package/src/storage/worker-lifecycle.ts +215 -0
- package/src/tray/assets/opencodex-tray-offline.ico +0 -0
- package/src/tray/assets/opencodex-tray-online.ico +0 -0
- package/src/tray/assets/opencodex-tray-warning.ico +0 -0
- package/src/tray/assets/opencodex-tray.png +0 -0
- package/src/tray/windows-tray.ps1 +364 -0
- package/src/tray/windows.ts +757 -0
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +876 -0
- package/src/types/provider.ts +545 -0
- package/src/types/request.ts +384 -0
- package/src/types/tools.ts +131 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +106 -0
- package/src/update/badge.ts +72 -0
- package/src/update/index.ts +415 -0
- package/src/update/job.ts +1887 -0
- package/src/update/notify.ts +263 -0
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/update/transactional-install.d.mts +22 -0
- package/src/update/transactional-install.mjs +259 -0
- package/src/update/tray-update-plan.d.mts +18 -0
- package/src/update/tray-update-plan.mjs +38 -0
- package/src/usage/cost.ts +625 -0
- package/src/usage/debug.ts +97 -0
- package/src/usage/expected-prices.ts +416 -0
- package/src/usage/log.ts +1223 -0
- package/src/usage/summary.ts +753 -0
- package/src/usage/totals.ts +14 -0
- 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 +189 -0
- package/src/vision/backends.ts +97 -0
- package/src/vision/describe.ts +131 -0
- package/src/vision/eligibility.ts +250 -0
- package/src/vision/index.ts +681 -0
- 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 +195 -0
- package/src/web-search/backends.ts +108 -0
- package/src/web-search/exa-executor.ts +88 -0
- package/src/web-search/executor.ts +113 -0
- package/src/web-search/format-result.ts +89 -0
- package/src/web-search/gemini-executor.ts +141 -0
- package/src/web-search/index.ts +331 -0
- package/src/web-search/loop.ts +896 -0
- package/src/web-search/parse.ts +315 -0
- package/src/web-search/progress-stream.ts +342 -0
- package/src/web-search/sources.ts +60 -0
- package/src/web-search/synthetic-tool.ts +47 -0
- package/src/web-search/xai-executor.ts +219 -0
|
@@ -0,0 +1,1433 @@
|
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, rmSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { uptime } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { atomicWriteFileAsync, getConfigDir, resolveWriteTarget } from "../config";
|
|
5
|
+
import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
|
|
6
|
+
import type { OcxProviderContinuationState } from "../types";
|
|
7
|
+
import {
|
|
8
|
+
deleteResponseSpill,
|
|
9
|
+
noteStubSwapForTest,
|
|
10
|
+
readResponseSpill,
|
|
11
|
+
recoverOrphanedResponseSpills,
|
|
12
|
+
responseSpillDirectory,
|
|
13
|
+
responseSpillPayloadCap,
|
|
14
|
+
type ResponseSpillRef,
|
|
15
|
+
writeResponseSpillDurably,
|
|
16
|
+
} from "./spill-store";
|
|
17
|
+
|
|
18
|
+
const MAX_STORED_RESPONSES = 1_000;
|
|
19
|
+
const RESPONSE_TTL_MS = 60 * 60 * 1_000;
|
|
20
|
+
const SNAPSHOT_DEBOUNCE_MS = 2_000;
|
|
21
|
+
/** In-memory high-water byte cap across all entries. Forced store:false retention (kiro/cursor
|
|
22
|
+
* continuation chains) stores the full expanded input each turn — ~quadratic bytes per chain —
|
|
23
|
+
* so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */
|
|
24
|
+
export const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
25
|
+
/** Legacy snapshot selection only. Spill demotion is governed solely by the RAM cap above. */
|
|
26
|
+
const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
|
|
27
|
+
const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
|
|
28
|
+
/** Refuse-to-parse ceiling for an existing snapshot file (above the 24 MiB write
|
|
29
|
+
* bound, so anything we wrote ourselves always loads; guards against externally
|
|
30
|
+
* planted or pre-cap unbounded files being parsed whole). */
|
|
31
|
+
const SNAPSHOT_FILE_MAX_BYTES = 32 * 1024 * 1024;
|
|
32
|
+
const STALE_TEMP_GRACE_MS = 15 * 60 * 1_000;
|
|
33
|
+
const STALE_TEMP_MAX_ENTRIES = 4_096;
|
|
34
|
+
const STALE_TEMP_MAX_CLEANUPS = 512;
|
|
35
|
+
/** Absorbs `os.uptime()` granularity only. It is deliberately NOT the safety margin:
|
|
36
|
+
* the unconditional 15-minute grace above is (see the boot floor in the scan loop). */
|
|
37
|
+
const BOOT_FLOOR_SKEW_MS = 60 * 1_000;
|
|
38
|
+
/** Per-tick budget for the periodic reclaim. Smaller than the startup budget because the
|
|
39
|
+
* periodic pass runs synchronously on the serving process's event loop every 60 s. */
|
|
40
|
+
const PERIODIC_TEMP_MAX_ENTRIES = 512;
|
|
41
|
+
const PERIODIC_TEMP_MAX_CLEANUPS = 64;
|
|
42
|
+
/** Wall-clock ceiling for one periodic scan. An entry cap bounds syscalls, not time: on a
|
|
43
|
+
* network-mounted config dir each `lstat` can cost 10-20 ms, which would stall in-flight
|
|
44
|
+
* streams. Reclaim is idempotent, so a truncated tick simply resumes on the next one. */
|
|
45
|
+
const PERIODIC_TEMP_SCAN_DEADLINE_MS = 25;
|
|
46
|
+
const RESPONSE_STATE_TEMP_NAME = /^responses-state\.json\.ocx\.(\d+)\.(\d+)\.tmp$/;
|
|
47
|
+
const MAX_SNAPSHOT_REWRITE_ATTEMPTS = 4;
|
|
48
|
+
|
|
49
|
+
interface ResidentResponseState {
|
|
50
|
+
kind: "resident";
|
|
51
|
+
createdAt: number;
|
|
52
|
+
clientThreadId?: string;
|
|
53
|
+
items: unknown[];
|
|
54
|
+
/** Index in `items` where provider output begins; see clientCarriedPrefixLength. */
|
|
55
|
+
providerOutputStart?: number;
|
|
56
|
+
providers?: OcxProviderContinuationState;
|
|
57
|
+
sizeBytes: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface SpilledResponseState {
|
|
61
|
+
kind: "spill";
|
|
62
|
+
createdAt: number;
|
|
63
|
+
clientThreadId?: string;
|
|
64
|
+
/** Mirrors the spilled payload boundary so a spilled entry keeps its anchor. */
|
|
65
|
+
providerOutputStart?: number;
|
|
66
|
+
providers?: OcxProviderContinuationState;
|
|
67
|
+
spill: ResponseSpillRef;
|
|
68
|
+
sizeBytes: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface SpillFailedResponseState {
|
|
72
|
+
kind: "spill-failed";
|
|
73
|
+
createdAt: number;
|
|
74
|
+
sizeBytes: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type StoredResponseState = ResidentResponseState | SpilledResponseState | SpillFailedResponseState;
|
|
78
|
+
type ResidentInput = Omit<ResidentResponseState, "kind" | "sizeBytes">;
|
|
79
|
+
|
|
80
|
+
export type PreviousResponseReplayFailure = {
|
|
81
|
+
code: "previous_response_not_found";
|
|
82
|
+
reason: "spill_missing" | "spill_corrupt" | "spill_failed" | "spill_too_large";
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const states = new Map<string, StoredResponseState>();
|
|
86
|
+
const replayScopeMismatches = new WeakSet<object>();
|
|
87
|
+
let storedResponseBytes = 0;
|
|
88
|
+
let residentResponseBytes = 0;
|
|
89
|
+
let oldestResidentId: string | undefined;
|
|
90
|
+
let oldestResidentAt: number | null = null;
|
|
91
|
+
let byteCapOverride: number | null = null;
|
|
92
|
+
let stateRevision = 0;
|
|
93
|
+
const spillCounters = { writes: 0, writeFailures: 0, readFailures: 0 };
|
|
94
|
+
/**
|
|
95
|
+
* Admission-boundary observability (test-visible). directSpills: oversized
|
|
96
|
+
* candidates routed straight to durable spill without a resident stay or
|
|
97
|
+
* unrelated demotion. oversizedDrops: candidates above the single-spill
|
|
98
|
+
* payload ceiling, tombstoned instead of retained. snapshotOversizedRefusals:
|
|
99
|
+
* snapshot files refused before parse.
|
|
100
|
+
*/
|
|
101
|
+
const admissionCounters = { directSpills: 0, oversizedDrops: 0, snapshotOversizedRefusals: 0 };
|
|
102
|
+
let replayScopeMismatchDrops = 0;
|
|
103
|
+
|
|
104
|
+
/** Test-only: admission-boundary counters (proves the new paths fire). */
|
|
105
|
+
export function responseAdmissionCountersForTests(): Readonly<typeof admissionCounters> {
|
|
106
|
+
return admissionCounters;
|
|
107
|
+
}
|
|
108
|
+
// Superseded spill generations awaiting a durable snapshot before unlink
|
|
109
|
+
// (review C1-1: unlinking at swap time races a crash against the debounced
|
|
110
|
+
// snapshot — the reloaded OLD stub would point at a deleted file).
|
|
111
|
+
const pendingSpillUnlinks: ResponseSpillRef[] = [];
|
|
112
|
+
// The queue itself must stay bounded (review C2-2: repeated replacements with
|
|
113
|
+
// a persistently failing snapshot write would otherwise grow it without
|
|
114
|
+
// limit). Beyond the cap the OLDEST superseded generation is unlinked
|
|
115
|
+
// immediately: the accepted worst case is that a crash inside that window
|
|
116
|
+
// reloads a stub whose file is gone, which fails replay with the explicit
|
|
117
|
+
// structured 400 — bounded-loss, never silent corruption or unbounded disk.
|
|
118
|
+
const PENDING_SPILL_UNLINKS_MAX = 128;
|
|
119
|
+
|
|
120
|
+
function byteCap(): number {
|
|
121
|
+
return byteCapOverride ?? MAX_STORED_RESPONSE_BYTES;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Test-only: lower/restore the in-memory byte cap (null restores the default). */
|
|
125
|
+
export function setResponseStateByteCapForTests(bytes: number | null): void {
|
|
126
|
+
byteCapOverride = bytes;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Test-only: current in-memory byte accounting (proves evictions release their bytes). */
|
|
130
|
+
export function getStoredResponseBytesForTests(): number {
|
|
131
|
+
return storedResponseBytes;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function serializedBytes(value: unknown): number | null {
|
|
135
|
+
try {
|
|
136
|
+
const serialized = JSON.stringify(value);
|
|
137
|
+
return serialized === undefined ? null : Buffer.byteLength(serialized, "utf8");
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function measureResidentEntry(id: string, entry: ResidentInput): ResidentResponseState | null {
|
|
144
|
+
const sizeBytes = serializedBytes({
|
|
145
|
+
responseId: id,
|
|
146
|
+
createdAt: entry.createdAt,
|
|
147
|
+
...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}),
|
|
148
|
+
items: entry.items,
|
|
149
|
+
...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}),
|
|
150
|
+
...(entry.providers ? { providers: entry.providers } : {}),
|
|
151
|
+
});
|
|
152
|
+
return sizeBytes === null ? null : { kind: "resident", ...entry, sizeBytes };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function recomputeOldestResident(): void {
|
|
156
|
+
oldestResidentId = undefined;
|
|
157
|
+
oldestResidentAt = null;
|
|
158
|
+
for (const [id, state] of states) {
|
|
159
|
+
if (state.kind !== "resident") continue;
|
|
160
|
+
if (oldestResidentAt !== null && state.createdAt >= oldestResidentAt) continue;
|
|
161
|
+
oldestResidentId = id;
|
|
162
|
+
oldestResidentAt = state.createdAt;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function replaceMapEntry(id: string, next: StoredResponseState, expected?: StoredResponseState): boolean {
|
|
167
|
+
const existing = states.get(id);
|
|
168
|
+
if (expected && existing !== expected) return false;
|
|
169
|
+
storedResponseBytes -= existing?.sizeBytes ?? 0;
|
|
170
|
+
storedResponseBytes += next.sizeBytes;
|
|
171
|
+
if (existing?.kind === "resident") {
|
|
172
|
+
residentResponseBytes -= existing.sizeBytes;
|
|
173
|
+
}
|
|
174
|
+
if (next.kind === "resident") {
|
|
175
|
+
residentResponseBytes += next.sizeBytes;
|
|
176
|
+
}
|
|
177
|
+
if (storedResponseBytes < 0) storedResponseBytes = 0;
|
|
178
|
+
if (residentResponseBytes < 0) residentResponseBytes = 0;
|
|
179
|
+
if (existing) states.delete(id);
|
|
180
|
+
states.set(id, next);
|
|
181
|
+
if (oldestResidentId === id) {
|
|
182
|
+
recomputeOldestResident();
|
|
183
|
+
} else if (next.kind === "resident" && (oldestResidentAt === null || next.createdAt < oldestResidentAt)) {
|
|
184
|
+
oldestResidentId = id;
|
|
185
|
+
oldestResidentAt = next.createdAt;
|
|
186
|
+
}
|
|
187
|
+
stateRevision += 1;
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function stubSize(id: string, entry: Omit<SpilledResponseState, "sizeBytes">): number {
|
|
192
|
+
return serializedBytes({ responseId: id, ...entry }) ?? 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function tombstone(id: string, createdAt: number): SpillFailedResponseState {
|
|
196
|
+
const base = { kind: "spill-failed" as const, createdAt };
|
|
197
|
+
return { ...base, sizeBytes: serializedBytes({ responseId: id, ...base }) ?? 0 };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function deleteOwnedSpills(entry: StoredResponseState): void {
|
|
201
|
+
if (entry.kind === "spill") deleteResponseSpill(entry.spill);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The ONLY deletion point: TTL, count, byte, and explicit deletes all route here. */
|
|
205
|
+
function deleteEntry(id: string, options: { deleteSpill?: boolean } = {}): void {
|
|
206
|
+
const existing = states.get(id);
|
|
207
|
+
if (!existing) return;
|
|
208
|
+
storedResponseBytes -= existing.sizeBytes;
|
|
209
|
+
if (existing.kind === "resident") {
|
|
210
|
+
residentResponseBytes -= existing.sizeBytes;
|
|
211
|
+
}
|
|
212
|
+
if (storedResponseBytes < 0) storedResponseBytes = 0;
|
|
213
|
+
if (residentResponseBytes < 0) residentResponseBytes = 0;
|
|
214
|
+
states.delete(id);
|
|
215
|
+
if (oldestResidentId === id) recomputeOldestResident();
|
|
216
|
+
stateRevision += 1;
|
|
217
|
+
if (options.deleteSpill !== false) deleteOwnedSpills(existing);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function replaceWithSpillFailure(
|
|
221
|
+
id: string,
|
|
222
|
+
expected?: StoredResponseState,
|
|
223
|
+
options: { deferSpillUnlink?: boolean } = {},
|
|
224
|
+
): void {
|
|
225
|
+
const existing = states.get(id);
|
|
226
|
+
if (expected && existing !== expected) return;
|
|
227
|
+
const failed = tombstone(id, expected?.createdAt ?? existing?.createdAt ?? now());
|
|
228
|
+
if (replaceMapEntry(id, failed, expected)) {
|
|
229
|
+
if (existing) {
|
|
230
|
+
if (options.deferSpillUnlink && existing.kind === "spill") {
|
|
231
|
+
// Crash consistency (same rule as replaceSpillEntryAtomically): the old
|
|
232
|
+
// durable snapshot still references this generation until the tombstone
|
|
233
|
+
// itself is durable — queue the unlink for the next stable persist.
|
|
234
|
+
pendingSpillUnlinks.push(existing.spill);
|
|
235
|
+
while (pendingSpillUnlinks.length > PENDING_SPILL_UNLINKS_MAX) {
|
|
236
|
+
deleteResponseSpill(pendingSpillUnlinks.shift()!);
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
deleteOwnedSpills(existing);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function swapResidentForSpill(id: string, expected: ResidentResponseState, ref: ResponseSpillRef): boolean {
|
|
246
|
+
const base: Omit<SpilledResponseState, "sizeBytes"> = {
|
|
247
|
+
kind: "spill",
|
|
248
|
+
createdAt: expected.createdAt,
|
|
249
|
+
...(expected.clientThreadId ? { clientThreadId: expected.clientThreadId } : {}),
|
|
250
|
+
...(expected.providers ? { providers: expected.providers } : {}),
|
|
251
|
+
spill: ref,
|
|
252
|
+
};
|
|
253
|
+
const next: SpilledResponseState = { ...base, sizeBytes: stubSize(id, base) };
|
|
254
|
+
if (!replaceMapEntry(id, next, expected)) {
|
|
255
|
+
deleteResponseSpill(ref);
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
noteStubSwapForTest();
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function replaceSpillEntryAtomically(
|
|
263
|
+
id: string,
|
|
264
|
+
expected: SpilledResponseState,
|
|
265
|
+
candidate: ResidentResponseState,
|
|
266
|
+
): void {
|
|
267
|
+
try {
|
|
268
|
+
const ref = writeResponseSpillDurably(id, {
|
|
269
|
+
createdAt: candidate.createdAt,
|
|
270
|
+
...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
|
|
271
|
+
items: candidate.items,
|
|
272
|
+
...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}),
|
|
273
|
+
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
274
|
+
});
|
|
275
|
+
const base: Omit<SpilledResponseState, "sizeBytes"> = {
|
|
276
|
+
kind: "spill",
|
|
277
|
+
createdAt: candidate.createdAt,
|
|
278
|
+
...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
|
|
279
|
+
...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}),
|
|
280
|
+
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
281
|
+
spill: ref,
|
|
282
|
+
};
|
|
283
|
+
const next: SpilledResponseState = { ...base, sizeBytes: stubSize(id, base) };
|
|
284
|
+
if (!replaceMapEntry(id, next, expected)) {
|
|
285
|
+
deleteResponseSpill(ref);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
spillCounters.writes += 1;
|
|
289
|
+
noteStubSwapForTest();
|
|
290
|
+
// The old generation is NOT unlinked here (review C1-1): the new stub is
|
|
291
|
+
// only durable once the debounced snapshot flushes — a crash before that
|
|
292
|
+
// reloads the OLD stub, which must still find its file. Queue the unlink;
|
|
293
|
+
// persistNow() drains the queue only after the snapshot write succeeds.
|
|
294
|
+
pendingSpillUnlinks.push(expected.spill);
|
|
295
|
+
while (pendingSpillUnlinks.length > PENDING_SPILL_UNLINKS_MAX) {
|
|
296
|
+
deleteResponseSpill(pendingSpillUnlinks.shift()!);
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
spillCounters.writeFailures += 1;
|
|
300
|
+
// deferSpillUnlink: the durable snapshot may still reference the old
|
|
301
|
+
// generation; deleting it now would strand the old stub after a crash.
|
|
302
|
+
replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function setResidentEntry(id: string, entry: ResidentInput): void {
|
|
307
|
+
const expected = states.get(id);
|
|
308
|
+
const candidate = measureResidentEntry(id, entry);
|
|
309
|
+
if (!candidate) {
|
|
310
|
+
replaceWithSpillFailure(id, expected);
|
|
311
|
+
// A tombstone is tiny but still resident state: the hard-cap invariant
|
|
312
|
+
// must hold on EVERY mutation path (review C2-1 — with a test cap below
|
|
313
|
+
// tombstone size, skipping the prune leaves the store over cap).
|
|
314
|
+
pruneResponses();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (candidate.sizeBytes > byteCap()) {
|
|
318
|
+
admitOversizedCandidate(id, candidate, expected);
|
|
319
|
+
pruneResponses();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (expected?.kind === "spill") {
|
|
323
|
+
replaceSpillEntryAtomically(id, expected, candidate);
|
|
324
|
+
pruneResponses();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (!replaceMapEntry(id, candidate, expected)) return;
|
|
328
|
+
pruneResponses();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Admission boundary for candidates that can never fit as resident (larger
|
|
333
|
+
* than the whole resident-map cap). Writes them DIRECTLY to durable spill and
|
|
334
|
+
* installs only the stub — the oversized candidate never becomes resident and
|
|
335
|
+
* no unrelated resident is demoted to make room for it. Candidates above the
|
|
336
|
+
* single-spill payload ceiling are tombstoned instead: retaining a spill the
|
|
337
|
+
* replay ceiling would refuse to read is write-only waste.
|
|
338
|
+
*/
|
|
339
|
+
function admitOversizedCandidate(
|
|
340
|
+
id: string,
|
|
341
|
+
candidate: ResidentResponseState,
|
|
342
|
+
expected?: StoredResponseState,
|
|
343
|
+
): void {
|
|
344
|
+
if (candidate.sizeBytes > responseSpillPayloadCap()) {
|
|
345
|
+
admissionCounters.oversizedDrops += 1;
|
|
346
|
+
replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const ref = writeResponseSpillDurably(id, {
|
|
351
|
+
createdAt: candidate.createdAt,
|
|
352
|
+
...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
|
|
353
|
+
items: candidate.items,
|
|
354
|
+
...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}),
|
|
355
|
+
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
356
|
+
});
|
|
357
|
+
// Enforce the ceiling against the REAL envelope: the spill payload adds
|
|
358
|
+
// the {version, responseId, ...} wrapper, so a candidate within the
|
|
359
|
+
// wrapper's size of the cap would otherwise be retained unreadably.
|
|
360
|
+
if (ref.payloadBytes > responseSpillPayloadCap()) {
|
|
361
|
+
deleteResponseSpill(ref);
|
|
362
|
+
admissionCounters.oversizedDrops += 1;
|
|
363
|
+
replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const base: Omit<SpilledResponseState, "sizeBytes"> = {
|
|
367
|
+
kind: "spill",
|
|
368
|
+
createdAt: candidate.createdAt,
|
|
369
|
+
...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
|
|
370
|
+
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
371
|
+
spill: ref,
|
|
372
|
+
};
|
|
373
|
+
const next: SpilledResponseState = { ...base, sizeBytes: stubSize(id, base) };
|
|
374
|
+
if (!replaceMapEntry(id, next, expected)) {
|
|
375
|
+
deleteResponseSpill(ref);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
spillCounters.writes += 1;
|
|
379
|
+
admissionCounters.directSpills += 1;
|
|
380
|
+
noteStubSwapForTest();
|
|
381
|
+
if (expected?.kind === "spill") {
|
|
382
|
+
// Same deferred-unlink rule as replaceSpillEntryAtomically: the new stub
|
|
383
|
+
// is durable only after the debounced snapshot, so the old generation
|
|
384
|
+
// stays until a stable persist drains the queue.
|
|
385
|
+
pendingSpillUnlinks.push(expected.spill);
|
|
386
|
+
while (pendingSpillUnlinks.length > PENDING_SPILL_UNLINKS_MAX) {
|
|
387
|
+
deleteResponseSpill(pendingSpillUnlinks.shift()!);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
} catch {
|
|
391
|
+
spillCounters.writeFailures += 1;
|
|
392
|
+
replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Replay provenance must stay proxy-private: a WeakMap distinguishes replayed history from the
|
|
397
|
+
// newly appended input suffix without adding an unknown field that native passthrough could send
|
|
398
|
+
// upstream. The parser uses this boundary to acknowledge historical compaction markers exactly
|
|
399
|
+
// once. It records the boundary whether the proxy prepended the history or the client already
|
|
400
|
+
// carried it — the boundary is the same either way, and only its provenance differs.
|
|
401
|
+
const replayedInputPrefixLengths = new WeakMap<object, number>();
|
|
402
|
+
const replayFailures = new WeakMap<object, PreviousResponseReplayFailure>();
|
|
403
|
+
let loaded = false;
|
|
404
|
+
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
405
|
+
let pendingPersistPath: string | null = null;
|
|
406
|
+
/** Single-flight gate: overlapping response-state writes serialize (#612). */
|
|
407
|
+
let persistGate: Promise<void> = Promise.resolve();
|
|
408
|
+
let persistAttemptHookForTests: (() => void) | null = null;
|
|
409
|
+
|
|
410
|
+
function now(): number {
|
|
411
|
+
return Date.now();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function snapshotPath(): string {
|
|
415
|
+
return join(getConfigDir(), "responses-state.json");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
interface LegacySnapshotState {
|
|
419
|
+
createdAt?: unknown;
|
|
420
|
+
clientThreadId?: unknown;
|
|
421
|
+
items?: unknown;
|
|
422
|
+
providers?: OcxProviderContinuationState;
|
|
423
|
+
conversationId?: unknown;
|
|
424
|
+
cursorCheckpointUsable?: unknown;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function isSpillRef(value: unknown): value is ResponseSpillRef {
|
|
428
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
429
|
+
const ref = value as ResponseSpillRef;
|
|
430
|
+
return ref.version === 1
|
|
431
|
+
&& typeof ref.fileName === "string"
|
|
432
|
+
&& /^[0-9a-f]{64}$/.test(ref.digest)
|
|
433
|
+
&& Number.isSafeInteger(ref.payloadBytes)
|
|
434
|
+
&& ref.payloadBytes >= 0;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function loadSnapshotEntry(id: string, value: unknown): void {
|
|
438
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
439
|
+
const rec = value as LegacySnapshotState & { kind?: unknown; spill?: unknown };
|
|
440
|
+
if (typeof rec.createdAt !== "number" || !Number.isFinite(rec.createdAt)) return;
|
|
441
|
+
const clientThreadId = typeof rec.clientThreadId === "string" && rec.clientThreadId.trim().length > 0
|
|
442
|
+
? rec.clientThreadId.trim()
|
|
443
|
+
: undefined;
|
|
444
|
+
// A malformed boundary degrades to "never skip" rather than to a bad index: an untrusted
|
|
445
|
+
// snapshot must not be able to authorize dropping conversation history.
|
|
446
|
+
const anchorFor = (itemCount: number): number | undefined => {
|
|
447
|
+
const raw = (rec as { providerOutputStart?: unknown }).providerOutputStart;
|
|
448
|
+
return Number.isSafeInteger(raw) && (raw as number) >= 0 && (raw as number) <= itemCount
|
|
449
|
+
? raw as number
|
|
450
|
+
: undefined;
|
|
451
|
+
};
|
|
452
|
+
if (rec.kind === "spill") {
|
|
453
|
+
if (!isSpillRef(rec.spill)) return;
|
|
454
|
+
const base: Omit<SpilledResponseState, "sizeBytes"> = {
|
|
455
|
+
kind: "spill",
|
|
456
|
+
createdAt: rec.createdAt,
|
|
457
|
+
...(clientThreadId ? { clientThreadId } : {}),
|
|
458
|
+
// Item count is unknown until materialization, so accept any non-negative integer
|
|
459
|
+
// here; the spill payload validator re-checks it against the real array.
|
|
460
|
+
...(anchorFor(Number.MAX_SAFE_INTEGER) !== undefined ? { providerOutputStart: anchorFor(Number.MAX_SAFE_INTEGER) } : {}),
|
|
461
|
+
...(rec.providers ? { providers: rec.providers } : {}),
|
|
462
|
+
spill: rec.spill,
|
|
463
|
+
};
|
|
464
|
+
replaceMapEntry(id, { ...base, sizeBytes: stubSize(id, base) });
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (rec.kind === "spill-failed") {
|
|
468
|
+
replaceMapEntry(id, tombstone(id, rec.createdAt));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (rec.kind !== undefined && rec.kind !== "resident") return;
|
|
472
|
+
if (!Array.isArray(rec.items)) return;
|
|
473
|
+
const providers = rec.providers ?? (typeof rec.conversationId === "string"
|
|
474
|
+
? {
|
|
475
|
+
cursor: {
|
|
476
|
+
conversationId: rec.conversationId,
|
|
477
|
+
...(typeof rec.cursorCheckpointUsable === "boolean"
|
|
478
|
+
? { checkpointUsable: rec.cursorCheckpointUsable }
|
|
479
|
+
: {}),
|
|
480
|
+
},
|
|
481
|
+
}
|
|
482
|
+
: undefined);
|
|
483
|
+
const resident = measureResidentEntry(id, {
|
|
484
|
+
createdAt: rec.createdAt,
|
|
485
|
+
...(clientThreadId ? { clientThreadId } : {}),
|
|
486
|
+
items: rec.items,
|
|
487
|
+
...(anchorFor(rec.items.length) !== undefined ? { providerOutputStart: anchorFor(rec.items.length) } : {}),
|
|
488
|
+
...(providers ? { providers } : {}),
|
|
489
|
+
});
|
|
490
|
+
if (!resident) {
|
|
491
|
+
replaceMapEntry(id, tombstone(id, rec.createdAt));
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
// Same admission boundary as live writes: an oversized snapshot row goes
|
|
495
|
+
// straight to spill (or tombstone above the payload ceiling) instead of
|
|
496
|
+
// entering the resident map and demoting unrelated rows on the first prune.
|
|
497
|
+
if (resident.sizeBytes > byteCap()) {
|
|
498
|
+
admitOversizedCandidate(id, resident, undefined);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
replaceMapEntry(id, resident);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export interface ResponseStateTempRecoveryResult {
|
|
505
|
+
matched: number;
|
|
506
|
+
removed: number;
|
|
507
|
+
failed: number;
|
|
508
|
+
bytesRemoved: number;
|
|
509
|
+
/** Entries that passed EVERY gate and would be reclaimed. In a dry run nothing is
|
|
510
|
+
* unlinked, so this is the only honest count to show an operator: `matched` is
|
|
511
|
+
* incremented before the file-type, age, boot-floor, and liveness gates. */
|
|
512
|
+
eligible: number;
|
|
513
|
+
/** Total size of the `eligible` entries. */
|
|
514
|
+
eligibleBytes: number;
|
|
515
|
+
/** The scan stopped on a budget (entry cap, cleanup cap, or deadline) rather than reaching
|
|
516
|
+
* the end of the directory, so the counts below describe a prefix of the backlog and not
|
|
517
|
+
* the backlog. `eligible > removed + failed` cannot express this: outside a dry run every
|
|
518
|
+
* eligible entry is unlinked or failed on the same iteration, so the two are always equal
|
|
519
|
+
* and a comparison between them is dead code. */
|
|
520
|
+
truncated: boolean;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
interface ResponseStateTempRecoveryIO {
|
|
524
|
+
now: () => number;
|
|
525
|
+
/** Approximate epoch ms of the current boot; see the boot floor in the scan loop. */
|
|
526
|
+
bootTime: () => number;
|
|
527
|
+
list: (dir: string) => Iterable<string>;
|
|
528
|
+
inspect: (path: string) => { isFile: boolean; mtimeMs: number; size: number };
|
|
529
|
+
isProcessAlive: (pid: number) => boolean;
|
|
530
|
+
unlink: (path: string) => void;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export type ResponseStateTempRecoveryOptions = Partial<ResponseStateTempRecoveryIO> & {
|
|
534
|
+
maxEntries?: number;
|
|
535
|
+
maxCleanups?: number;
|
|
536
|
+
/** Wall-clock ceiling for the scan, or null/undefined for no deadline (startup path). */
|
|
537
|
+
deadlineMs?: number | null;
|
|
538
|
+
/** Report only: apply every gate, count what would be reclaimed, unlink nothing. */
|
|
539
|
+
dryRun?: boolean;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
function processIsAlive(pid: number): boolean {
|
|
543
|
+
if (pid === process.pid) return true;
|
|
544
|
+
try {
|
|
545
|
+
process.kill(pid, 0);
|
|
546
|
+
return true;
|
|
547
|
+
} catch (error) {
|
|
548
|
+
// EPERM means the process exists but cannot be signalled. Unknown platform errors
|
|
549
|
+
// are also protected; cleanup should prefer a false negative over touching a live writer.
|
|
550
|
+
return (error as NodeJS.ErrnoException).code !== "ESRCH";
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const responseStateTempRecoveryIO: ResponseStateTempRecoveryIO = {
|
|
555
|
+
now: Date.now,
|
|
556
|
+
bootTime: () => Date.now() - uptime() * 1_000,
|
|
557
|
+
list: function* list(dir) {
|
|
558
|
+
const handle = opendirSync(dir);
|
|
559
|
+
try {
|
|
560
|
+
for (let entry = handle.readSync(); entry; entry = handle.readSync()) yield entry.name;
|
|
561
|
+
} finally {
|
|
562
|
+
handle.closeSync();
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
inspect: path => {
|
|
566
|
+
const stat = lstatSync(path);
|
|
567
|
+
return { isFile: stat.isFile() && !stat.isSymbolicLink(), mtimeMs: stat.mtimeMs, size: stat.size };
|
|
568
|
+
},
|
|
569
|
+
isProcessAlive: processIsAlive,
|
|
570
|
+
unlink: unlinkSync,
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Recover only abandoned response-state atomic-write files. The exact basename,
|
|
575
|
+
* regular-file check, age gate, and PID liveness check protect unrelated/active files.
|
|
576
|
+
* Cleanup is capped and best-effort because continuation state is only a cache. Removal
|
|
577
|
+
* deliberately uses unlink only: path-based truncation could follow a replacement symlink.
|
|
578
|
+
*/
|
|
579
|
+
export function recoverStaleResponseStateTemps(
|
|
580
|
+
dir = getConfigDir(),
|
|
581
|
+
options: ResponseStateTempRecoveryOptions = {},
|
|
582
|
+
): ResponseStateTempRecoveryResult {
|
|
583
|
+
const {
|
|
584
|
+
maxEntries = STALE_TEMP_MAX_ENTRIES,
|
|
585
|
+
maxCleanups = STALE_TEMP_MAX_CLEANUPS,
|
|
586
|
+
deadlineMs = null,
|
|
587
|
+
dryRun = false,
|
|
588
|
+
...overrides
|
|
589
|
+
} = options;
|
|
590
|
+
const io = { ...responseStateTempRecoveryIO, ...overrides };
|
|
591
|
+
const result: ResponseStateTempRecoveryResult = {
|
|
592
|
+
matched: 0,
|
|
593
|
+
removed: 0,
|
|
594
|
+
failed: 0,
|
|
595
|
+
bytesRemoved: 0,
|
|
596
|
+
eligible: 0,
|
|
597
|
+
eligibleBytes: 0,
|
|
598
|
+
truncated: false,
|
|
599
|
+
};
|
|
600
|
+
const startedAt = io.now();
|
|
601
|
+
// One probe per scan, not one per entry. A non-finite or future-dated boot is anomalous, and
|
|
602
|
+
// clamping it to "now" would be the WORST response: the floor would then retire the liveness
|
|
603
|
+
// probe for every file older than the skew, which is every file past the grace. Disable it
|
|
604
|
+
// instead -- an absent floor only costs a missed reclaim, never a wrong one.
|
|
605
|
+
const rawBoot = io.bootTime();
|
|
606
|
+
const bootMs = Number.isFinite(rawBoot) && rawBoot <= startedAt ? rawBoot : Number.NEGATIVE_INFINITY;
|
|
607
|
+
let names: Iterable<string>;
|
|
608
|
+
try { names = io.list(dir); } catch { return result; }
|
|
609
|
+
let iterator: Iterator<string>;
|
|
610
|
+
try { iterator = names[Symbol.iterator](); } catch { return result; }
|
|
611
|
+
let scanned = 0;
|
|
612
|
+
// Every early exit runs through this. The production `list` is a generator that closes its
|
|
613
|
+
// directory handle in a `finally`, and a `finally` does NOT run when the consumer simply
|
|
614
|
+
// stops calling `next()` -- only `return()` resumes the generator to completion. Breaking
|
|
615
|
+
// out of the loop directly therefore leaked one directory handle per truncated scan, and the
|
|
616
|
+
// periodic reclaim truncates on purpose (entry cap, cleanup cap, deadline), so on a slow
|
|
617
|
+
// filesystem that is a leak per tick, forever.
|
|
618
|
+
const stopScan = (): ResponseStateTempRecoveryResult => {
|
|
619
|
+
try { iterator.return?.(); } catch { /* closing is best-effort; never fail a reclaim on it */ }
|
|
620
|
+
return result;
|
|
621
|
+
};
|
|
622
|
+
for (;;) {
|
|
623
|
+
let next: IteratorResult<string>;
|
|
624
|
+
try { next = iterator.next(); } catch { return result; }
|
|
625
|
+
if (next.done) break;
|
|
626
|
+
const name = next.value;
|
|
627
|
+
scanned += 1;
|
|
628
|
+
// A dry run performs no cleanups, so bounding it by the cleanup budget would truncate
|
|
629
|
+
// the very report an operator uses to size the problem.
|
|
630
|
+
if (scanned > maxEntries) { result.truncated = true; return stopScan(); }
|
|
631
|
+
if (!dryRun && result.removed + result.failed >= maxCleanups) { result.truncated = true; return stopScan(); }
|
|
632
|
+
if (deadlineMs !== null && io.now() - startedAt > deadlineMs) { result.truncated = true; return stopScan(); }
|
|
633
|
+
const match = RESPONSE_STATE_TEMP_NAME.exec(name);
|
|
634
|
+
if (!match) continue;
|
|
635
|
+
result.matched += 1;
|
|
636
|
+
const pid = Number(match[1]);
|
|
637
|
+
const sequence = Number(match[2]);
|
|
638
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isSafeInteger(sequence) || sequence <= 0) continue;
|
|
639
|
+
const path = join(dir, name);
|
|
640
|
+
let file: ReturnType<ResponseStateTempRecoveryIO["inspect"]>;
|
|
641
|
+
try { file = io.inspect(path); } catch { continue; }
|
|
642
|
+
if (!file.isFile || io.now() - file.mtimeMs < STALE_TEMP_GRACE_MS) continue;
|
|
643
|
+
// Boot floor. After a reboot the original writer's pid is routinely reused, which makes
|
|
644
|
+
// the liveness skip PERMANENT: the 15-minute grace above is a lower bound and never
|
|
645
|
+
// expires it, so the file is skipped on every future pass forever. A temp older than
|
|
646
|
+
// this boot cannot be owned by the pid we would probe, so the probe is vacuous and we
|
|
647
|
+
// retire it. This does NOT claim the file is provably dead: under a shared-volume
|
|
648
|
+
// container, suspend-excluding uptime, or a network config dir the computed boot can
|
|
649
|
+
// land after the real one. The unconditional 15-minute grace above remains the safety
|
|
650
|
+
// floor, and this process's own temps are never touched.
|
|
651
|
+
const predatesBoot = file.mtimeMs < bootMs - BOOT_FLOOR_SKEW_MS;
|
|
652
|
+
if (pid === process.pid) continue;
|
|
653
|
+
if (!predatesBoot && io.isProcessAlive(pid)) continue;
|
|
654
|
+
|
|
655
|
+
result.eligible += 1;
|
|
656
|
+
result.eligibleBytes += file.size;
|
|
657
|
+
if (dryRun) continue;
|
|
658
|
+
|
|
659
|
+
try {
|
|
660
|
+
io.unlink(path);
|
|
661
|
+
result.removed += 1;
|
|
662
|
+
result.bytesRemoved += file.size;
|
|
663
|
+
} catch (error) {
|
|
664
|
+
// Another proxy sharing this config dir may have won the race. A file that is already
|
|
665
|
+
// gone is reclaimed, not a failure -- reporting it as one would surface "in use or
|
|
666
|
+
// locked" to an operator for a file nobody holds.
|
|
667
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
668
|
+
result.removed += 1;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
// Locked files remain for a later startup. Do not truncate by path: a same-user
|
|
672
|
+
// replacement could turn that fallback into an arbitrary symlink-target write.
|
|
673
|
+
result.failed += 1;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return result;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Literal config dir plus the snapshot's resolved dir. Atomic writes place their temp beside
|
|
681
|
+
* the RESOLVED target, so a symlinked snapshot (dotfiles-managed config dir) strands temps in
|
|
682
|
+
* the link's real directory where a scan of the literal dir would never see them. The two
|
|
683
|
+
* collapse to one when nothing is symlinked.
|
|
684
|
+
*/
|
|
685
|
+
function responseStateSweepDirectories(): Set<string> {
|
|
686
|
+
const path = snapshotPath();
|
|
687
|
+
let resolvedDir = dirname(path);
|
|
688
|
+
try {
|
|
689
|
+
resolvedDir = dirname(resolveWriteTarget(path));
|
|
690
|
+
} catch {
|
|
691
|
+
/* unresolvable link: sweep the literal dir only */
|
|
692
|
+
}
|
|
693
|
+
return new Set([dirname(path), resolvedDir]);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Best-effort disk snapshot so previous_response_id chains survive a proxy restart (the
|
|
698
|
+
* dominant expansion-miss cause: an in-memory-only store dies with the process, and the next
|
|
699
|
+
* chained turn then reaches the upstream as a naked delta). Load is lazy on first store access;
|
|
700
|
+
* persistence is debounced + unref'd so the hot path never blocks and the process can exit.
|
|
701
|
+
* Every disk failure is swallowed — the snapshot is a cache, not a source of truth.
|
|
702
|
+
*/
|
|
703
|
+
function ensureLoaded(): void {
|
|
704
|
+
if (loaded) return;
|
|
705
|
+
loaded = true;
|
|
706
|
+
const path = snapshotPath();
|
|
707
|
+
// Atomic writes place their temp beside the RESOLVED target, so a symlinked
|
|
708
|
+
// snapshot (dotfiles-managed config dir) strands temps in the link's real
|
|
709
|
+
// directory where a scan of the literal config dir would never see them.
|
|
710
|
+
// Both locations are swept; they collapse to one when nothing is symlinked.
|
|
711
|
+
// resolveWriteTarget refuses a dangling link; snapshot loading stays independent.
|
|
712
|
+
let resolvedDir = dirname(path);
|
|
713
|
+
try {
|
|
714
|
+
resolvedDir = dirname(resolveWriteTarget(path));
|
|
715
|
+
} catch {
|
|
716
|
+
/* unresolvable link: sweep the literal dir only */
|
|
717
|
+
}
|
|
718
|
+
for (const dir of new Set([dirname(path), resolvedDir])) {
|
|
719
|
+
try {
|
|
720
|
+
recoverStaleResponseStateTemps(dir);
|
|
721
|
+
} catch {
|
|
722
|
+
/* best-effort cleanup only; snapshot loading must remain independent */
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
try {
|
|
726
|
+
if (existsSync(path)) {
|
|
727
|
+
// Bound the read BEFORE parse: the 24 MiB write cap constrains snapshots
|
|
728
|
+
// this process wrote, not a pre-existing oversized file. statSync follows
|
|
729
|
+
// symlinks deliberately — readFileSync below follows them too, so the
|
|
730
|
+
// size gate must measure the same target the read would.
|
|
731
|
+
const stat = statSync(path);
|
|
732
|
+
if (!stat.isFile()) {
|
|
733
|
+
// Symlink to a FIFO/device (e.g. /dev/zero): reading would block or
|
|
734
|
+
// return unbounded input. Only regular files are ever parsed.
|
|
735
|
+
} else if (stat.size > SNAPSHOT_FILE_MAX_BYTES) {
|
|
736
|
+
admissionCounters.snapshotOversizedRefusals += 1;
|
|
737
|
+
} else {
|
|
738
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
|
|
739
|
+
if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.states)) {
|
|
740
|
+
for (const entry of raw.states) {
|
|
741
|
+
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") continue;
|
|
742
|
+
loadSnapshotEntry(entry[0], entry[1]);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
} catch {
|
|
748
|
+
/* missing/corrupt snapshot: start empty */
|
|
749
|
+
}
|
|
750
|
+
const referenced = new Set<string>();
|
|
751
|
+
for (const state of states.values()) {
|
|
752
|
+
if (state.kind === "spill") referenced.add(state.spill.fileName);
|
|
753
|
+
}
|
|
754
|
+
try { recoverOrphanedResponseSpills(referenced); } catch { /* best effort */ }
|
|
755
|
+
pruneResponses();
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
type SnapshotWriteOutcome = "stable" | "unstable" | "failed";
|
|
759
|
+
|
|
760
|
+
async function writeBoundedSnapshot(path: string): Promise<SnapshotWriteOutcome> {
|
|
761
|
+
// Serialize writers so concurrent flush + debounce cannot race on temps / ACL (#612).
|
|
762
|
+
const previous = persistGate;
|
|
763
|
+
let release!: () => void;
|
|
764
|
+
persistGate = new Promise<void>(resolve => { release = resolve; });
|
|
765
|
+
await previous;
|
|
766
|
+
try {
|
|
767
|
+
for (let attempt = 0; attempt < MAX_SNAPSHOT_REWRITE_ATTEMPTS; attempt += 1) {
|
|
768
|
+
const revision = stateRevision;
|
|
769
|
+
const entries: Array<[string, unknown]> = [];
|
|
770
|
+
let total = 0;
|
|
771
|
+
// Newest-first so the most recent chains survive both legacy snapshot caps.
|
|
772
|
+
for (const [id, state] of [...states].reverse()) {
|
|
773
|
+
let persistable: unknown;
|
|
774
|
+
if (state.kind === "resident") {
|
|
775
|
+
const { sizeBytes: _sizeBytes, kind: _kind, ...resident } = state;
|
|
776
|
+
persistable = resident;
|
|
777
|
+
} else {
|
|
778
|
+
const { sizeBytes: _sizeBytes, ...smallState } = state;
|
|
779
|
+
persistable = smallState;
|
|
780
|
+
}
|
|
781
|
+
const persistEntry: [string, unknown] = [id, persistable];
|
|
782
|
+
// UTF-8 bytes, not UTF-16 code units: multibyte items otherwise slip
|
|
783
|
+
// past both snapshot caps at up to 2x the intended size.
|
|
784
|
+
const size = Buffer.byteLength(JSON.stringify(persistEntry), "utf8");
|
|
785
|
+
if (state.kind === "resident" && size > SNAPSHOT_ENTRY_MAX_BYTES) continue;
|
|
786
|
+
if (total + size > SNAPSHOT_TOTAL_MAX_BYTES) break;
|
|
787
|
+
total += size;
|
|
788
|
+
entries.push(persistEntry);
|
|
789
|
+
}
|
|
790
|
+
entries.reverse();
|
|
791
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
792
|
+
try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
|
|
793
|
+
await atomicWriteFileAsync(path, JSON.stringify({ version: 2, states: entries }));
|
|
794
|
+
persistAttemptHookForTests?.();
|
|
795
|
+
if (revision === stateRevision) return "stable";
|
|
796
|
+
}
|
|
797
|
+
return "unstable";
|
|
798
|
+
} catch {
|
|
799
|
+
return "failed";
|
|
800
|
+
} finally {
|
|
801
|
+
release();
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function drainPendingSpillUnlinks(): void {
|
|
806
|
+
while (pendingSpillUnlinks.length > 0) {
|
|
807
|
+
const ref = pendingSpillUnlinks.shift()!;
|
|
808
|
+
deleteResponseSpill(ref);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function schedulePersistAt(path: string, replace = false): void {
|
|
813
|
+
if (persistTimer && !replace) return;
|
|
814
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
815
|
+
pendingPersistPath = path;
|
|
816
|
+
persistTimer = setTimeout(() => { void persistNow(path); }, SNAPSHOT_DEBOUNCE_MS);
|
|
817
|
+
(persistTimer as { unref?: () => void }).unref?.();
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function persistNow(path: string, awaitFollowUp = false): Promise<void> {
|
|
821
|
+
if (persistTimer) {
|
|
822
|
+
clearTimeout(persistTimer);
|
|
823
|
+
persistTimer = null;
|
|
824
|
+
}
|
|
825
|
+
pendingPersistPath = null;
|
|
826
|
+
let outcome = await writeBoundedSnapshot(path);
|
|
827
|
+
if (outcome === "unstable" && awaitFollowUp) {
|
|
828
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
829
|
+
persistTimer = null;
|
|
830
|
+
pendingPersistPath = null;
|
|
831
|
+
outcome = await writeBoundedSnapshot(path);
|
|
832
|
+
}
|
|
833
|
+
if (outcome === "stable") drainPendingSpillUnlinks();
|
|
834
|
+
else if (outcome === "unstable" && !awaitFollowUp) schedulePersistAt(path, true);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function schedulePersist(): void {
|
|
838
|
+
// Resolve the target path NOW: tests (and anything else) may swap OPENCODEX_HOME before the
|
|
839
|
+
// debounce fires, and a late write must land in the home that owned the recorded state.
|
|
840
|
+
schedulePersistAt(snapshotPath());
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */
|
|
844
|
+
export async function flushResponseState(): Promise<void> {
|
|
845
|
+
if (persistTimer) {
|
|
846
|
+
await persistNow(pendingPersistPath ?? snapshotPath(), true);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
// No pending timer: still await any in-flight write so shutdown does not race (#612).
|
|
850
|
+
await persistGate;
|
|
851
|
+
// A bounded background pass may have scheduled its same-path follow-up while
|
|
852
|
+
// this flush was waiting on the single-flight gate. Shutdown owns one awaited
|
|
853
|
+
// bounded follow-up rather than returning behind that unref'd timer.
|
|
854
|
+
if (persistTimer) await persistNow(pendingPersistPath ?? snapshotPath(), true);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function inputItems(input: unknown): unknown[] {
|
|
858
|
+
if (input === undefined) return [];
|
|
859
|
+
if (Array.isArray(input)) return input;
|
|
860
|
+
if (typeof input === "string") return [{ role: "user", content: input }];
|
|
861
|
+
return [input];
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** Hard cap for canonicalizing ANY item. Past it, the item is not comparable. */
|
|
865
|
+
const REPLAY_FINGERPRINT_MAX_BYTES = 8 * 1024;
|
|
866
|
+
/** Depth ceiling so a pathologically nested item cannot blow the canonicalizer. */
|
|
867
|
+
const REPLAY_FINGERPRINT_MAX_DEPTH = 64;
|
|
868
|
+
|
|
869
|
+
let replayOverlapSkips = 0;
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Canonical, order-stable fingerprint for one input item, or null when the item cannot be
|
|
873
|
+
* compared safely.
|
|
874
|
+
*
|
|
875
|
+
* Byte-counted DURING the walk rather than serialize-then-measure: a tool result can be
|
|
876
|
+
* megabytes and this runs on the request path, so the point of the cap is to stop early,
|
|
877
|
+
* not to discover afterwards that we should have. Object keys are sorted so two
|
|
878
|
+
* semantically identical items cannot differ by key order alone.
|
|
879
|
+
*
|
|
880
|
+
* The cap applies to EVERY item. An `id`/`call_id` is additional occurrence evidence, never
|
|
881
|
+
* a substitute for content equality, so an over-cap identified tool item is non-comparable
|
|
882
|
+
* exactly like an over-cap message.
|
|
883
|
+
*/
|
|
884
|
+
function replayItemFingerprint(item: unknown): string | null {
|
|
885
|
+
const out: string[] = [];
|
|
886
|
+
let bytes = 0;
|
|
887
|
+
const push = (text: string): boolean => {
|
|
888
|
+
bytes += Buffer.byteLength(text, "utf8");
|
|
889
|
+
if (bytes > REPLAY_FINGERPRINT_MAX_BYTES) return false;
|
|
890
|
+
out.push(text);
|
|
891
|
+
return true;
|
|
892
|
+
};
|
|
893
|
+
const walk = (value: unknown, depth: number): boolean => {
|
|
894
|
+
if (depth > REPLAY_FINGERPRINT_MAX_DEPTH) return false;
|
|
895
|
+
if (value === null || typeof value !== "object") return push(JSON.stringify(value) ?? "null");
|
|
896
|
+
if (Array.isArray(value)) {
|
|
897
|
+
if (!push("[")) return false;
|
|
898
|
+
for (const element of value) {
|
|
899
|
+
if (!walk(element, depth + 1)) return false;
|
|
900
|
+
if (!push(",")) return false;
|
|
901
|
+
}
|
|
902
|
+
return push("]");
|
|
903
|
+
}
|
|
904
|
+
if (!push("{")) return false;
|
|
905
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
906
|
+
if (!push(JSON.stringify(key))) return false;
|
|
907
|
+
if (!walk((value as Record<string, unknown>)[key], depth + 1)) return false;
|
|
908
|
+
if (!push(",")) return false;
|
|
909
|
+
}
|
|
910
|
+
return push("}");
|
|
911
|
+
};
|
|
912
|
+
return walk(item, 0) ? out.join("") : null;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Non-empty provider-issued `id`/`call_id` on an item, else null. */
|
|
916
|
+
function providerIssuedIdentity(item: unknown): string | null {
|
|
917
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
|
|
918
|
+
const record = item as { id?: unknown; call_id?: unknown };
|
|
919
|
+
for (const candidate of [record.id, record.call_id]) {
|
|
920
|
+
if (typeof candidate === "string" && candidate.trim().length > 0) return candidate;
|
|
921
|
+
}
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Number of leading stored items the client already carries verbatim, or 0.
|
|
927
|
+
*
|
|
928
|
+
* Requires an exact ordered run: every stored item must match the client input item at the
|
|
929
|
+
* same index. Any not-comparable item aborts to 0 — skipping just that item could align two
|
|
930
|
+
* different occurrences and manufacture a false positive, and a false positive here deletes
|
|
931
|
+
* real conversation history.
|
|
932
|
+
*
|
|
933
|
+
* Known gap (FU-2): stored input can contain proxy-injected guidance the client never saw,
|
|
934
|
+
* and ids repaired after recording. Those sessions do not match here and expand as before.
|
|
935
|
+
*/
|
|
936
|
+
function clientCarriedPrefixLength(stored: readonly unknown[], clientInput: readonly unknown[]): number {
|
|
937
|
+
if (stored.length === 0 || clientInput.length < stored.length) return 0;
|
|
938
|
+
for (let index = 0; index < stored.length; index += 1) {
|
|
939
|
+
const storedPrint = replayItemFingerprint(stored[index]);
|
|
940
|
+
if (storedPrint === null) return 0;
|
|
941
|
+
const clientPrint = replayItemFingerprint(clientInput[index]);
|
|
942
|
+
if (clientPrint === null || storedPrint !== clientPrint) return 0;
|
|
943
|
+
}
|
|
944
|
+
return stored.length;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** Test-only: replay prepends skipped because the client already carried the history. */
|
|
948
|
+
export function replayOverlapSkipsForTests(): number {
|
|
949
|
+
return replayOverlapSkips;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function pruneResponses(at = now()): void {
|
|
953
|
+
for (const [id, state] of states) {
|
|
954
|
+
if (at - state.createdAt > RESPONSE_TTL_MS) deleteEntry(id);
|
|
955
|
+
}
|
|
956
|
+
while (states.size > MAX_STORED_RESPONSES) {
|
|
957
|
+
const oldest = states.keys().next().value;
|
|
958
|
+
if (!oldest) break;
|
|
959
|
+
deleteEntry(oldest);
|
|
960
|
+
}
|
|
961
|
+
// Unconditional RAM cap. Resident payloads demote durably; stubs/tombstones are
|
|
962
|
+
// deleted only when even their bounded metadata cannot fit the override.
|
|
963
|
+
while (storedResponseBytes > byteCap() && states.size > 0) {
|
|
964
|
+
const oldestResident = [...states].find(([, entry]) => entry.kind === "resident");
|
|
965
|
+
const oldestId = oldestResident?.[0] ?? states.keys().next().value as string | undefined;
|
|
966
|
+
if (!oldestId) break;
|
|
967
|
+
const entry = states.get(oldestId)!;
|
|
968
|
+
if (entry.kind !== "resident") {
|
|
969
|
+
deleteEntry(oldestId);
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
try {
|
|
973
|
+
const ref = writeResponseSpillDurably(oldestId, {
|
|
974
|
+
createdAt: entry.createdAt,
|
|
975
|
+
...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}),
|
|
976
|
+
items: entry.items,
|
|
977
|
+
...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}),
|
|
978
|
+
...(entry.providers ? { providers: entry.providers } : {}),
|
|
979
|
+
});
|
|
980
|
+
if (swapResidentForSpill(oldestId, entry, ref)) spillCounters.writes += 1;
|
|
981
|
+
} catch {
|
|
982
|
+
spillCounters.writeFailures += 1;
|
|
983
|
+
replaceWithSpillFailure(oldestId, entry);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** Periodic TTL-only sweep; count/byte eviction remains owned by mutation paths. */
|
|
989
|
+
export function sweepExpiredResponseStates(at = now()): number {
|
|
990
|
+
let removed = 0;
|
|
991
|
+
for (const [id, state] of states) {
|
|
992
|
+
if (at - state.createdAt <= RESPONSE_TTL_MS) continue;
|
|
993
|
+
deleteEntry(id);
|
|
994
|
+
removed += 1;
|
|
995
|
+
}
|
|
996
|
+
if (removed > 0) schedulePersist();
|
|
997
|
+
return removed;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Periodic disk reclaim for abandoned atomic-write temps.
|
|
1002
|
+
*
|
|
1003
|
+
* `ensureLoaded` sweeps once per process, at load, BEFORE that process writes anything:
|
|
1004
|
+
* every `schedulePersist` site is downstream of it. So a process that abandons a temp has
|
|
1005
|
+
* already had its only look, the 15-minute grace hides the temp its predecessor's crash
|
|
1006
|
+
* just produced, and `maxCleanups` caps a single pass below a large backlog. A restart
|
|
1007
|
+
* loop therefore accumulates monotonically. Repeating the reclaim on a timer fixes all
|
|
1008
|
+
* three: the grace expires into a later tick and the per-pass cap becomes a per-tick rate.
|
|
1009
|
+
*
|
|
1010
|
+
* Registered on the sweeper's LIVENESS tick, not the TTL tick: `sweepExpiredOnWrite` puts
|
|
1011
|
+
* `sweepExpired` on hot write paths, and a directory scan does not belong there.
|
|
1012
|
+
*/
|
|
1013
|
+
export function reclaimAbandonedResponseStateTemps(
|
|
1014
|
+
options: ResponseStateTempRecoveryOptions = {},
|
|
1015
|
+
): ResponseStateTempRecoveryResult {
|
|
1016
|
+
const total: ResponseStateTempRecoveryResult = {
|
|
1017
|
+
matched: 0, removed: 0, failed: 0, bytesRemoved: 0, eligible: 0, eligibleBytes: 0, truncated: false,
|
|
1018
|
+
};
|
|
1019
|
+
// The try encloses responseStateSweepDirectories() deliberately: recoverStaleResponseStateTemps
|
|
1020
|
+
// already swallows its own enumeration failures, so a catch around only that call would be
|
|
1021
|
+
// unreachable. snapshotPath()/getConfigDir() are the paths that can genuinely throw.
|
|
1022
|
+
try {
|
|
1023
|
+
for (const dir of responseStateSweepDirectories()) {
|
|
1024
|
+
const result = recoverStaleResponseStateTemps(dir, options);
|
|
1025
|
+
total.matched += result.matched;
|
|
1026
|
+
total.removed += result.removed;
|
|
1027
|
+
total.failed += result.failed;
|
|
1028
|
+
total.bytesRemoved += result.bytesRemoved;
|
|
1029
|
+
total.eligible += result.eligible;
|
|
1030
|
+
total.eligibleBytes += result.eligibleBytes;
|
|
1031
|
+
// Truncation anywhere makes the whole total a prefix.
|
|
1032
|
+
total.truncated ||= result.truncated;
|
|
1033
|
+
}
|
|
1034
|
+
} catch {
|
|
1035
|
+
/* best-effort: disk reclaim must never destabilize the caller */
|
|
1036
|
+
}
|
|
1037
|
+
return total;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Report-only counterpart for `ocx doctor`: applies every selection gate and unlinks
|
|
1042
|
+
* nothing. It runs the SAME predicate as the reclaim, so the report and the subsequent
|
|
1043
|
+
* removal cannot disagree about which files are reclaimable.
|
|
1044
|
+
*/
|
|
1045
|
+
export function inspectAbandonedResponseStateTemps(): ResponseStateTempRecoveryResult {
|
|
1046
|
+
return reclaimAbandonedResponseStateTemps({ dryRun: true });
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** Sweeper adapter: narrows the reclaim to the `() => number` the liveness tick expects. */
|
|
1050
|
+
export function sweepAbandonedResponseStateTemps(): number {
|
|
1051
|
+
return reclaimAbandonedResponseStateTemps({
|
|
1052
|
+
maxEntries: PERIODIC_TEMP_MAX_ENTRIES,
|
|
1053
|
+
maxCleanups: PERIODIC_TEMP_MAX_CLEANUPS,
|
|
1054
|
+
deadlineMs: PERIODIC_TEMP_SCAN_DEADLINE_MS,
|
|
1055
|
+
}).removed;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
export function responseContinuationRetainedStoreSnapshot(): RetainedStoreSnapshot {
|
|
1059
|
+
return {
|
|
1060
|
+
count: states.size,
|
|
1061
|
+
bytes: storedResponseBytes,
|
|
1062
|
+
evictableBytes: residentResponseBytes,
|
|
1063
|
+
pinnedBytes: Math.max(0, storedResponseBytes - residentResponseBytes),
|
|
1064
|
+
oldestAt: oldestResidentAt,
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
export function evictOldestResponseContinuationForBudget(): number {
|
|
1069
|
+
if (oldestResidentId === undefined) return 0;
|
|
1070
|
+
const id = oldestResidentId;
|
|
1071
|
+
const entry = states.get(id);
|
|
1072
|
+
if (!entry || entry.kind !== "resident") return 0;
|
|
1073
|
+
try {
|
|
1074
|
+
const ref = writeResponseSpillDurably(id, {
|
|
1075
|
+
createdAt: entry.createdAt,
|
|
1076
|
+
...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}),
|
|
1077
|
+
items: entry.items,
|
|
1078
|
+
...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}),
|
|
1079
|
+
...(entry.providers ? { providers: entry.providers } : {}),
|
|
1080
|
+
});
|
|
1081
|
+
if (swapResidentForSpill(id, entry, ref)) spillCounters.writes += 1;
|
|
1082
|
+
} catch {
|
|
1083
|
+
spillCounters.writeFailures += 1;
|
|
1084
|
+
replaceWithSpillFailure(id, entry);
|
|
1085
|
+
}
|
|
1086
|
+
schedulePersist();
|
|
1087
|
+
const replacement = states.get(id);
|
|
1088
|
+
return !replacement || replacement.kind === "resident"
|
|
1089
|
+
? 0
|
|
1090
|
+
: Math.max(0, entry.sizeBytes - replacement.sizeBytes);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function materializeEntry(
|
|
1094
|
+
id: string,
|
|
1095
|
+
entry: StoredResponseState,
|
|
1096
|
+
): { ok: true; state: ResidentResponseState } | { ok: false; failure: PreviousResponseReplayFailure } {
|
|
1097
|
+
if (entry.kind === "resident") return { ok: true, state: entry };
|
|
1098
|
+
if (entry.kind === "spill-failed") {
|
|
1099
|
+
return { ok: false, failure: { code: "previous_response_not_found", reason: "spill_failed" } };
|
|
1100
|
+
}
|
|
1101
|
+
const result = readResponseSpill(id, entry.spill);
|
|
1102
|
+
if (!result.ok) {
|
|
1103
|
+
spillCounters.readFailures += 1;
|
|
1104
|
+
const failure: PreviousResponseReplayFailure = {
|
|
1105
|
+
code: "previous_response_not_found",
|
|
1106
|
+
reason: result.reason === "missing"
|
|
1107
|
+
? "spill_missing"
|
|
1108
|
+
: result.reason === "too_large"
|
|
1109
|
+
? "spill_too_large"
|
|
1110
|
+
: "spill_corrupt",
|
|
1111
|
+
};
|
|
1112
|
+
replaceWithSpillFailure(id, entry);
|
|
1113
|
+
schedulePersist();
|
|
1114
|
+
return { ok: false, failure };
|
|
1115
|
+
}
|
|
1116
|
+
const state = measureResidentEntry(id, {
|
|
1117
|
+
createdAt: result.payload.createdAt,
|
|
1118
|
+
...(result.payload.clientThreadId ? { clientThreadId: result.payload.clientThreadId } : {}),
|
|
1119
|
+
items: result.payload.items,
|
|
1120
|
+
...(result.payload.providerOutputStart !== undefined
|
|
1121
|
+
? { providerOutputStart: result.payload.providerOutputStart }
|
|
1122
|
+
: {}),
|
|
1123
|
+
...(result.payload.providers ? { providers: result.payload.providers } : {}),
|
|
1124
|
+
});
|
|
1125
|
+
if (!state) {
|
|
1126
|
+
spillCounters.readFailures += 1;
|
|
1127
|
+
replaceWithSpillFailure(id, entry);
|
|
1128
|
+
schedulePersist();
|
|
1129
|
+
return { ok: false, failure: { code: "previous_response_not_found", reason: "spill_corrupt" } };
|
|
1130
|
+
}
|
|
1131
|
+
return { ok: true, state };
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function normalizedClientThreadId(value: unknown): string | undefined {
|
|
1135
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function withoutPreviousResponseId(request: Record<string, unknown>): Record<string, unknown> {
|
|
1139
|
+
const { previous_response_id: _previousResponseId, ...freshRequest } = request;
|
|
1140
|
+
return freshRequest;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
export function expandPreviousResponseInput(body: unknown, clientThreadId?: string): unknown {
|
|
1144
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
|
1145
|
+
const request = body as Record<string, unknown>;
|
|
1146
|
+
const previousId = typeof request.previous_response_id === "string" ? request.previous_response_id : undefined;
|
|
1147
|
+
if (!previousId) return body;
|
|
1148
|
+
ensureLoaded();
|
|
1149
|
+
pruneResponses();
|
|
1150
|
+
const previous = states.get(previousId);
|
|
1151
|
+
if (!previous) return body;
|
|
1152
|
+
const materialized = materializeEntry(previousId, previous);
|
|
1153
|
+
if (!materialized.ok) {
|
|
1154
|
+
replayFailures.set(request, materialized.failure);
|
|
1155
|
+
return body;
|
|
1156
|
+
}
|
|
1157
|
+
const requestThreadId = normalizedClientThreadId(clientThreadId);
|
|
1158
|
+
const storedThreadId = normalizedClientThreadId(materialized.state.clientThreadId);
|
|
1159
|
+
// A Codex task must never inherit another task's continuation, nor a legacy unscoped entry.
|
|
1160
|
+
// Unscoped callers retain backward-compatible replay only with other unscoped entries.
|
|
1161
|
+
if (requestThreadId !== storedThreadId) {
|
|
1162
|
+
const freshRequest = withoutPreviousResponseId(request);
|
|
1163
|
+
replayScopeMismatches.add(freshRequest);
|
|
1164
|
+
replayScopeMismatchDrops += 1;
|
|
1165
|
+
return freshRequest;
|
|
1166
|
+
}
|
|
1167
|
+
// The client already replayed this history verbatim. Prepending the stored copy would
|
|
1168
|
+
// double it, and the doubled turn is stored again, so the next turn triples (#1412 saw
|
|
1169
|
+
// 127k of real context reach 1.3M tokens this way).
|
|
1170
|
+
//
|
|
1171
|
+
// Three conditions, all required. The run must cover the whole stored entry; it must reach
|
|
1172
|
+
// the provider-output region; and some matched item in that region must carry a
|
|
1173
|
+
// provider-issued id. The last one is the load-bearing part: content equality alone proves
|
|
1174
|
+
// two items look alike, not that they are the same occurrence, so a client that merely
|
|
1175
|
+
// repeats its own message would otherwise authorize a skip that deletes real history.
|
|
1176
|
+
// There is no invariant that provider output always carries ids, so an entry whose output
|
|
1177
|
+
// has none simply never skips.
|
|
1178
|
+
{
|
|
1179
|
+
const clientInput = inputItems(request.input);
|
|
1180
|
+
const stored = materialized.state.items;
|
|
1181
|
+
const anchor = materialized.state.providerOutputStart;
|
|
1182
|
+
const carried = clientCarriedPrefixLength(stored, clientInput);
|
|
1183
|
+
if (
|
|
1184
|
+
carried === stored.length
|
|
1185
|
+
&& anchor !== undefined
|
|
1186
|
+
&& carried > anchor
|
|
1187
|
+
&& stored.slice(anchor, carried).some(item => providerIssuedIdentity(item) !== null)
|
|
1188
|
+
) {
|
|
1189
|
+
replayOverlapSkips += 1;
|
|
1190
|
+
// Keep previous_response_id: Kiro and Cursor recover their conversation ids from it
|
|
1191
|
+
// (kiro-wire.ts, cursor/request-builder.ts). Only the concatenation is skipped.
|
|
1192
|
+
const unchanged = { ...request };
|
|
1193
|
+
// Same provenance boundary a real expansion would record, so the replayed prefix does
|
|
1194
|
+
// not re-acknowledge historical compaction markers (parser.ts) and stays visible to
|
|
1195
|
+
// guidance de-duplication (collaboration.ts).
|
|
1196
|
+
replayedInputPrefixLengths.set(unchanged, carried);
|
|
1197
|
+
return unchanged;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
const expanded = {
|
|
1201
|
+
...request,
|
|
1202
|
+
input: [...materialized.state.items, ...inputItems(request.input)],
|
|
1203
|
+
};
|
|
1204
|
+
replayedInputPrefixLengths.set(expanded, materialized.state.items.length);
|
|
1205
|
+
return expanded;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
export function previousResponseReplayFailure(body: unknown): PreviousResponseReplayFailure | undefined {
|
|
1209
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return undefined;
|
|
1210
|
+
return replayFailures.get(body);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** Number of leading input items restored from previous_response_id state for this exact body. */
|
|
1214
|
+
export function previousResponseReplayPrefixLength(body: unknown): number {
|
|
1215
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return 0;
|
|
1216
|
+
return replayedInputPrefixLengths.get(body) ?? 0;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** Copy proxy-private replay provenance to an internal clone with the same materialized input. */
|
|
1220
|
+
export function copyPreviousResponseReplayProvenance(source: unknown, target: unknown): void {
|
|
1221
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) return;
|
|
1222
|
+
if (!target || typeof target !== "object" || Array.isArray(target)) return;
|
|
1223
|
+
const prefixLength = replayedInputPrefixLengths.get(source);
|
|
1224
|
+
if (!prefixLength) return;
|
|
1225
|
+
const input = (target as { input?: unknown }).input;
|
|
1226
|
+
if (!Array.isArray(input) || prefixLength > input.length) return;
|
|
1227
|
+
replayedInputPrefixLengths.set(target, prefixLength);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/** True when a stale or foreign previous_response_id was removed from this exact request body. */
|
|
1231
|
+
export function previousResponseScopeMismatch(body: unknown): boolean {
|
|
1232
|
+
return !!body && typeof body === "object" && replayScopeMismatches.has(body as object);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
export function previousResponseConversationId(responseId: string | undefined): string | undefined {
|
|
1236
|
+
return previousResponseProviderState(responseId)?.cursor?.conversationId;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
export function previousResponseProviderState(responseId: string | undefined): OcxProviderContinuationState | undefined {
|
|
1240
|
+
if (!responseId) return undefined;
|
|
1241
|
+
ensureLoaded();
|
|
1242
|
+
pruneResponses();
|
|
1243
|
+
const state = states.get(responseId);
|
|
1244
|
+
const providers = state?.kind === "spill-failed" ? undefined : state?.providers;
|
|
1245
|
+
return providers ? structuredClone(providers) : undefined;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
export interface ResponseStateMetrics {
|
|
1249
|
+
count: number;
|
|
1250
|
+
residentCount: number;
|
|
1251
|
+
spillStubCount: number;
|
|
1252
|
+
tombstoneCount: number;
|
|
1253
|
+
totalBytes: number;
|
|
1254
|
+
spillPayloadBytes: number;
|
|
1255
|
+
largestBytes: number;
|
|
1256
|
+
oldestAgeMs: number;
|
|
1257
|
+
spillWrites: number;
|
|
1258
|
+
spillWriteFailures: number;
|
|
1259
|
+
spillReadFailures: number;
|
|
1260
|
+
replayScopeMismatchDrops: number;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Observe-only snapshot of the in-RAM continuation store, surfaced via GET /api/system/memory.
|
|
1265
|
+
* Additive and side-effect free — it does NOT lazy-load the disk snapshot, prune, or evict — so a
|
|
1266
|
+
* diagnostics probe can sample it without perturbing request handling. `totalBytes` reads the
|
|
1267
|
+
* running byte counter and `largestBytes` reads each entry's cached `sizeBytes`, so a probe never
|
|
1268
|
+
* re-serializes the whole store (a large transient allocation that would fire exactly when memory
|
|
1269
|
+
* is already under pressure). This is the seam for deciding whether RAM growth originates in this
|
|
1270
|
+
* store (JS heap) or in the runtime allocator (native).
|
|
1271
|
+
*/
|
|
1272
|
+
export function responseStateMetrics(): ResponseStateMetrics {
|
|
1273
|
+
const at = now();
|
|
1274
|
+
let largestBytes = 0;
|
|
1275
|
+
let oldestCreatedAt = at;
|
|
1276
|
+
let residentCount = 0;
|
|
1277
|
+
let spillStubCount = 0;
|
|
1278
|
+
let tombstoneCount = 0;
|
|
1279
|
+
let spillPayloadBytes = 0;
|
|
1280
|
+
for (const state of states.values()) {
|
|
1281
|
+
const bytes = state.sizeBytes;
|
|
1282
|
+
if (bytes > largestBytes) largestBytes = bytes;
|
|
1283
|
+
if (state.createdAt < oldestCreatedAt) oldestCreatedAt = state.createdAt;
|
|
1284
|
+
if (state.kind === "resident") {
|
|
1285
|
+
residentCount += 1;
|
|
1286
|
+
} else if (state.kind === "spill") {
|
|
1287
|
+
spillStubCount += 1;
|
|
1288
|
+
spillPayloadBytes += state.spill.payloadBytes;
|
|
1289
|
+
} else tombstoneCount += 1;
|
|
1290
|
+
}
|
|
1291
|
+
return {
|
|
1292
|
+
count: states.size,
|
|
1293
|
+
residentCount,
|
|
1294
|
+
spillStubCount,
|
|
1295
|
+
tombstoneCount,
|
|
1296
|
+
totalBytes: storedResponseBytes,
|
|
1297
|
+
spillPayloadBytes,
|
|
1298
|
+
largestBytes,
|
|
1299
|
+
oldestAgeMs: states.size > 0 ? at - oldestCreatedAt : 0,
|
|
1300
|
+
spillWrites: spillCounters.writes,
|
|
1301
|
+
spillWriteFailures: spillCounters.writeFailures,
|
|
1302
|
+
spillReadFailures: spillCounters.readFailures,
|
|
1303
|
+
replayScopeMismatchDrops,
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Cache completed output and max_output_tokens partial output for previous_response_id replay.
|
|
1309
|
+
* Content-filtered incomplete and failed output are not authoritative replay history.
|
|
1310
|
+
*/
|
|
1311
|
+
/**
|
|
1312
|
+
* Request bodies that must never enter the continuation cache.
|
|
1313
|
+
*
|
|
1314
|
+
* The cache is persisted to `responses-state.json`, so anything recorded here reaches disk.
|
|
1315
|
+
* Encrypted-agent-task recovery decrypts task text into the request body and promises
|
|
1316
|
+
* in-memory, TTL-bounded retention; recording that body would put the plaintext on disk with
|
|
1317
|
+
* no TTL and break the promise.
|
|
1318
|
+
*
|
|
1319
|
+
* A WeakSet rather than a body field on purpose: `_rawBody` is serialized verbatim by the
|
|
1320
|
+
* native passthrough, so any marker written into the body itself would be sent upstream.
|
|
1321
|
+
* Marking is enforced once here rather than at each call site, because every recording path
|
|
1322
|
+
* (streaming, non-streaming, passthrough, forced) funnels through `rememberResponseState` —
|
|
1323
|
+
* a new call site cannot reintroduce the leak by forgetting a guard.
|
|
1324
|
+
*/
|
|
1325
|
+
const nonPersistableBodies = new WeakSet<object>();
|
|
1326
|
+
|
|
1327
|
+
/** Bar this exact request body from the continuation cache, and therefore from disk. */
|
|
1328
|
+
export function markBodyNonPersistable(body: unknown): void {
|
|
1329
|
+
if (body && typeof body === "object") nonPersistableBodies.add(body as object);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
export function rememberResponseState(
|
|
1333
|
+
requestBody: unknown,
|
|
1334
|
+
response: { id?: unknown; output?: unknown; status?: unknown; incomplete_details?: unknown },
|
|
1335
|
+
providerState?: OcxProviderContinuationState | string,
|
|
1336
|
+
opts?: { force?: boolean; clientThreadId?: string },
|
|
1337
|
+
): void {
|
|
1338
|
+
if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) return;
|
|
1339
|
+
const request = requestBody as Record<string, unknown>;
|
|
1340
|
+
if (nonPersistableBodies.has(request)) return;
|
|
1341
|
+
// `force` bypasses only the store:false skip: Codex sends `store:false` on every non-Azure
|
|
1342
|
+
// HTTP request (and WS inherits it), yet its WS turns still chain with previous_response_id.
|
|
1343
|
+
// The passthrough branch records with force so those chains can be expanded locally; the
|
|
1344
|
+
// store stays in-memory with a 1h TTL, so this is a proxy-internal continuation cache, not
|
|
1345
|
+
// real server-side response storage.
|
|
1346
|
+
if (request.store === false && !opts?.force) return;
|
|
1347
|
+
if (typeof response.id !== "string" || !Array.isArray(response.output)) return;
|
|
1348
|
+
if (response.status === "incomplete") {
|
|
1349
|
+
const details = response.incomplete_details;
|
|
1350
|
+
if (!details || typeof details !== "object" || Array.isArray(details)
|
|
1351
|
+
|| (details as { reason?: unknown }).reason !== "max_output_tokens") return;
|
|
1352
|
+
} else if (response.status !== undefined && response.status !== "completed") return;
|
|
1353
|
+
ensureLoaded();
|
|
1354
|
+
const normalizedProviderState: OcxProviderContinuationState = typeof providerState === "string"
|
|
1355
|
+
? { cursor: { conversationId: providerState } }
|
|
1356
|
+
: structuredClone(providerState ?? {});
|
|
1357
|
+
if (normalizedProviderState.cursor?.conversationId) {
|
|
1358
|
+
normalizedProviderState.cursor.checkpointUsable = !response.output.some(item => {
|
|
1359
|
+
return !!item && typeof item === "object" && (item as { type?: unknown }).type === "function_call";
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
const clientThreadId = normalizedClientThreadId(opts?.clientThreadId);
|
|
1363
|
+
// Compute the normalized array once and reuse it for both fields, so the recorded
|
|
1364
|
+
// boundary can never disagree with the items it indexes.
|
|
1365
|
+
const requestItems = inputItems(request.input);
|
|
1366
|
+
setResidentEntry(response.id, {
|
|
1367
|
+
createdAt: now(),
|
|
1368
|
+
...(clientThreadId ? { clientThreadId } : {}),
|
|
1369
|
+
items: [...requestItems, ...response.output],
|
|
1370
|
+
// Where response.output begins. A replay skip requires a matched item at or past this
|
|
1371
|
+
// index that also carries a provider-issued id — position alone proves only that an item
|
|
1372
|
+
// sits on the provider side, not that the provider authored it.
|
|
1373
|
+
providerOutputStart: requestItems.length,
|
|
1374
|
+
// Always preserve the Cursor conversation id so the next tool-result turn can continue the SAME
|
|
1375
|
+
// Cursor conversation (multi-turn continuation). Separately track whether Cursor's own
|
|
1376
|
+
// checkpoint/cache is safe to reuse: a turn that ended with a pending client tool call produced an
|
|
1377
|
+
// incomplete agent turn on the Cursor side (we suspended without a real mcpResult), so its
|
|
1378
|
+
// checkpoint must not be reused — but the conversation id string itself is still valid.
|
|
1379
|
+
...(Object.keys(normalizedProviderState).length > 0 ? { providers: normalizedProviderState } : {}),
|
|
1380
|
+
});
|
|
1381
|
+
enforceAppOwnedMemoryBudget();
|
|
1382
|
+
schedulePersist();
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** Test-only persistence churn hook; invoked after each atomic snapshot rewrite. */
|
|
1386
|
+
export function setResponseStatePersistAttemptHookForTests(hook: (() => void) | null): void {
|
|
1387
|
+
persistAttemptHookForTests = hook;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/** Test-only: deterministically run the pending background debounce pass. */
|
|
1391
|
+
export async function runPendingResponseStatePersistForTests(): Promise<void> {
|
|
1392
|
+
if (!persistTimer) return;
|
|
1393
|
+
await persistNow(pendingPersistPath ?? snapshotPath());
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/** Test-only: observe whether a debounce/follow-up pass is pending. */
|
|
1397
|
+
export function responseStatePersistPendingForTests(): boolean {
|
|
1398
|
+
return persistTimer !== null;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/** Memory-only reset (simulates a process restart: the snapshot file survives). */
|
|
1402
|
+
export function clearResponseStateMemoryForTests(): void {
|
|
1403
|
+
if (persistTimer) {
|
|
1404
|
+
clearTimeout(persistTimer);
|
|
1405
|
+
persistTimer = null;
|
|
1406
|
+
}
|
|
1407
|
+
pendingPersistPath = null;
|
|
1408
|
+
states.clear();
|
|
1409
|
+
storedResponseBytes = 0;
|
|
1410
|
+
residentResponseBytes = 0;
|
|
1411
|
+
oldestResidentId = undefined;
|
|
1412
|
+
oldestResidentAt = null;
|
|
1413
|
+
stateRevision = 0;
|
|
1414
|
+
pendingSpillUnlinks.length = 0;
|
|
1415
|
+
spillCounters.writes = 0;
|
|
1416
|
+
spillCounters.writeFailures = 0;
|
|
1417
|
+
spillCounters.readFailures = 0;
|
|
1418
|
+
replayScopeMismatchDrops = 0;
|
|
1419
|
+
replayOverlapSkips = 0;
|
|
1420
|
+
persistAttemptHookForTests = null;
|
|
1421
|
+
loaded = false;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
export function clearResponseStateForTests(): void {
|
|
1425
|
+
for (const entry of states.values()) deleteOwnedSpills(entry);
|
|
1426
|
+
clearResponseStateMemoryForTests();
|
|
1427
|
+
try {
|
|
1428
|
+
unlinkSync(snapshotPath());
|
|
1429
|
+
} catch {
|
|
1430
|
+
/* no snapshot on disk */
|
|
1431
|
+
}
|
|
1432
|
+
try { rmSync(responseSpillDirectory(), { recursive: true, force: true }); } catch { /* no spill directory */ }
|
|
1433
|
+
}
|