@bitkyc08/opencodex 2.80.0 → 2.82.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/README.md +3 -3
- package/bin/ocx.mjs +166 -73
- package/gui/dist/assets/App-Ds_qL7o8.js +54 -0
- package/gui/dist/assets/Tray-BqHF5Trd.js +1 -0
- package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
- package/gui/dist/assets/index-D3FBCNiP.js +94 -0
- package/gui/dist/assets/quota-summary-DTBC8n7-.js +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/claude-green.svg +1 -0
- package/package.json +3 -2
- package/src/adapters/anthropic-model-contract.ts +8 -2
- package/src/adapters/anthropic.ts +8 -3
- package/src/adapters/codebuddy/mcp-server.ts +2 -25
- package/src/adapters/codebuddy/tool-bridge.ts +1 -7
- package/src/adapters/coding-agent/mcp-server.ts +33 -0
- package/src/adapters/coding-agent/protocol.ts +122 -17
- package/src/adapters/coding-agent/tool-catalog.ts +75 -0
- package/src/adapters/coding-agent/turn.ts +101 -44
- package/src/adapters/cursor/catalog.ts +8 -0
- package/src/adapters/cursor/effort-map.ts +2 -0
- package/src/adapters/devin/live-models.ts +2 -0
- package/src/adapters/kiro-retry.ts +9 -4
- package/src/adapters/openai-chat/messages.ts +7 -6
- package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -4
- package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
- package/src/adapters/physical-send.ts +8 -5
- package/src/adapters/qoder/adapter.ts +41 -6
- package/src/adapters/qoder/scaffold-guard.ts +2 -3
- package/src/adapters/zed.ts +16 -1
- package/src/claude/agents-inject.ts +14 -3
- package/src/claude/context-windows.ts +19 -17
- package/src/claude/desktop-3p.ts +88 -12
- package/src/claude/desktop-profile.ts +2 -1
- package/src/claude/gateway-cache.ts +18 -1
- package/src/claude/inbound-cache-stabilize.ts +4 -9
- package/src/claude/inbound.ts +78 -3
- package/src/claude/intercept/cli-catalog.ts +3 -0
- package/src/claude/intercept/model-bindings.ts +1 -0
- package/src/claude/intercept/picker-ca-persistence.ts +7 -6
- package/src/claude/intercept/picker-listener.ts +71 -4
- package/src/claude/intercept/picker-models.ts +14 -6
- package/src/claude/intercept/settings.ts +3 -0
- package/src/claude/long-context.ts +66 -0
- package/src/claude/model-info.ts +19 -16
- package/src/claude/outbound.ts +94 -9
- package/src/claude/subagent-model.ts +40 -11
- package/src/cli/account-anthropic-threshold.ts +14 -5
- package/src/cli/account-api.ts +3 -1
- package/src/cli/account-extended.ts +13 -9
- package/src/cli/account-policy-dto.ts +1 -1
- package/src/cli/account-policy.ts +13 -4
- package/src/cli/account.ts +3 -3
- package/src/cli/agent.ts +9 -5
- package/src/cli/aside-profile-recovery.ts +33 -0
- package/src/cli/capabilities-accounts.ts +2 -2
- package/src/cli/capabilities-agents-routing.ts +24 -0
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-integrations.ts +18 -2
- package/src/cli/cli-path-diagnostics.ts +132 -0
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/desktop-runtime-guidance.ts +65 -0
- package/src/cli/dispatch.ts +15 -1
- package/src/cli/doctor.ts +16 -3
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +25 -14
- package/src/cli/integration-aside-sync.ts +2 -1
- package/src/cli/integration-plan-dto.ts +1 -0
- package/src/cli/integration-preview.ts +8 -1
- package/src/cli/integrations.ts +48 -6
- package/src/cli/message-args.ts +34 -0
- package/src/cli/message-command.ts +43 -0
- package/src/cli/message-runtime.ts +6 -0
- package/src/cli/observe.ts +3 -1
- package/src/cli/provider.ts +29 -6
- package/src/cli/registry.ts +24 -2
- package/src/cli/resolve.ts +25 -2
- package/src/cli/root.ts +4 -2
- package/src/cli/status.ts +64 -12
- package/src/cli/update-restart-child.ts +4 -2
- package/src/cli/update-restart-home.ts +28 -10
- package/src/cli/update-restart-service-record.ts +116 -0
- package/src/cli/update-restart-supervision.ts +96 -0
- package/src/cli/update-restart.ts +59 -13
- package/src/cli/v2.ts +27 -20
- package/src/cli/version-skew-notice.ts +77 -0
- package/src/clients/config-export/commandcode.ts +131 -0
- package/src/clients/config-export/contracts.ts +12 -0
- package/src/clients/config-export.ts +64 -1
- package/src/clients/lazycodex.ts +1 -1
- package/src/clients/omo-role-models.ts +32 -7
- package/src/codex/auth-api/account-list.ts +11 -3
- package/src/codex/auth-api/account-selection.ts +107 -0
- package/src/codex/auth-api/main-account-probe.ts +25 -8
- package/src/codex/auth-api/pool-mode-gate.ts +33 -9
- package/src/codex/auth-api/pool-quota-probe.ts +15 -1
- package/src/codex/auth-api/routes.ts +11 -49
- package/src/codex/auth-context.ts +20 -1
- package/src/codex/autostart-health.ts +12 -3
- package/src/codex/catalog/auto-review.ts +13 -6
- package/src/codex/catalog/build-entries.ts +33 -7
- package/src/codex/catalog/bundled.ts +155 -26
- package/src/codex/catalog/combo-member.ts +3 -2
- package/src/codex/catalog/control-plane.ts +45 -0
- package/src/codex/catalog/effort.ts +37 -6
- package/src/codex/catalog/gather-capture.ts +10 -3
- package/src/codex/catalog/model-hints.ts +15 -8
- package/src/codex/catalog/model-visibility.ts +3 -1
- package/src/codex/catalog/operator-tiers.ts +38 -0
- package/src/codex/catalog/parsing.ts +2 -1
- package/src/codex/catalog/provider-models.ts +58 -44
- package/src/codex/catalog/retained-sync.ts +7 -5
- package/src/codex/catalog/routed-gather.ts +15 -1
- package/src/codex/catalog-auto-refresh-sources.ts +3 -4
- package/src/codex/catalog-auto-refresh.ts +19 -0
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-home-owner.ts +3 -0
- package/src/codex/config-write-lock.ts +333 -0
- package/src/codex/control-plane-models.ts +6 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/features.ts +257 -200
- package/src/codex/inject/config-write-section.ts +34 -0
- package/src/codex/inject/multi-agent-v2.ts +14 -6
- package/src/codex/inject/remove.ts +36 -11
- package/src/codex/inject/restore.ts +85 -20
- package/src/codex/inject-coordination.ts +15 -6
- package/src/codex/inject.ts +62 -20
- package/src/codex/journal.ts +62 -17
- package/src/codex/main-account-cache.ts +43 -0
- package/src/codex/paths.ts +2 -0
- package/src/codex/prepared-home.ts +18 -0
- package/src/codex/prompt-journal.ts +25 -12
- package/src/codex/prompt-layers.ts +49 -26
- package/src/codex/prompt-lock-claim.ts +134 -0
- package/src/codex/prompt-lock-evidence.ts +26 -0
- package/src/codex/prompt-lock-io.ts +16 -0
- package/src/codex/prompt-lock-owner.ts +130 -0
- package/src/codex/prompt-lock.ts +84 -53
- package/src/codex/routing/failure-window.ts +146 -0
- package/src/codex/routing/selection.ts +4 -1
- package/src/codex/routing.ts +13 -17
- package/src/codex/runtime.ts +106 -18
- package/src/codex/shim.ts +45 -19
- package/src/combos/jev-decision-contract.ts +25 -2
- package/src/combos/jev-service-exchange.ts +281 -0
- package/src/combos/jev.ts +34 -219
- package/src/combos/types.ts +1 -1
- package/src/config/atomic-write.ts +1 -1
- package/src/config/codex-ws-reuse-setting.ts +10 -0
- package/src/config/diagnostics.ts +27 -16
- package/src/config/live-reconcile.ts +17 -0
- package/src/config/load-degrade.ts +42 -0
- package/src/config/rebase-provenance.ts +11 -0
- package/src/config/schema/anthropic-account-pool.ts +143 -0
- package/src/config/schema/config-schema.ts +11 -1
- package/src/config/schema/leaf-validators.ts +4 -0
- package/src/config.ts +2 -0
- package/src/generated/compatibility-version.json +566 -298
- package/src/generated/model-metadata.ts +7 -6
- package/src/grok/inject.ts +50 -4
- package/src/grok/sync.ts +2 -1
- package/src/images/loop.ts +30 -11
- package/src/integrations/aside-profiles.ts +10 -3
- package/src/integrations/catalog-refresh.ts +19 -3
- package/src/integrations/mutation-plan.ts +7 -0
- package/src/integrations/registry.ts +8 -0
- package/src/integrations/state.ts +9 -2
- package/src/integrations/writer.ts +17 -5
- package/src/lib/bun-path-runtime.d.mts +32 -0
- package/src/lib/bun-path-runtime.mjs +95 -0
- package/src/lib/bun-runtime-preflight.ts +140 -0
- package/src/lib/desktop-cli-handoff.d.mts +20 -0
- package/src/lib/desktop-cli-handoff.mjs +92 -0
- package/src/lib/desktop-cli-record.d.mts +29 -0
- package/src/lib/desktop-cli-record.mjs +102 -0
- package/src/lib/lab-live-route-production.ts +9 -1
- package/src/lib/request-execution-budget.ts +244 -58
- package/src/lib/spend-pool-alias-validation.ts +79 -0
- package/src/lib/spend-pool-continuity.ts +34 -0
- package/src/lib/spend-reservation-ledger.ts +412 -50
- package/src/lib/state-store-registrations.ts +20 -3
- package/src/lib/synced-state-location.ts +46 -6
- package/src/lib/test-home-guard.ts +87 -1
- package/src/lib/translator-budget.ts +17 -4
- package/src/lib/upstream-retry.ts +17 -5
- package/src/lib/windows-atomic-replace.ts +38 -16
- package/src/lib/windows-elevation.ts +6 -0
- package/src/lib/windows-secret-acl.ts +92 -25
- package/src/lib/winsw.ts +2 -3
- package/src/lib/workflow-budget.ts +53 -11
- package/src/messaging/budget.ts +45 -0
- package/src/messaging/discovery.ts +57 -0
- package/src/messaging/envelope.ts +49 -0
- package/src/messaging/input.ts +37 -0
- package/src/messaging/rpc.ts +196 -0
- package/src/messaging/send.ts +58 -0
- package/src/messaging/socket.ts +72 -0
- package/src/messaging/types.ts +29 -0
- package/src/oauth/anthropic-account-refusal.ts +90 -18
- package/src/oauth/anthropic-account-threshold.ts +8 -2
- package/src/oauth/anthropic-continuity.ts +15 -1
- package/src/oauth/anthropic-model-quota.ts +120 -48
- package/src/oauth/anthropic-model-routes.ts +11 -3
- package/src/oauth/anthropic-oauth-definitions.ts +98 -0
- package/src/oauth/anthropic-pool-config.ts +30 -0
- package/src/oauth/anthropic-rate-limit-policy.ts +64 -9
- package/src/oauth/anthropic-routing.ts +1202 -866
- package/src/oauth/anthropic-send-ownership.ts +41 -0
- package/src/oauth/anthropic.ts +4 -1
- package/src/oauth/chatgpt.ts +113 -41
- package/src/oauth/generic-account-failover.ts +3 -1
- package/src/oauth/health.ts +8 -6
- package/src/oauth/index.ts +96 -175
- package/src/oauth/kiro.ts +22 -9
- package/src/oauth/login-cli.ts +2 -0
- package/src/oauth/model-discovery-auth.ts +43 -0
- package/src/oauth/pool-kernel.ts +11 -5
- package/src/oauth/pool-settings-capability.ts +5 -3
- package/src/oauth/store-anthropic-instance.ts +87 -0
- package/src/oauth/store.ts +68 -10
- package/src/oauth/token-guardian.ts +2 -0
- package/src/oauth/types.ts +2 -0
- package/src/protocols/plan-snapshot.ts +5 -0
- package/src/protocols/settings.ts +20 -5
- package/src/providers/anthropic-instance-id.ts +47 -0
- package/src/providers/anthropic-instance.ts +48 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +11 -1
- package/src/providers/azure-model-metadata.ts +115 -0
- package/src/providers/derive.ts +23 -0
- package/src/providers/kiro-models.ts +3 -0
- package/src/providers/label.ts +1 -1
- package/src/providers/model-rename-fields.ts +2 -0
- package/src/providers/quota/account-cache.ts +37 -11
- package/src/providers/quota/anthropic-account-quota.ts +93 -0
- package/src/providers/quota/anthropic-cooldown-recovery.ts +222 -111
- package/src/providers/quota/report-cache.ts +24 -1
- package/src/providers/quota/vendor-probes-key.ts +127 -17
- package/src/providers/quota/vendor-probes-oauth.ts +46 -14
- package/src/providers/quota.ts +23 -33
- package/src/providers/registry/entries-core.ts +35 -30
- package/src/providers/registry/model-ids.ts +1 -0
- package/src/providers/registry/model-seeds.ts +7 -4
- package/src/providers/registry/types.ts +3 -0
- package/src/providers/registry.ts +3 -3
- package/src/responses/reasoning-envelope.ts +29 -1
- package/src/responses/reasoning-replay-cache.ts +17 -1
- package/src/responses/spill-store.ts +20 -3
- package/src/responses/state/metrics.ts +95 -0
- package/src/responses/state/snapshot-codec.ts +6 -0
- package/src/responses/state/spill-failure.ts +12 -0
- package/src/responses/state/spill-queue.ts +13 -5
- package/src/responses/state/unforced-store-false.ts +55 -0
- package/src/responses/state.ts +68 -76
- package/src/responses/task-input.ts +17 -0
- package/src/router.ts +28 -4
- package/src/routing/compatibility/assemble.ts +1 -1
- package/src/routing/quota.ts +7 -3
- package/src/server/auth-cors.ts +5 -0
- package/src/server/background-lifecycle.ts +27 -0
- package/src/server/chat-completions.ts +7 -1
- package/src/server/chat-native.ts +64 -28
- package/src/server/claude-messages.ts +59 -8
- package/src/server/gui-static.ts +38 -4
- package/src/server/inbound-body-admission.ts +1 -0
- package/src/server/index/claude-intercept-lifecycle.ts +3 -1
- package/src/server/index/serve-options.ts +10 -2
- package/src/server/index/spend-ledger-lifecycle.ts +12 -5
- package/src/server/index/startup-warnings.ts +19 -0
- package/src/server/index.ts +5 -2
- package/src/server/inference/context.ts +1 -1
- package/src/server/lifecycle.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +123 -38
- package/src/server/management/anthropic-account-threshold.ts +12 -9
- package/src/server/management/anthropic-pool-settings.ts +72 -0
- package/src/server/management/anthropic-reset-grant-routes.ts +98 -39
- package/src/server/management/codex-agent-role-routes.ts +4 -2
- package/src/server/management/codex-prompt-routes.ts +1 -0
- package/src/server/management/codex-role-auto-assign.ts +13 -2
- package/src/server/management/config-routes.ts +85 -14
- package/src/server/management/context.ts +2 -2
- package/src/server/management/decision-routes.ts +2 -1
- package/src/server/management/integration-routes.ts +25 -2
- package/src/server/management/oauth-account-routes.ts +79 -98
- package/src/server/management/provider-instance-ownership.ts +44 -0
- package/src/server/management/provider-routes.ts +21 -28
- package/src/server/management/route-registry.ts +3 -1
- package/src/server/management/routing-profile-routes.ts +4 -4
- package/src/server/management/shared.ts +3 -3
- package/src/server/management/system-restart.ts +44 -14
- package/src/server/management/system-routes.ts +24 -2
- package/src/server/management/vision-sidecar-options.ts +9 -6
- package/src/server/management/web-search-sidecar-options.ts +51 -2
- package/src/server/management-auth.ts +6 -0
- package/src/server/messages-native-eligibility.ts +3 -2
- package/src/server/messages-native-oauth.ts +91 -45
- package/src/server/messages-native-selector.ts +39 -0
- package/src/server/messages-native.ts +133 -38
- package/src/server/messages-response-headers.ts +14 -0
- package/src/server/request-log-conversation.ts +48 -5
- package/src/server/request-log.ts +36 -2
- package/src/server/responses/adapter-continuation.ts +31 -19
- package/src/server/responses/adapter-dispatch.ts +75 -24
- package/src/server/responses/antigravity-validation-refusal.ts +20 -6
- package/src/server/responses/codex-ws-exchange.ts +17 -2
- package/src/server/responses/codex-ws-pool.ts +100 -13
- package/src/server/responses/codex-ws-wire.ts +9 -0
- package/src/server/responses/combo-requested-effort.ts +3 -1
- package/src/server/responses/compact.ts +25 -8
- package/src/server/responses/compaction-recovery.ts +1 -1
- package/src/server/responses/core-codex-account.ts +22 -1
- package/src/server/responses/core-combo-native.ts +24 -4
- package/src/server/responses/core-combo.ts +38 -25
- package/src/server/responses/core-lifetime.ts +3 -36
- package/src/server/responses/core-normalize.ts +1 -0
- package/src/server/responses/core-options.ts +8 -1
- package/src/server/responses/core-replay.ts +78 -0
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/fetch-helpers.ts +26 -3
- package/src/server/responses/passthrough-delivery.ts +30 -4
- package/src/server/responses/passthrough-dispatch.ts +39 -9
- package/src/server/responses/request-prepare.ts +41 -5
- package/src/server/responses/request-send-budget.ts +150 -50
- package/src/server/responses/request-spend.ts +325 -33
- package/src/server/responses/request-transport.ts +132 -52
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/run-turn-execution.ts +13 -3
- package/src/server/responses/sidecar-execution.ts +113 -22
- package/src/server/responses/sidecar-send-budget.ts +58 -0
- package/src/server/responses/ws-upstream.ts +19 -6
- package/src/server/responses-undeclared-tool-guard.ts +5 -1
- package/src/server/startup-health-cache.ts +2 -1
- package/src/server/workflow-refusal.ts +33 -0
- package/src/service/cli.ts +20 -4
- package/src/service/desktop-command-guard.ts +32 -0
- package/src/service/desktop-startup.ts +66 -40
- package/src/service/desktop-supervision.d.mts +26 -0
- package/src/service/desktop-supervision.mjs +140 -0
- package/src/service/managing-cli.ts +1 -0
- package/src/service/orchestration.ts +46 -20
- package/src/service/ownership-mutation-lease.d.mts +1 -1
- package/src/service/ownership-mutation-lease.mjs +5 -7
- package/src/service/repair.ts +25 -9
- package/src/service/state.ts +4 -3
- package/src/service/systemd.ts +17 -8
- package/src/service/windows-ops.ts +61 -22
- package/src/sidecar/anthropic-binding.ts +79 -0
- package/src/sidecar/auth.ts +76 -2
- package/src/storage/policy-job.ts +5 -1
- package/src/storage/policy-worker.ts +3 -1
- package/src/storage/policy.ts +9 -0
- package/src/types/anthropic-account-pool.ts +33 -0
- package/src/types/config.ts +33 -28
- package/src/types/provider.ts +5 -0
- package/src/types/request.ts +5 -0
- package/src/types/tools.ts +7 -0
- package/src/types/wire.ts +1 -0
- package/src/types.ts +4 -0
- package/src/update/index.ts +57 -9
- package/src/update/restart-ownership.ts +10 -3
- package/src/update/runtime-ownership.d.mts +6 -0
- package/src/update/runtime-ownership.mjs +23 -7
- package/src/usage/cost.ts +3 -2
- package/src/usage/expected-prices.ts +66 -14
- package/src/vision/anthropic-describe.ts +16 -3
- package/src/vision/index.ts +3 -0
- package/src/vision/plan.ts +19 -6
- package/src/web-search/alpha-search.ts +7 -1
- package/src/web-search/anthropic-executor.ts +16 -4
- package/src/web-search/index.ts +7 -3
- package/src/web-search/loop.ts +32 -7
- package/src/web-search/passthrough-bridge.ts +4 -1
- package/src/web-search/sidecar-providers.ts +4 -3
- package/gui/dist/assets/App-MbDVfUeC.js +0 -54
- package/gui/dist/assets/Tray-CzkxK82A.js +0 -1
- package/gui/dist/assets/index-UJt6npXk.js +0 -94
- package/gui/dist/assets/quota-summary-DU3sD6Z-.js +0 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { ownEvidence, ownerState, safeNamespace, type OwnerDeps, type OwnerEvidence } from "./prompt-lock-owner";
|
|
4
|
+
import { ChangedLock as ChangedClaim, checkEntry, checkIdentity, guarded, type Entry, type Namespace } from "./prompt-lock-evidence";
|
|
5
|
+
import { LockFileBusy, lockFileOperation } from "./prompt-lock-io";
|
|
6
|
+
|
|
7
|
+
export interface ClaimRecord extends OwnerEvidence { ticket: number; token?: string }
|
|
8
|
+
export class UnsafeLockNamespace extends Error {
|
|
9
|
+
constructor(readonly path: string) { super(`Unsafe lock state at ${path}; deliberate removal is required.`); }
|
|
10
|
+
}
|
|
11
|
+
interface AbandonedClaim { token: string; parents: Namespace[]; entry: Entry }
|
|
12
|
+
// Only a completed invocation may enter this map; live/reentrant reservations never do.
|
|
13
|
+
const abandoned = new Map<string, AbandonedClaim>();
|
|
14
|
+
function recoverAbandoned(directory: string, deps: OwnerDeps): void {
|
|
15
|
+
for (const [path, claim] of abandoned) {
|
|
16
|
+
if (dirname(path) !== directory) continue;
|
|
17
|
+
// The token is bound to the captured bytes and unique name, never to PID alone.
|
|
18
|
+
const record = JSON.parse(claim.entry.body) as ClaimRecord;
|
|
19
|
+
if (record.pid !== process.pid || record.token !== claim.token) throw new ChangedClaim("Unknown local claim");
|
|
20
|
+
try { guarded(claim.parents, [claim.entry], deps, () => unlinkSync(path)); }
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (!(error instanceof ChangedClaim) && (error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
23
|
+
// A replacement is not ours; forget custody without deleting it.
|
|
24
|
+
}
|
|
25
|
+
abandoned.delete(path);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Publish owner evidence together with choosing=0; an empty reservation is never published. */
|
|
29
|
+
export function withLockClaim<T>(
|
|
30
|
+
path: string, token: string, deps: OwnerDeps, run: () => T,
|
|
31
|
+
initialized?: () => void,
|
|
32
|
+
): { ok: true; value: T } | { ok: false } {
|
|
33
|
+
const io = <R>(operation: () => R): R => lockFileOperation(operation, deps.platform);
|
|
34
|
+
const directory = `${path}.claims`;
|
|
35
|
+
try { recoverAbandoned(directory, deps); }
|
|
36
|
+
catch (error) { if (error instanceof LockFileBusy) return { ok: false }; throw error; }
|
|
37
|
+
if (!safeNamespace(dirname(path), "directory", deps)) throw new UnsafeLockNamespace(dirname(path));
|
|
38
|
+
const root: Namespace = { path: dirname(path), stat: io(() => deps.lstat(dirname(path))) };
|
|
39
|
+
try { guarded([root], [], deps, () => mkdirSync(directory, { mode: 0o700 })); }
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error instanceof LockFileBusy) return { ok: false };
|
|
42
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
43
|
+
}
|
|
44
|
+
if (!safeNamespace(directory, "directory", deps)) throw new UnsafeLockNamespace(directory);
|
|
45
|
+
const parents = [root, { path: directory, stat: io(() => deps.lstat(directory)) }];
|
|
46
|
+
const claimDirectoryGone = (): boolean => {
|
|
47
|
+
try { const now = deps.lstat(directory); return now.dev !== parents[1].stat.dev || now.ino !== parents[1].stat.ino; }
|
|
48
|
+
catch { return true; }
|
|
49
|
+
};
|
|
50
|
+
const ownName = `${process.pid}-${token}.claim`, ownPath = join(directory, ownName);
|
|
51
|
+
const temporary = `${path}.claim-init-${token}`;
|
|
52
|
+
let ownEntry: Entry | undefined, temporaryEntry: Entry | undefined;
|
|
53
|
+
const evidence = { ...ownEvidence(deps), token };
|
|
54
|
+
const capture = (entryPath: string, expectedBody?: string): Entry => guarded(parents, [], deps, () => {
|
|
55
|
+
if (!safeNamespace(entryPath, "file", deps, true)) throw new UnsafeLockNamespace(entryPath);
|
|
56
|
+
const entry = { path: entryPath, stat: deps.lstat(entryPath), body: readFileSync(entryPath, "utf8") };
|
|
57
|
+
checkEntry(entry, deps);
|
|
58
|
+
if (expectedBody !== undefined && entry.body !== expectedBody) throw new ChangedClaim("Local claim evidence changed");
|
|
59
|
+
return entry;
|
|
60
|
+
});
|
|
61
|
+
const peers = (): Array<{ name: string; ticket: number }> => {
|
|
62
|
+
const result: Array<{ name: string; ticket: number }> = [];
|
|
63
|
+
for (const name of guarded(parents, [], deps, () => readdirSync(directory))) {
|
|
64
|
+
if (name === ownName) continue;
|
|
65
|
+
const peerPath = join(directory, name);
|
|
66
|
+
if (!/^([1-9][0-9]*)-[0-9a-f]{16}\.claim$/.test(name)
|
|
67
|
+
|| !safeNamespace(peerPath, "file", deps, true)) throw new UnsafeLockNamespace(peerPath);
|
|
68
|
+
let entry: Entry, peer: ClaimRecord | null;
|
|
69
|
+
try { entry = capture(peerPath); peer = JSON.parse(entry.body); }
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof LockFileBusy || error instanceof UnsafeLockNamespace) throw error;
|
|
72
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
73
|
+
throw new UnsafeLockNamespace(peerPath);
|
|
74
|
+
}
|
|
75
|
+
const state = ownerState(peer, deps);
|
|
76
|
+
if (state === "unsafe") throw new UnsafeLockNamespace(peerPath);
|
|
77
|
+
if (state === "dead") {
|
|
78
|
+
try { guarded(parents, [entry], deps, () => {
|
|
79
|
+
if (ownerState(peer, deps) !== "dead") throw new ChangedClaim("Peer is no longer dead");
|
|
80
|
+
checkEntry(entry, deps);
|
|
81
|
+
for (const parent of parents) checkIdentity(parent, deps);
|
|
82
|
+
unlinkSync(peerPath);
|
|
83
|
+
}); }
|
|
84
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const ticket = peer?.ticket;
|
|
88
|
+
if (typeof ticket !== "number" || !Number.isSafeInteger(ticket) || ticket < 0) throw new UnsafeLockNamespace(peerPath);
|
|
89
|
+
result.push({ name, ticket });
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
const choosingBody = JSON.stringify({ ...evidence, ticket: 0 });
|
|
95
|
+
guarded(parents, [], deps, () => writeFileSync(temporary, choosingBody, { mode: 0o600, flag: "wx" }));
|
|
96
|
+
temporaryEntry = capture(temporary, choosingBody);
|
|
97
|
+
guarded(parents, [temporaryEntry], deps, () => linkSync(temporary, ownPath));
|
|
98
|
+
ownEntry = { ...temporaryEntry, path: ownPath };
|
|
99
|
+
guarded(parents, [temporaryEntry], deps, () => unlinkSync(temporary)); temporaryEntry = undefined;
|
|
100
|
+
initialized?.();
|
|
101
|
+
let ticket = 1;
|
|
102
|
+
for (const peer of peers()) ticket = Math.max(ticket, peer.ticket + 1);
|
|
103
|
+
if (!Number.isSafeInteger(ticket)) return { ok: false };
|
|
104
|
+
const ticketBody = JSON.stringify({ ...evidence, ticket });
|
|
105
|
+
guarded(parents, [], deps, () => writeFileSync(temporary, ticketBody, { mode: 0o600, flag: "wx" }));
|
|
106
|
+
temporaryEntry = capture(temporary, ticketBody);
|
|
107
|
+
guarded(parents, [temporaryEntry, ownEntry], deps, () => renameSync(temporary, ownPath));
|
|
108
|
+
ownEntry = { ...temporaryEntry, path: ownPath }; temporaryEntry = undefined;
|
|
109
|
+
for (const peer of peers()) {
|
|
110
|
+
if (peer.ticket === 0 || peer.ticket < ticket || (peer.ticket === ticket && peer.name < ownName)) return { ok: false };
|
|
111
|
+
}
|
|
112
|
+
if (!safeNamespace(path, "file", deps, true)) throw new UnsafeLockNamespace(path);
|
|
113
|
+
return { ok: true, value: run() };
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
116
|
+
// A peer's final rmdir can remove the claims directory between our identity check and the
|
|
117
|
+
// link/rename into it; platforms report that as ENOENT or (macOS link) EINVAL. Either is contention.
|
|
118
|
+
if (error instanceof LockFileBusy || code === "ENOENT" || (code === "EINVAL" && claimDirectoryGone())) return { ok: false };
|
|
119
|
+
throw error;
|
|
120
|
+
} finally {
|
|
121
|
+
if (temporaryEntry) {
|
|
122
|
+
try { guarded(parents, [temporaryEntry], deps, () => unlinkSync(temporary)); } catch { /* Preserve changed or busy entries. */ }
|
|
123
|
+
}
|
|
124
|
+
if (ownEntry) {
|
|
125
|
+
try { guarded(parents, [ownEntry], deps, () => unlinkSync(ownPath)); }
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (!(error instanceof ChangedClaim) && (error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
128
|
+
abandoned.set(ownPath, { token, parents, entry: ownEntry });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
try { guarded(parents, [], deps, () => rmdirSync(directory)); } catch { /* A peer or replacement still owns the directory. */ }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFileSync, type Stats } from "node:fs";
|
|
2
|
+
import { type OwnerDeps } from "./prompt-lock-owner";
|
|
3
|
+
import { LockFileBusy, lockFileOperation } from "./prompt-lock-io";
|
|
4
|
+
|
|
5
|
+
export class ChangedLock extends LockFileBusy {}
|
|
6
|
+
export interface Namespace { path: string; stat: Stats }
|
|
7
|
+
export interface Entry extends Namespace { body: string }
|
|
8
|
+
export function checkIdentity(saved: Namespace, deps: OwnerDeps): void {
|
|
9
|
+
const current = deps.lstat(saved.path);
|
|
10
|
+
if (current.isSymbolicLink() || current.dev !== saved.stat.dev || current.ino !== saved.stat.ino
|
|
11
|
+
|| current.uid !== saved.stat.uid || current.isDirectory() !== saved.stat.isDirectory()
|
|
12
|
+
|| current.isFile() !== saved.stat.isFile()) throw new ChangedLock("Lock namespace changed");
|
|
13
|
+
}
|
|
14
|
+
export function checkEntry(saved: Entry, deps: OwnerDeps): void {
|
|
15
|
+
checkIdentity(saved, deps);
|
|
16
|
+
if (readFileSync(saved.path, "utf8") !== saved.body) throw new ChangedLock("Claim evidence changed");
|
|
17
|
+
checkIdentity(saved, deps);
|
|
18
|
+
}
|
|
19
|
+
export function guarded<T>(parents: Namespace[], entries: Entry[], deps: OwnerDeps, operation: () => T): T {
|
|
20
|
+
return lockFileOperation(() => {
|
|
21
|
+
for (const parent of parents) checkIdentity(parent, deps);
|
|
22
|
+
for (const entry of entries) checkEntry(entry, deps);
|
|
23
|
+
for (const parent of parents) checkIdentity(parent, deps);
|
|
24
|
+
return operation();
|
|
25
|
+
}, deps.platform);
|
|
26
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { retryWindowsFileOperation, transientWindowsReplaceCode } from "../lib/windows-atomic-replace";
|
|
2
|
+
|
|
3
|
+
/** A held Windows handle is contention, never owner-death evidence. */
|
|
4
|
+
export class LockFileBusy extends Error {}
|
|
5
|
+
|
|
6
|
+
export function lockFileOperation<T>(operation: () => T, platform: NodeJS.Platform): T {
|
|
7
|
+
let contended = false;
|
|
8
|
+
try {
|
|
9
|
+
return retryWindowsFileOperation({ run: operation, onRetry: () => { contended = true; } }, { platform, sleep: Bun.sleepSync });
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (transientWindowsReplaceCode(platform, error)
|
|
13
|
+
|| (contended && (error as NodeJS.ErrnoException).code === "EEXIST")) throw new LockFileBusy("Lock filesystem operation is busy");
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, type Stats } from "node:fs";
|
|
2
|
+
import { resolveTrustedWindowsPowerShellExe, resolveTrustedWindowsRegExe } from "../lib/windows-elevation";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
|
|
5
|
+
import { LockFileBusy, lockFileOperation } from "./prompt-lock-io";
|
|
6
|
+
|
|
7
|
+
export interface HostIdentity { hostname: string; machine: string }
|
|
8
|
+
export interface OwnerEvidence { pid: number; host?: HostIdentity; processStart?: string }
|
|
9
|
+
export interface OwnerDeps {
|
|
10
|
+
isProcessAlive: (pid: number) => boolean | undefined;
|
|
11
|
+
hostIdentity: () => HostIdentity | undefined;
|
|
12
|
+
processStart: (pid: number) => string | undefined;
|
|
13
|
+
lstat: (path: string) => Stats;
|
|
14
|
+
uid: () => number | undefined;
|
|
15
|
+
platform: NodeJS.Platform;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function command(args: string[], timeoutMs: number): string | undefined {
|
|
19
|
+
try {
|
|
20
|
+
const result = Bun.spawnSync(args, { stdout: "pipe", stderr: "pipe", timeout: timeoutMs });
|
|
21
|
+
const value = result.stdout.toString().trim();
|
|
22
|
+
return result.exitCode === 0 && value ? value : undefined;
|
|
23
|
+
} catch { return undefined; }
|
|
24
|
+
}
|
|
25
|
+
const MACHINE_GUID_KEY = "HKLM\\SOFTWARE\\Microsoft\\Cryptography";
|
|
26
|
+
function parseMachineGuid(output: string | undefined): string | undefined {
|
|
27
|
+
const lines = output?.trim().split(/\r?\n/);
|
|
28
|
+
if (lines?.length !== 2 || lines[0]!.trim().toLowerCase() !== "hkey_local_machine\\software\\microsoft\\cryptography") return undefined;
|
|
29
|
+
return /^[ \t]+MachineGuid[ \t]+REG_SZ[ \t]+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})[ \t]*$/i.exec(lines[1]!)?.[1]?.toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
/** One lazy identity cache per process; unavailable results are cached too. */
|
|
32
|
+
export function createOwnerIdentity({
|
|
33
|
+
platform = process.platform, runCommand = command,
|
|
34
|
+
powerShellExe = resolveTrustedWindowsPowerShellExe,
|
|
35
|
+
regExe = resolveTrustedWindowsRegExe,
|
|
36
|
+
readFile = (path: string) => readFileSync(path, "utf8"),
|
|
37
|
+
}: {
|
|
38
|
+
platform?: NodeJS.Platform;
|
|
39
|
+
runCommand?: (args: string[], timeoutMs: number) => string | undefined;
|
|
40
|
+
powerShellExe?: () => string;
|
|
41
|
+
regExe?: () => string;
|
|
42
|
+
readFile?: (path: string) => string;
|
|
43
|
+
} = {}): Pick<OwnerDeps, "hostIdentity" | "processStart"> {
|
|
44
|
+
let cachedHost: HostIdentity | undefined;
|
|
45
|
+
let hostRead = false;
|
|
46
|
+
function hostIdentity(): HostIdentity | undefined {
|
|
47
|
+
if (hostRead) return cachedHost;
|
|
48
|
+
hostRead = true;
|
|
49
|
+
let machine: string | undefined;
|
|
50
|
+
try {
|
|
51
|
+
if (platform === "linux") {
|
|
52
|
+
for (const path of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
53
|
+
try {
|
|
54
|
+
const value = readFile(path).trim();
|
|
55
|
+
if (/^[0-9a-f]{32}$/i.test(value)) { machine = value.toLowerCase(); break; }
|
|
56
|
+
} catch { /* Try the fallback machine-id file. */ }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else if (platform === "darwin") machine = runCommand(["/usr/sbin/sysctl", "-n", "kern.bootsessionuuid"], 1_000);
|
|
60
|
+
else if (platform === "win32") machine = parseMachineGuid(runCommand([regExe(), "query", MACHINE_GUID_KEY, "/v", "MachineGuid"], 3_000));
|
|
61
|
+
if (machine) cachedHost = { hostname: hostname(), machine };
|
|
62
|
+
} catch { /* Missing identity means no automatic takeover. */ }
|
|
63
|
+
return cachedHost;
|
|
64
|
+
}
|
|
65
|
+
function probeProcessStart(pid: number): string | undefined {
|
|
66
|
+
try {
|
|
67
|
+
if (platform === "linux") {
|
|
68
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
69
|
+
return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
|
|
70
|
+
}
|
|
71
|
+
if (platform === "darwin") return runCommand(["/bin/ps", "-p", String(pid), "-o", "lstart="], 1_000);
|
|
72
|
+
if (platform === "win32") return runCommand([powerShellExe(), "-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`], 1_000);
|
|
73
|
+
} catch { /* Unknown start identity never proves death. */ }
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
let ownStartRead = false;
|
|
77
|
+
let cachedOwnStart: string | undefined;
|
|
78
|
+
function processStart(pid: number): string | undefined {
|
|
79
|
+
// No spawn-free Windows start-time source is available here. Missing start
|
|
80
|
+
// evidence disables only the PID-reuse check, never the live-PID veto.
|
|
81
|
+
if (platform === "win32" && pid === process.pid) return undefined;
|
|
82
|
+
if (pid !== process.pid) return probeProcessStart(pid);
|
|
83
|
+
if (!ownStartRead) { ownStartRead = true; cachedOwnStart = probeProcessStart(pid); }
|
|
84
|
+
return cachedOwnStart;
|
|
85
|
+
}
|
|
86
|
+
return { hostIdentity, processStart };
|
|
87
|
+
}
|
|
88
|
+
const identity = createOwnerIdentity();
|
|
89
|
+
export const ownerDefaults: OwnerDeps = {
|
|
90
|
+
isProcessAlive(pid) {
|
|
91
|
+
try { process.kill(pid, 0); return true; }
|
|
92
|
+
catch (error) {
|
|
93
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
94
|
+
return code === "ESRCH" ? false : code === "EPERM" ? true : undefined;
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
...identity, lstat: lstatSync,
|
|
98
|
+
uid: () => process.getuid?.(), platform: process.platform,
|
|
99
|
+
};
|
|
100
|
+
export function ownEvidence(deps: OwnerDeps): OwnerEvidence {
|
|
101
|
+
let host: HostIdentity | undefined, start: string | undefined;
|
|
102
|
+
try { host = deps.hostIdentity(); } catch { /* hostless */ }
|
|
103
|
+
try { start = deps.processStart(process.pid); } catch { /* unknown */ }
|
|
104
|
+
return { pid: process.pid, ...(host ? { host } : {}), ...(start ? { processStart: start } : {}) };
|
|
105
|
+
}
|
|
106
|
+
export function ownerState(record: OwnerEvidence | null, deps: OwnerDeps): "dead" | "live" | "unsafe" {
|
|
107
|
+
let host: HostIdentity | undefined;
|
|
108
|
+
try { host = deps.hostIdentity(); } catch { return "unsafe"; }
|
|
109
|
+
if (!host || !record?.host || (record.processStart !== undefined && (typeof record.processStart !== "string" || !record.processStart))
|
|
110
|
+
|| !Number.isSafeInteger(record.pid) || record.pid <= 0
|
|
111
|
+
|| host.hostname !== record.host.hostname || host.machine !== record.host.machine) return "unsafe";
|
|
112
|
+
try {
|
|
113
|
+
// Probe another PID's start only when liveness permits a takeover decision.
|
|
114
|
+
if (deps.isProcessAlive(record.pid) !== false) return "live";
|
|
115
|
+
const start = record.processStart === undefined ? undefined : deps.processStart(record.pid);
|
|
116
|
+
// A reused PID is not permission to remove another process's record.
|
|
117
|
+
if (start !== undefined && start !== record.processStart) return "live";
|
|
118
|
+
return deps.isProcessAlive(record.pid) === false ? "dead" : "live";
|
|
119
|
+
} catch { return "live"; }
|
|
120
|
+
}
|
|
121
|
+
export function safeNamespace(path: string, kind: "file" | "directory", deps: OwnerDeps, missing = false): boolean {
|
|
122
|
+
try {
|
|
123
|
+
const stat = lockFileOperation(() => deps.lstat(path), deps.platform), uid = deps.uid();
|
|
124
|
+
return !stat.isSymbolicLink() && (kind === "file" ? stat.isFile() : stat.isDirectory())
|
|
125
|
+
&& (deps.platform === "win32" || (uid !== undefined && stat.uid === uid));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error instanceof LockFileBusy) throw error;
|
|
128
|
+
return missing && (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/codex/prompt-lock.ts
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* the lock stale, B removes it and acquires its own, then A unlinks *B's live
|
|
10
10
|
* lock* and both proceed. Unlinking a path you did not verify is the bug. Here
|
|
11
11
|
* the contender renames the observed stale lock to a token-quarantined name —
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* a serialized operation under a short, unique per-process reservation. The
|
|
13
|
+
* reservation covers observation through creation: rename alone cannot stop
|
|
14
|
+
* an old observation from moving a successor's live lock.
|
|
14
15
|
*
|
|
15
16
|
* RELEASE ONLY DELETES A LOCK WHOSE TOKEN IS STILL OURS. A mismatch means we
|
|
16
17
|
* were superseded, and deleting it would hand the critical section to two
|
|
@@ -20,15 +21,22 @@
|
|
|
20
21
|
* is handled by the per-target byte checks in the write path, and the rename
|
|
21
22
|
* window itself is documented as irreducible from user space.
|
|
22
23
|
*/
|
|
23
|
-
import {
|
|
24
|
+
import { readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { dirname } from "node:path";
|
|
26
|
+
import { checkEntry, guarded, type Entry } from "./prompt-lock-evidence";
|
|
24
27
|
import { randomBytes } from "node:crypto";
|
|
28
|
+
import { UnsafeLockNamespace, withLockClaim } from "./prompt-lock-claim";
|
|
29
|
+
|
|
30
|
+
import { ownerDefaults, ownEvidence, ownerState, safeNamespace, type OwnerDeps, type OwnerEvidence } from "./prompt-lock-owner";
|
|
31
|
+
|
|
32
|
+
import { LockFileBusy, lockFileOperation } from "./prompt-lock-io";
|
|
25
33
|
|
|
26
34
|
const FILE_MODE = 0o600;
|
|
27
35
|
|
|
28
36
|
/** A lock younger than this is respected even if its pid looks gone. */
|
|
29
37
|
export const STALE_AFTER_MS = 10_000;
|
|
30
38
|
|
|
31
|
-
export interface LockRecord {
|
|
39
|
+
export interface LockRecord extends OwnerEvidence {
|
|
32
40
|
token: string;
|
|
33
41
|
pid: number;
|
|
34
42
|
acquiredAt: number;
|
|
@@ -41,83 +49,100 @@ export interface LockHandle {
|
|
|
41
49
|
|
|
42
50
|
export type AcquireResult =
|
|
43
51
|
| { ok: true; handle: LockHandle }
|
|
44
|
-
| { ok: false; error: "locked" }
|
|
52
|
+
| { ok: false; error: "locked" }
|
|
53
|
+
| { ok: false; error: "unsafe"; detail: string };
|
|
45
54
|
|
|
46
|
-
export interface LockDeps {
|
|
47
|
-
|
|
48
|
-
isProcessAlive: (pid: number) => boolean;
|
|
55
|
+
export interface LockDeps extends Partial<OwnerDeps> {
|
|
56
|
+
isProcessAlive: (pid: number) => boolean | undefined;
|
|
49
57
|
now: () => number;
|
|
58
|
+
onClaimInitialized?: () => void;
|
|
50
59
|
}
|
|
60
|
+
const defaultDeps: LockDeps = { ...ownerDefaults, now: () => Date.now() };
|
|
51
61
|
|
|
52
|
-
|
|
53
|
-
isProcessAlive: pid => {
|
|
54
|
-
try {
|
|
55
|
-
process.kill(pid, 0);
|
|
56
|
-
return true;
|
|
57
|
-
} catch (error) {
|
|
58
|
-
// EPERM means it exists but belongs to another user.
|
|
59
|
-
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
60
|
-
}
|
|
61
|
-
},
|
|
62
|
-
now: () => Date.now(),
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
function readRecord(path: string): LockRecord | null {
|
|
62
|
+
function readRecord(path: string, platform: NodeJS.Platform): LockRecord | null {
|
|
66
63
|
try {
|
|
67
|
-
const parsed = JSON.parse(readFileSync(path, "utf8")) as LockRecord;
|
|
64
|
+
const parsed = JSON.parse(lockFileOperation(() => readFileSync(path, "utf8"), platform)) as LockRecord;
|
|
68
65
|
if (typeof parsed?.token !== "string" || typeof parsed?.pid !== "number") return null;
|
|
69
66
|
return parsed;
|
|
70
|
-
} catch {
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error instanceof LockFileBusy) throw error;
|
|
71
69
|
return null;
|
|
72
70
|
}
|
|
73
71
|
}
|
|
74
72
|
|
|
75
|
-
/**
|
|
76
|
-
function isStale(record: LockRecord | null, deps: LockDeps): boolean {
|
|
77
|
-
if (record === null) return true; // unparseable: treat as debris
|
|
78
|
-
if (deps.isProcessAlive(record.pid)) return false;
|
|
79
|
-
return deps.now() - record.acquiredAt > STALE_AFTER_MS;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* One attempt. The caller decides whether to retry — a loser must go back to
|
|
84
|
-
* re-reading the lock rather than assuming its quarantine still applies.
|
|
85
|
-
*/
|
|
73
|
+
/** One attempt, including namespace validation and serialized stale observation. */
|
|
86
74
|
export function tryAcquire(path: string, deps: LockDeps = defaultDeps): AcquireResult {
|
|
75
|
+
const resolved = { ...ownerDefaults, ...deps };
|
|
87
76
|
const token = randomBytes(8).toString("hex");
|
|
88
|
-
|
|
77
|
+
try {
|
|
78
|
+
if (!safeNamespace(path, "file", resolved, true)) throw new UnsafeLockNamespace(path);
|
|
79
|
+
const reserved = withLockClaim(path, token, resolved,
|
|
80
|
+
() => acquireReserved(path, token, resolved), deps.onClaimInitialized);
|
|
81
|
+
return reserved.ok ? reserved.value : { ok: false, error: "locked" };
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error instanceof LockFileBusy) return { ok: false, error: "locked" };
|
|
84
|
+
if (error instanceof UnsafeLockNamespace) return { ok: false, error: "unsafe", detail: error.path };
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function acquireReserved(path: string, token: string, deps: OwnerDeps & LockDeps): AcquireResult {
|
|
90
|
+
const parentPath = dirname(path);
|
|
91
|
+
if (!safeNamespace(parentPath, "directory", deps)) throw new UnsafeLockNamespace(parentPath);
|
|
92
|
+
const parents = [{ path: parentPath, stat: lockFileOperation(() => deps.lstat(parentPath), deps.platform) }];
|
|
93
|
+
const io = <R>(operation: () => R): R => guarded(parents, [], deps, operation);
|
|
94
|
+
const record: LockRecord = { token, ...ownEvidence(deps), acquiredAt: deps.now() };
|
|
89
95
|
const body = JSON.stringify(record);
|
|
90
96
|
|
|
91
97
|
try {
|
|
92
|
-
writeFileSync(path, body, { encoding: "utf8", mode: FILE_MODE, flag: "wx" });
|
|
98
|
+
io(() => writeFileSync(path, body, { encoding: "utf8", mode: FILE_MODE, flag: "wx" }));
|
|
93
99
|
return { ok: true, handle: { path, token } };
|
|
94
100
|
} catch (error) {
|
|
95
101
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
96
102
|
}
|
|
97
103
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
const observed = io(() => {
|
|
105
|
+
const entry: Entry = { path, stat: deps.lstat(path), body: readFileSync(path, "utf8") };
|
|
106
|
+
checkEntry(entry, deps);
|
|
107
|
+
return entry;
|
|
108
|
+
});
|
|
109
|
+
let previous: LockRecord | null;
|
|
110
|
+
// Same shape gate as readRecord: a malformed record is evidence, never a takeover target.
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(observed.body) as LockRecord;
|
|
113
|
+
previous = typeof parsed?.token === "string" && typeof parsed?.pid === "number" ? parsed : null;
|
|
114
|
+
} catch { previous = null; }
|
|
115
|
+
const state = ownerState(previous, deps);
|
|
116
|
+
if (state === "unsafe") return { ok: false, error: "unsafe", detail: path };
|
|
117
|
+
if (state !== "dead" || !previous || !Number.isFinite(previous.acquiredAt)
|
|
118
|
+
|| deps.now() - previous.acquiredAt <= STALE_AFTER_MS) return { ok: false, error: "locked" };
|
|
119
|
+
|
|
120
|
+
// The reservation excludes cooperating writers; fence external replacements
|
|
121
|
+
// and parent retargets again on every sharing-error retry.
|
|
101
122
|
const quarantine = `${path}.stale-${token}`;
|
|
102
123
|
try {
|
|
103
|
-
renameSync(path, quarantine);
|
|
124
|
+
guarded(parents, [observed], deps, () => renameSync(path, quarantine));
|
|
104
125
|
} catch {
|
|
105
126
|
// Someone else won the rename, or the owner released between our checks.
|
|
106
127
|
// Either way we do NOT touch the path — retry from the top.
|
|
107
128
|
return { ok: false, error: "locked" };
|
|
108
129
|
}
|
|
109
130
|
|
|
131
|
+
const moved = { ...observed, path: quarantine };
|
|
132
|
+
const cleanup = () => {
|
|
133
|
+
try { guarded(parents, [moved], deps, () => unlinkSync(quarantine)); } catch { /* Preserve changed or busy debris. */ }
|
|
134
|
+
};
|
|
110
135
|
try {
|
|
111
|
-
writeFileSync(path, body, { encoding: "utf8", mode: FILE_MODE, flag: "wx" });
|
|
136
|
+
io(() => writeFileSync(path, body, { encoding: "utf8", mode: FILE_MODE, flag: "wx" }));
|
|
112
137
|
} catch (error) {
|
|
113
138
|
// A successor acquired the real lock between our rename and this create.
|
|
114
139
|
// Its lock is live and is not ours to remove.
|
|
115
|
-
|
|
140
|
+
cleanup();
|
|
116
141
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") return { ok: false, error: "locked" };
|
|
117
142
|
throw error;
|
|
118
143
|
}
|
|
119
144
|
|
|
120
|
-
|
|
145
|
+
cleanup();
|
|
121
146
|
return { ok: true, handle: { path, token } };
|
|
122
147
|
}
|
|
123
148
|
|
|
@@ -126,18 +151,24 @@ export function tryAcquire(path: string, deps: LockDeps = defaultDeps): AcquireR
|
|
|
126
151
|
* when we were superseded, which the caller surfaces as `write_superseded`.
|
|
127
152
|
*/
|
|
128
153
|
export function release(handle: LockHandle): boolean {
|
|
129
|
-
const record = readRecord(handle.path);
|
|
130
|
-
if (record === null || record.token !== handle.token) return false;
|
|
131
154
|
try {
|
|
132
|
-
|
|
133
|
-
return
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
155
|
+
const deps = ownerDefaults;
|
|
156
|
+
if (!safeNamespace(handle.path, "file", deps) || !safeNamespace(dirname(handle.path), "directory", deps)) return false;
|
|
157
|
+
const parents = [{ path: dirname(handle.path), stat: lockFileOperation(() => deps.lstat(dirname(handle.path)), deps.platform) }];
|
|
158
|
+
const entry = guarded(parents, [], deps, () => {
|
|
159
|
+
const saved = { path: handle.path, stat: deps.lstat(handle.path), body: readFileSync(handle.path, "utf8") };
|
|
160
|
+
checkEntry(saved, deps);
|
|
161
|
+
return saved;
|
|
162
|
+
});
|
|
163
|
+
if (JSON.parse(entry.body)?.token !== handle.token) return false;
|
|
164
|
+
return guarded(parents, [entry], deps, () => { unlinkSync(handle.path); return true; });
|
|
165
|
+
} catch { return false; }
|
|
137
166
|
}
|
|
138
167
|
|
|
139
168
|
/** True when the on-disk lock is still the one this handle acquired. */
|
|
140
169
|
export function stillHeld(handle: LockHandle): boolean {
|
|
141
|
-
|
|
142
|
-
|
|
170
|
+
try {
|
|
171
|
+
return safeNamespace(handle.path, "file", ownerDefaults)
|
|
172
|
+
&& readRecord(handle.path, ownerDefaults.platform)?.token === handle.token;
|
|
173
|
+
} catch { return false; }
|
|
143
174
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { pinnedCodexAccountId } from "../account-priority";
|
|
2
|
+
import type { OcxConfig } from "../../types";
|
|
3
|
+
import {
|
|
4
|
+
deleteAccountHealth,
|
|
5
|
+
setAccountHealth,
|
|
6
|
+
type CodexUpstreamHealth,
|
|
7
|
+
} from "./health-store";
|
|
8
|
+
|
|
9
|
+
/** Sliding window for transient upstream faults. Distinct from the 5-minute consecutive-streak gap. */
|
|
10
|
+
export const CODEX_SLIDING_FAILURE_WINDOW_MS = 60_000;
|
|
11
|
+
export const CODEX_SLIDING_FAILURE_MIN_SAMPLES = 20;
|
|
12
|
+
export const CODEX_SLIDING_FAILURE_DEGRADE_RATIO = 0.25;
|
|
13
|
+
export const CODEX_SLIDING_FAILURE_RECOVER_RATIO = 0.1;
|
|
14
|
+
export const CODEX_SLIDING_FAILURE_RECOVER_SUSTAIN_MS = 30_000;
|
|
15
|
+
const MAX_SAMPLES = 512;
|
|
16
|
+
|
|
17
|
+
type Sample = { at: number; failed: boolean };
|
|
18
|
+
type WindowState = { samples: Sample[]; degraded: boolean; healthySince?: number };
|
|
19
|
+
|
|
20
|
+
const windows = new Map<string, WindowState>();
|
|
21
|
+
const pinnedDegradeWarned = new Set<string>();
|
|
22
|
+
|
|
23
|
+
export function codexFailureWindowEnabled(config: OcxConfig): boolean {
|
|
24
|
+
return config.codexFailureWindow !== false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stateFor(accountId: string): WindowState {
|
|
28
|
+
let state = windows.get(accountId);
|
|
29
|
+
if (!state) {
|
|
30
|
+
state = { samples: [], degraded: false };
|
|
31
|
+
windows.set(accountId, state);
|
|
32
|
+
}
|
|
33
|
+
return state;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function recompute(state: WindowState, now: number): void {
|
|
37
|
+
const cutoff = now - CODEX_SLIDING_FAILURE_WINDOW_MS;
|
|
38
|
+
if (state.samples.length > 0 && state.samples[0]!.at <= cutoff) {
|
|
39
|
+
state.samples = state.samples.filter(sample => sample.at > cutoff);
|
|
40
|
+
}
|
|
41
|
+
const samples = state.samples.length;
|
|
42
|
+
const failed = state.samples.reduce((count, sample) => count + (sample.failed ? 1 : 0), 0);
|
|
43
|
+
const degraded = samples >= CODEX_SLIDING_FAILURE_MIN_SAMPLES
|
|
44
|
+
&& failed * 4 >= samples;
|
|
45
|
+
const recovering = state.degraded
|
|
46
|
+
&& samples >= CODEX_SLIDING_FAILURE_MIN_SAMPLES
|
|
47
|
+
&& failed * 10 <= samples;
|
|
48
|
+
if (degraded) {
|
|
49
|
+
state.degraded = true;
|
|
50
|
+
state.healthySince = undefined;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!state.degraded) {
|
|
54
|
+
state.healthySince = undefined;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!recovering) {
|
|
58
|
+
state.healthySince = undefined;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
state.healthySince ??= now;
|
|
62
|
+
if (now - state.healthySince >= CODEX_SLIDING_FAILURE_RECOVER_SUSTAIN_MS) {
|
|
63
|
+
state.degraded = false;
|
|
64
|
+
state.healthySince = undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Record one terminal success or transient fault. Quota and caller errors are not samples. */
|
|
69
|
+
export function noteCodexFailureWindowSample(
|
|
70
|
+
config: OcxConfig,
|
|
71
|
+
accountId: string,
|
|
72
|
+
failed: boolean,
|
|
73
|
+
now: number,
|
|
74
|
+
): void {
|
|
75
|
+
if (!codexFailureWindowEnabled(config)) return;
|
|
76
|
+
const state = stateFor(accountId);
|
|
77
|
+
state.samples.push({ at: now, failed });
|
|
78
|
+
if (state.samples.length > MAX_SAMPLES) state.samples.splice(0, state.samples.length - MAX_SAMPLES);
|
|
79
|
+
recompute(state, now);
|
|
80
|
+
if (!state.degraded) pinnedDegradeWarned.delete(accountId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isCodexFailureWindowDegraded(accountId: string, now = Date.now()): boolean {
|
|
84
|
+
const state = windows.get(accountId);
|
|
85
|
+
if (!state) return false;
|
|
86
|
+
recompute(state, now);
|
|
87
|
+
if (!state.degraded) pinnedDegradeWarned.delete(accountId);
|
|
88
|
+
return state.degraded;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Unbound placement may leave a degraded account. A manual pin stays unless the operator
|
|
93
|
+
* opts into detouring new threads. Bound threads never consult this predicate.
|
|
94
|
+
*/
|
|
95
|
+
export function failureWindowSteersNewThreads(
|
|
96
|
+
config: OcxConfig,
|
|
97
|
+
accountId: string,
|
|
98
|
+
now: number,
|
|
99
|
+
): boolean {
|
|
100
|
+
if (!codexFailureWindowEnabled(config)) return false;
|
|
101
|
+
if (!isCodexFailureWindowDegraded(accountId, now)) return false;
|
|
102
|
+
if (pinnedCodexAccountId(config) !== accountId) return true;
|
|
103
|
+
if (config.codexPinnedTransientPolicy === "detour-new-threads") return true;
|
|
104
|
+
if (!pinnedDegradeWarned.has(accountId)) {
|
|
105
|
+
pinnedDegradeWarned.add(accountId);
|
|
106
|
+
console.warn("[opencodex] pinned Codex account is degraded; new threads stay on it until it recovers");
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function forgetCodexFailureWindow(accountId: string): void {
|
|
112
|
+
windows.delete(accountId);
|
|
113
|
+
pinnedDegradeWarned.delete(accountId);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function forgetCodexFailureWindows(): void {
|
|
117
|
+
windows.clear();
|
|
118
|
+
pinnedDegradeWarned.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Consecutive-success bookkeeping for a healthy terminal, moved out of the routing module. */
|
|
122
|
+
export function settleCodexSuccessStreak(
|
|
123
|
+
config: OcxConfig,
|
|
124
|
+
accountId: string,
|
|
125
|
+
current: CodexUpstreamHealth | undefined,
|
|
126
|
+
base: CodexUpstreamHealth | undefined,
|
|
127
|
+
preserved: Partial<CodexUpstreamHealth>,
|
|
128
|
+
cooldownUntil: number | null,
|
|
129
|
+
): void {
|
|
130
|
+
const failoverEnabled = (config.upstreamFailoverThreshold ?? 3) > 0;
|
|
131
|
+
if (failoverEnabled && current && current.consecutiveFailures >= 2) {
|
|
132
|
+
const consecutiveSuccesses = (current.consecutiveSuccesses ?? 0) + 1;
|
|
133
|
+
if (consecutiveSuccesses < 2) {
|
|
134
|
+
setAccountHealth(accountId, {
|
|
135
|
+
...base!,
|
|
136
|
+
...preserved,
|
|
137
|
+
consecutiveSuccesses,
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Level 1 clears immediately; escalated accounts need two consecutive healthy terminals.
|
|
143
|
+
// Hard quota cooldown intentionally survives either recovery path.
|
|
144
|
+
if (cooldownUntil) setAccountHealth(accountId, { consecutiveFailures: 0, ...preserved });
|
|
145
|
+
else deleteAccountHealth(accountId);
|
|
146
|
+
}
|