@cleocode/core 2026.6.12 → 2026.6.13
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/dist/conduit/local-transport.d.ts +12 -0
- package/dist/conduit/local-transport.d.ts.map +1 -1
- package/dist/conduit/local-transport.js +53 -23
- package/dist/conduit/local-transport.js.map +1 -1
- package/dist/crypto/credentials.d.ts +37 -0
- package/dist/crypto/credentials.d.ts.map +1 -1
- package/dist/crypto/credentials.js +110 -0
- package/dist/crypto/credentials.js.map +1 -1
- package/dist/dispatch/contracts/derive-output-contract.d.ts +86 -0
- package/dist/dispatch/contracts/derive-output-contract.d.ts.map +1 -0
- package/dist/dispatch/contracts/derive-output-contract.js +347 -0
- package/dist/dispatch/contracts/derive-output-contract.js.map +1 -0
- package/dist/dispatch/contracts/ensures-schema.d.ts +77 -0
- package/dist/dispatch/contracts/ensures-schema.d.ts.map +1 -0
- package/dist/dispatch/contracts/ensures-schema.js +91 -0
- package/dist/dispatch/contracts/ensures-schema.js.map +1 -0
- package/dist/dispatch/contracts/output-contracts.d.ts +27 -5
- package/dist/dispatch/contracts/output-contracts.d.ts.map +1 -1
- package/dist/dispatch/contracts/output-contracts.js +29 -6
- package/dist/dispatch/contracts/output-contracts.js.map +1 -1
- package/dist/docs/export-document.js +2294 -1250
- package/dist/docs/export-document.js.map +3 -3
- package/dist/go/driver.d.ts +132 -7
- package/dist/go/driver.d.ts.map +1 -1
- package/dist/go/driver.js +135 -10
- package/dist/go/driver.js.map +1 -1
- package/dist/go/index.d.ts +1 -1
- package/dist/go/index.d.ts.map +1 -1
- package/dist/go/index.js +1 -1
- package/dist/go/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +13 -10
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +11 -11
- package/dist/internal.js.map +1 -1
- package/dist/lifecycle/engine-ops.d.ts.map +1 -1
- package/dist/lifecycle/engine-ops.js +7 -4
- package/dist/lifecycle/engine-ops.js.map +1 -1
- package/dist/lifecycle/ivtr-loop.d.ts +108 -183
- package/dist/lifecycle/ivtr-loop.d.ts.map +1 -1
- package/dist/lifecycle/ivtr-loop.js +207 -522
- package/dist/lifecycle/ivtr-loop.js.map +1 -1
- package/dist/llm/auxiliary-fallback.js +1 -1
- package/dist/llm/auxiliary-fallback.js.map +1 -1
- package/dist/llm/cli-ops.d.ts +14 -1
- package/dist/llm/cli-ops.d.ts.map +1 -1
- package/dist/llm/cli-ops.js +41 -12
- package/dist/llm/cli-ops.js.map +1 -1
- package/dist/llm/concrete-session.d.ts.map +1 -1
- package/dist/llm/concrete-session.js +4 -0
- package/dist/llm/concrete-session.js.map +1 -1
- package/dist/llm/credentials.d.ts +35 -0
- package/dist/llm/credentials.d.ts.map +1 -1
- package/dist/llm/credentials.js +49 -0
- package/dist/llm/credentials.js.map +1 -1
- package/dist/llm/index.d.ts +4 -0
- package/dist/llm/index.d.ts.map +1 -1
- package/dist/llm/index.js +4 -0
- package/dist/llm/index.js.map +1 -1
- package/dist/llm/pi/gondolin-loader.d.ts +241 -0
- package/dist/llm/pi/gondolin-loader.d.ts.map +1 -0
- package/dist/llm/pi/gondolin-loader.js +195 -0
- package/dist/llm/pi/gondolin-loader.js.map +1 -0
- package/dist/llm/pi/index.d.ts +28 -0
- package/dist/llm/pi/index.d.ts.map +1 -0
- package/dist/llm/pi/index.js +27 -0
- package/dist/llm/pi/index.js.map +1 -0
- package/dist/llm/pi/pi-agent-adapter.d.ts +135 -0
- package/dist/llm/pi/pi-agent-adapter.d.ts.map +1 -0
- package/dist/llm/pi/pi-agent-adapter.js +329 -0
- package/dist/llm/pi/pi-agent-adapter.js.map +1 -0
- package/dist/llm/pi/pi-errors.d.ts +161 -0
- package/dist/llm/pi/pi-errors.d.ts.map +1 -0
- package/dist/llm/pi/pi-errors.js +296 -0
- package/dist/llm/pi/pi-errors.js.map +1 -0
- package/dist/llm/pi/pi-execution-env.d.ts +286 -0
- package/dist/llm/pi/pi-execution-env.d.ts.map +1 -0
- package/dist/llm/pi/pi-execution-env.js +406 -0
- package/dist/llm/pi/pi-execution-env.js.map +1 -0
- package/dist/llm/pi/pi-gondolin-env.d.ts +227 -0
- package/dist/llm/pi/pi-gondolin-env.d.ts.map +1 -0
- package/dist/llm/pi/pi-gondolin-env.js +541 -0
- package/dist/llm/pi/pi-gondolin-env.js.map +1 -0
- package/dist/llm/pi/pi-session-storage.d.ts +154 -0
- package/dist/llm/pi/pi-session-storage.d.ts.map +1 -0
- package/dist/llm/pi/pi-session-storage.js +314 -0
- package/dist/llm/pi/pi-session-storage.js.map +1 -0
- package/dist/llm/pi/pi-stream-fn.d.ts +124 -0
- package/dist/llm/pi/pi-stream-fn.d.ts.map +1 -0
- package/dist/llm/pi/pi-stream-fn.js +504 -0
- package/dist/llm/pi/pi-stream-fn.js.map +1 -0
- package/dist/llm/pi/pi-tool-bridge.d.ts +99 -0
- package/dist/llm/pi/pi-tool-bridge.d.ts.map +1 -0
- package/dist/llm/pi/pi-tool-bridge.js +117 -0
- package/dist/llm/pi/pi-tool-bridge.js.map +1 -0
- package/dist/llm/pi/pi-types.d.ts +56 -0
- package/dist/llm/pi/pi-types.d.ts.map +1 -0
- package/dist/llm/pi/pi-types.js +16 -0
- package/dist/llm/pi/pi-types.js.map +1 -0
- package/dist/llm/pi/resolve-execution-env.d.ts +194 -0
- package/dist/llm/pi/resolve-execution-env.d.ts.map +1 -0
- package/dist/llm/pi/resolve-execution-env.js +132 -0
- package/dist/llm/pi/resolve-execution-env.js.map +1 -0
- package/dist/llm/plugin-facade.js +2253 -1151
- package/dist/llm/plugin-facade.js.map +3 -3
- package/dist/llm/role-executor.d.ts.map +1 -1
- package/dist/llm/role-executor.js +8 -5
- package/dist/llm/role-executor.js.map +1 -1
- package/dist/llm/role-resolver.d.ts +46 -14
- package/dist/llm/role-resolver.d.ts.map +1 -1
- package/dist/llm/role-resolver.js +178 -20
- package/dist/llm/role-resolver.js.map +1 -1
- package/dist/llm/sealed-credential.d.ts +125 -0
- package/dist/llm/sealed-credential.d.ts.map +1 -0
- package/dist/llm/sealed-credential.js +133 -0
- package/dist/llm/sealed-credential.js.map +1 -0
- package/dist/llm/session-factory.d.ts.map +1 -1
- package/dist/llm/session-factory.js +6 -2
- package/dist/llm/session-factory.js.map +1 -1
- package/dist/llm/system-key.d.ts +67 -0
- package/dist/llm/system-key.d.ts.map +1 -0
- package/dist/llm/system-key.js +87 -0
- package/dist/llm/system-key.js.map +1 -0
- package/dist/llm/system-of-use-registry.d.ts +119 -0
- package/dist/llm/system-of-use-registry.d.ts.map +1 -0
- package/dist/llm/system-of-use-registry.js +200 -0
- package/dist/llm/system-of-use-registry.js.map +1 -0
- package/dist/llm/system-resolver.d.ts +32 -10
- package/dist/llm/system-resolver.d.ts.map +1 -1
- package/dist/llm/system-resolver.js +70 -18
- package/dist/llm/system-resolver.js.map +1 -1
- package/dist/memory/brain-writer-thread.d.ts.map +1 -1
- package/dist/memory/brain-writer-thread.js +116 -11
- package/dist/memory/brain-writer-thread.js.map +1 -1
- package/dist/memory/claude-mem-migration.d.ts.map +1 -1
- package/dist/memory/claude-mem-migration.js +21 -0
- package/dist/memory/claude-mem-migration.js.map +1 -1
- package/dist/memory/graph-memory-bridge.d.ts.map +1 -1
- package/dist/memory/graph-memory-bridge.js +32 -25
- package/dist/memory/graph-memory-bridge.js.map +1 -1
- package/dist/memory/llm-backend-resolver.js +23 -5
- package/dist/memory/llm-backend-resolver.js.map +1 -1
- package/dist/nexus/migrations/M-split-graph.d.ts.map +1 -1
- package/dist/nexus/migrations/M-split-graph.js +60 -53
- package/dist/nexus/migrations/M-split-graph.js.map +1 -1
- package/dist/orchestrate/pivot.d.ts.map +1 -1
- package/dist/orchestrate/pivot.js +20 -2
- package/dist/orchestrate/pivot.js.map +1 -1
- package/dist/release/engine-ops.js +2 -2
- package/dist/release/engine-ops.js.map +1 -1
- package/dist/routing/capability-matrix.d.ts.map +1 -1
- package/dist/routing/capability-matrix.js +0 -57
- package/dist/routing/capability-matrix.js.map +1 -1
- package/dist/selfimprove/budget.d.ts +206 -0
- package/dist/selfimprove/budget.d.ts.map +1 -0
- package/dist/selfimprove/budget.js +203 -0
- package/dist/selfimprove/budget.js.map +1 -0
- package/dist/selfimprove/dhq-adapter.d.ts +105 -0
- package/dist/selfimprove/dhq-adapter.d.ts.map +1 -0
- package/dist/selfimprove/dhq-adapter.js +95 -0
- package/dist/selfimprove/dhq-adapter.js.map +1 -0
- package/dist/selfimprove/draft-pr.d.ts +129 -0
- package/dist/selfimprove/draft-pr.d.ts.map +1 -0
- package/dist/selfimprove/draft-pr.js +158 -0
- package/dist/selfimprove/draft-pr.js.map +1 -0
- package/dist/selfimprove/envelope-diff.d.ts +162 -0
- package/dist/selfimprove/envelope-diff.d.ts.map +1 -0
- package/dist/selfimprove/envelope-diff.js +321 -0
- package/dist/selfimprove/envelope-diff.js.map +1 -0
- package/dist/selfimprove/replay.d.ts +121 -0
- package/dist/selfimprove/replay.d.ts.map +1 -0
- package/dist/selfimprove/replay.js +109 -0
- package/dist/selfimprove/replay.js.map +1 -0
- package/dist/selfimprove/run-loop.d.ts +148 -0
- package/dist/selfimprove/run-loop.d.ts.map +1 -0
- package/dist/selfimprove/run-loop.js +240 -0
- package/dist/selfimprove/run-loop.js.map +1 -0
- package/dist/selfimprove/scenario.d.ts +145 -0
- package/dist/selfimprove/scenario.d.ts.map +1 -0
- package/dist/selfimprove/scenario.js +243 -0
- package/dist/selfimprove/scenario.js.map +1 -0
- package/dist/sentient/daemon-entry.js +5 -0
- package/dist/sentient/daemon-entry.js.map +1 -1
- package/dist/sentient/daemon.d.ts.map +1 -1
- package/dist/sentient/daemon.js +19 -0
- package/dist/sentient/daemon.js.map +1 -1
- package/dist/sentient/hygiene-scan.d.ts.map +1 -1
- package/dist/sentient/hygiene-scan.js +14 -6
- package/dist/sentient/hygiene-scan.js.map +1 -1
- package/dist/sessions/briefing.d.ts.map +1 -1
- package/dist/sessions/briefing.js +13 -9
- package/dist/sessions/briefing.js.map +1 -1
- package/dist/sessions/connection-session-handle.d.ts +161 -0
- package/dist/sessions/connection-session-handle.d.ts.map +1 -0
- package/dist/sessions/connection-session-handle.js +168 -0
- package/dist/sessions/connection-session-handle.js.map +1 -0
- package/dist/sessions/handoff.d.ts.map +1 -1
- package/dist/sessions/handoff.js +9 -0
- package/dist/sessions/handoff.js.map +1 -1
- package/dist/sessions/index.d.ts +4 -1
- package/dist/sessions/index.d.ts.map +1 -1
- package/dist/sessions/index.js +31 -1
- package/dist/sessions/index.js.map +1 -1
- package/dist/sessions/session-id.d.ts +34 -0
- package/dist/sessions/session-id.d.ts.map +1 -1
- package/dist/sessions/session-id.js +37 -0
- package/dist/sessions/session-id.js.map +1 -1
- package/dist/sessions/session-manifest-mirror.d.ts +115 -0
- package/dist/sessions/session-manifest-mirror.d.ts.map +1 -0
- package/dist/sessions/session-manifest-mirror.js +250 -0
- package/dist/sessions/session-manifest-mirror.js.map +1 -0
- package/dist/sessions/session-switch.d.ts.map +1 -1
- package/dist/sessions/session-switch.js +5 -2
- package/dist/sessions/session-switch.js.map +1 -1
- package/dist/store/converters.d.ts.map +1 -1
- package/dist/store/converters.js +2 -0
- package/dist/store/converters.js.map +1 -1
- package/dist/store/db-helpers.d.ts.map +1 -1
- package/dist/store/db-helpers.js +2 -0
- package/dist/store/db-helpers.js.map +1 -1
- package/dist/store/dual-scope-db.d.ts.map +1 -1
- package/dist/store/dual-scope-db.js +87 -47
- package/dist/store/dual-scope-db.js.map +1 -1
- package/dist/store/pi-session-store.d.ts +151 -0
- package/dist/store/pi-session-store.d.ts.map +1 -0
- package/dist/store/pi-session-store.js +166 -0
- package/dist/store/pi-session-store.js.map +1 -0
- package/dist/store/safety-data-accessor.d.ts +1 -0
- package/dist/store/safety-data-accessor.d.ts.map +1 -1
- package/dist/store/safety-data-accessor.js +5 -0
- package/dist/store/safety-data-accessor.js.map +1 -1
- package/dist/store/schema/cleo-global/accounts.d.ts +357 -0
- package/dist/store/schema/cleo-global/accounts.d.ts.map +1 -0
- package/dist/store/schema/cleo-global/accounts.js +136 -0
- package/dist/store/schema/cleo-global/accounts.js.map +1 -0
- package/dist/store/schema/cleo-global/index.d.ts +2 -0
- package/dist/store/schema/cleo-global/index.d.ts.map +1 -1
- package/dist/store/schema/cleo-global/index.js +2 -0
- package/dist/store/schema/cleo-global/index.js.map +1 -1
- package/dist/store/schema/cleo-global/session-manifest.d.ts +217 -0
- package/dist/store/schema/cleo-global/session-manifest.d.ts.map +1 -0
- package/dist/store/schema/cleo-global/session-manifest.js +99 -0
- package/dist/store/schema/cleo-global/session-manifest.js.map +1 -0
- package/dist/store/schema/cleo-project/tasks-core.d.ts +16 -0
- package/dist/store/schema/cleo-project/tasks-core.d.ts.map +1 -1
- package/dist/store/schema/cleo-project/tasks-core.js +14 -0
- package/dist/store/schema/cleo-project/tasks-core.js.map +1 -1
- package/dist/store/schema/tasks.d.ts +16 -0
- package/dist/store/schema/tasks.d.ts.map +1 -1
- package/dist/store/schema/tasks.js +10 -0
- package/dist/store/schema/tasks.js.map +1 -1
- package/dist/store/selfimprove-dhq-schema.d.ts +284 -0
- package/dist/store/selfimprove-dhq-schema.d.ts.map +1 -0
- package/dist/store/selfimprove-dhq-schema.js +128 -0
- package/dist/store/selfimprove-dhq-schema.js.map +1 -0
- package/dist/store/selfimprove-dhq-store.d.ts +175 -0
- package/dist/store/selfimprove-dhq-store.d.ts.map +1 -0
- package/dist/store/selfimprove-dhq-store.js +182 -0
- package/dist/store/selfimprove-dhq-store.js.map +1 -0
- package/dist/store/session-store.d.ts +52 -13
- package/dist/store/session-store.d.ts.map +1 -1
- package/dist/store/session-store.js +71 -13
- package/dist/store/session-store.js.map +1 -1
- package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
- package/dist/store/sqlite-data-accessor.js +7 -0
- package/dist/store/sqlite-data-accessor.js.map +1 -1
- package/dist/store/umbrella-data-accessor.d.ts +1 -0
- package/dist/store/umbrella-data-accessor.d.ts.map +1 -1
- package/dist/store/umbrella-data-accessor.js +5 -0
- package/dist/store/umbrella-data-accessor.js.map +1 -1
- package/dist/store/validation-schemas.d.ts +32 -0
- package/dist/store/validation-schemas.d.ts.map +1 -1
- package/dist/store/writer-lease-schema.d.ts +392 -0
- package/dist/store/writer-lease-schema.d.ts.map +1 -0
- package/dist/store/writer-lease-schema.js +142 -0
- package/dist/store/writer-lease-schema.js.map +1 -0
- package/dist/store/writer-lease.d.ts +368 -0
- package/dist/store/writer-lease.d.ts.map +1 -0
- package/dist/store/writer-lease.js +1002 -0
- package/dist/store/writer-lease.js.map +1 -0
- package/dist/tasks/backfill-child-projections.d.ts.map +1 -1
- package/dist/tasks/backfill-child-projections.js +14 -9
- package/dist/tasks/backfill-child-projections.js.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +7 -10
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/duplicate-detector.d.ts.map +1 -1
- package/dist/tasks/duplicate-detector.js +14 -7
- package/dist/tasks/duplicate-detector.js.map +1 -1
- package/dist/telemetry/index.d.ts.map +1 -1
- package/dist/telemetry/index.js +10 -4
- package/dist/telemetry/index.js.map +1 -1
- package/dist/tools/agent-registry.d.ts +250 -0
- package/dist/tools/agent-registry.d.ts.map +1 -0
- package/dist/tools/agent-registry.js +292 -0
- package/dist/tools/agent-registry.js.map +1 -0
- package/dist/tools/agent-tool-families.d.ts +107 -0
- package/dist/tools/agent-tool-families.d.ts.map +1 -0
- package/dist/tools/agent-tool-families.js +527 -0
- package/dist/tools/agent-tool-families.js.map +1 -0
- package/dist/tools/browser-driver.d.ts +183 -0
- package/dist/tools/browser-driver.d.ts.map +1 -0
- package/dist/tools/browser-driver.js +242 -0
- package/dist/tools/browser-driver.js.map +1 -0
- package/dist/tools/builtin-agent-tools.d.ts +36 -0
- package/dist/tools/builtin-agent-tools.d.ts.map +1 -0
- package/dist/tools/builtin-agent-tools.js +150 -0
- package/dist/tools/builtin-agent-tools.js.map +1 -0
- package/dist/tools/dispatch.d.ts +370 -0
- package/dist/tools/dispatch.d.ts.map +1 -0
- package/dist/tools/dispatch.js +505 -0
- package/dist/tools/dispatch.js.map +1 -0
- package/dist/tools/env-scrub.d.ts +93 -0
- package/dist/tools/env-scrub.d.ts.map +1 -0
- package/dist/tools/env-scrub.js +176 -0
- package/dist/tools/env-scrub.js.map +1 -0
- package/dist/tools/fs.d.ts +30 -0
- package/dist/tools/fs.d.ts.map +1 -1
- package/dist/tools/fs.js +55 -2
- package/dist/tools/fs.js.map +1 -1
- package/dist/tools/guard.d.ts +15 -0
- package/dist/tools/guard.d.ts.map +1 -1
- package/dist/tools/guard.js +49 -12
- package/dist/tools/guard.js.map +1 -1
- package/dist/tools/index.d.ts +10 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +37 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/pty.d.ts +45 -0
- package/dist/tools/pty.d.ts.map +1 -0
- package/dist/tools/pty.js +166 -0
- package/dist/tools/pty.js.map +1 -0
- package/dist/tools/schema-gen.d.ts +57 -0
- package/dist/tools/schema-gen.d.ts.map +1 -0
- package/dist/tools/schema-gen.js +52 -0
- package/dist/tools/schema-gen.js.map +1 -0
- package/dist/tools/shell.d.ts +12 -0
- package/dist/tools/shell.d.ts.map +1 -1
- package/dist/tools/shell.js +14 -1
- package/dist/tools/shell.js.map +1 -1
- package/dist/tools/web-agent-tools.d.ts +72 -0
- package/dist/tools/web-agent-tools.d.ts.map +1 -0
- package/dist/tools/web-agent-tools.js +393 -0
- package/dist/tools/web-agent-tools.js.map +1 -0
- package/dist/tools/web-search-backends.d.ts +156 -0
- package/dist/tools/web-search-backends.d.ts.map +1 -0
- package/dist/tools/web-search-backends.js +290 -0
- package/dist/tools/web-search-backends.js.map +1 -0
- package/dist/validation/ops.d.ts +1 -15
- package/dist/validation/ops.d.ts.map +1 -1
- package/dist/validation/ops.js +0 -20
- package/dist/validation/ops.js.map +1 -1
- package/migrations/drizzle-cleo-global/20260607000000_t11891-writer-leases/migration.sql +55 -0
- package/migrations/drizzle-cleo-global/20260607010000_t11639-session-manifest/migration.sql +43 -0
- package/migrations/drizzle-cleo-global/20260608000000_t11899-pi-session-tree/migration.sql +52 -0
- package/migrations/drizzle-cleo-global/20260608010000_t11709-accounts-credential-pool/migration.sql +72 -0
- package/migrations/drizzle-cleo-global/20260608020000_t11889-selfimprove-dhq/migration.sql +64 -0
- package/migrations/drizzle-cleo-project/20260607000000_t11891-writer-leases/migration.sql +55 -0
- package/migrations/drizzle-cleo-project/20260607010000_t11639-session-parent/migration.sql +22 -0
- package/migrations/drizzle-cleo-project/20260608000000_t11899-pi-session-tree/migration.sql +52 -0
- package/migrations/drizzle-cleo-project/20260608020000_t11889-selfimprove-dhq/migration.sql +64 -0
- package/package.json +15 -13
- package/dist/lifecycle/chain-composition.d.ts +0 -30
- package/dist/lifecycle/chain-composition.d.ts.map +0 -1
- package/dist/lifecycle/chain-composition.js +0 -152
- package/dist/lifecycle/chain-composition.js.map +0 -1
- package/dist/lifecycle/chain-store.d.ts +0 -70
- package/dist/lifecycle/chain-store.d.ts.map +0 -1
- package/dist/lifecycle/chain-store.js +0 -246
- package/dist/lifecycle/chain-store.js.map +0 -1
- package/dist/lifecycle/default-chain.d.ts +0 -39
- package/dist/lifecycle/default-chain.d.ts.map +0 -1
- package/dist/lifecycle/default-chain.js +0 -176
- package/dist/lifecycle/default-chain.js.map +0 -1
- package/dist/lifecycle/tessera-engine.d.ts +0 -48
- package/dist/lifecycle/tessera-engine.d.ts.map +0 -1
- package/dist/lifecycle/tessera-engine.js +0 -249
- package/dist/lifecycle/tessera-engine.js.map +0 -1
- package/dist/validation/chain-validation.d.ts +0 -43
- package/dist/validation/chain-validation.d.ts.map +0 -1
- package/dist/validation/chain-validation.js +0 -146
- package/dist/validation/chain-validation.js.map +0 -1
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **DbWriterLease** — single in-process writer-lease arbitration over a persisted
|
|
3
|
+
* SQLite row (T11627 ST-2 · local-mode engine).
|
|
4
|
+
*
|
|
5
|
+
* Heals the T5158 multi-writer corruption (`E_NOT_INITIALIZED` / `E_INTERNAL` on
|
|
6
|
+
* the consolidated `cleo.db`) **while the supervisor daemon stays DISABLED in
|
|
7
|
+
* production** by serializing writers through a `BEGIN IMMEDIATE` claim transaction
|
|
8
|
+
* against a persisted `_writer_leases` row, fenced by an epoch-CAS. The supervisor
|
|
9
|
+
* (ST-5, daemon-on) and Node (this module, daemon-off) share the SAME arbitration
|
|
10
|
+
* primitive and row format — IPC is a coordination optimization over the persisted
|
|
11
|
+
* source-of-truth, never a second source.
|
|
12
|
+
*
|
|
13
|
+
* ## Modes ({@link resolveLeaseMode})
|
|
14
|
+
*
|
|
15
|
+
* `CLEO_WRITER_LEASE_MODE` ∈ `{ supervisor | local | off | require }`, default
|
|
16
|
+
* `local`:
|
|
17
|
+
*
|
|
18
|
+
* - `local` — Node arbitrates the row directly. No IPC. **Heals T5158 with the
|
|
19
|
+
* daemon off** (the shipping config). DEFAULT.
|
|
20
|
+
* - `supervisor` — would prefer IPC to the supervisor; in ST-2 (no IPC client
|
|
21
|
+
* wired) it transparently behaves as `local`, logging the demotion once.
|
|
22
|
+
* - `off` — pure pass-through: {@link withWriterLease} just runs `fn` under the
|
|
23
|
+
* existing `busy_timeout=30000`. Byte-identical to pre-lease behaviour. Rollback
|
|
24
|
+
* escape hatch.
|
|
25
|
+
* - `require` — strict: an acquire that cannot take the row throws
|
|
26
|
+
* `E_LEASE_UNAVAILABLE`. No fallback.
|
|
27
|
+
*
|
|
28
|
+
* `busy_timeout=30000` (SSoT in `specs/sqlite-pragmas.json`) backstops every
|
|
29
|
+
* `BEGIN IMMEDIATE`, so a contended claim degrades to today's bounded wait rather
|
|
30
|
+
* than a hang in any mode.
|
|
31
|
+
*
|
|
32
|
+
* ## Re-entrancy
|
|
33
|
+
*
|
|
34
|
+
* A process-local grant memo (`Map<\`${scope}::${lane}\`, { handle, refcount }>`)
|
|
35
|
+
* memoizes the active grant so a nested same-lane write in the SAME process shares
|
|
36
|
+
* one lease (`refcount++` + the row's `reentrancy_depth++`) instead of re-running
|
|
37
|
+
* the claim txn — no second `BEGIN IMMEDIATE`, no IPC. `release()` decrements; the
|
|
38
|
+
* row is freed (`active = 0`) at depth 0.
|
|
39
|
+
*
|
|
40
|
+
* ## DB Open Guard (Gate 3)
|
|
41
|
+
*
|
|
42
|
+
* This module lives under `packages/core/src/store/**`, which is inside the Gate-3
|
|
43
|
+
* raw-open allowlist (the DB chokepoint). It obtains the native handle via
|
|
44
|
+
* {@link openDualScopeDb} (no new raw `new DatabaseSync(`) and runs raw
|
|
45
|
+
* `BEGIN IMMEDIATE` claim/reclaim/release transactions on it.
|
|
46
|
+
*
|
|
47
|
+
* ## Cold-open seam (T11627 ST-3 · Seam 0 — the T5158 heal)
|
|
48
|
+
*
|
|
49
|
+
* {@link withColdOpenLease} leases the `dual-scope-db.ts` cold-open critical
|
|
50
|
+
* section (`reconcileJournal` + `migrateWithRetry` + the exodus-on-open hook)
|
|
51
|
+
* against the SAME native handle the open just created — it does NOT route through
|
|
52
|
+
* the default resolver (which would re-enter `openDualScopeDb` and recurse). It
|
|
53
|
+
* idempotently bootstraps the lease tables on that handle FIRST (the full
|
|
54
|
+
* migration that creates them runs INSIDE the leased section), so exactly one
|
|
55
|
+
* process per scope runs the migrate/reconcile write-txn while peers
|
|
56
|
+
* `BEGIN IMMEDIATE`-queue and then observe a ready DB. This is the heal that ships
|
|
57
|
+
* with the supervisor daemon disabled.
|
|
58
|
+
*
|
|
59
|
+
* @module
|
|
60
|
+
* @task T11627
|
|
61
|
+
* @epic T11625
|
|
62
|
+
* @see ./writer-lease-schema.ts — the drizzle table decls + bootstrap index assertion
|
|
63
|
+
* @see ./dual-scope-db.ts — the open chokepoint this engine routes through (Seams 0 & 1)
|
|
64
|
+
*/
|
|
65
|
+
import { randomUUID } from 'node:crypto';
|
|
66
|
+
import { getLogger } from '../logger.js';
|
|
67
|
+
import { openDualScopeDb, openDualScopeDbAtPath, resolveDualScopeDbPath } from './dual-scope-db.js';
|
|
68
|
+
import { assertWriterLeaseActiveIndexPresent, WRITER_LEASES_ACTIVE_INDEX, WRITER_LEASES_TABLE, WRITER_QUEUE_TABLE, } from './writer-lease-schema.js';
|
|
69
|
+
/** Default lease TTL — aligned to `busy_timeout=30000` (specs/sqlite-pragmas.json). */
|
|
70
|
+
export const DEFAULT_TTL_MS = 30_000;
|
|
71
|
+
/** Default advisory priority (lower acquires sooner). */
|
|
72
|
+
const DEFAULT_PRIORITY = 100;
|
|
73
|
+
/** The cold-open schema-bootstrap priority (highest). */
|
|
74
|
+
export const SCHEMA_BOOTSTRAP_PRIORITY = 0;
|
|
75
|
+
/** Max wall-clock a single acquire will spin before giving up (ms). */
|
|
76
|
+
const ACQUIRE_DEADLINE_MS = 30_000;
|
|
77
|
+
/** Backoff between contended claim retries (ms). */
|
|
78
|
+
const CLAIM_RETRY_DELAY_MS = 25;
|
|
79
|
+
/**
|
|
80
|
+
* Lazily-memoized module logger.
|
|
81
|
+
*
|
|
82
|
+
* Constructed on first use rather than at import time. A top-level
|
|
83
|
+
* `getLogger(...)` call executes the `'../logger.js'` factory during module
|
|
84
|
+
* initialization, which — when this module is pulled into a test's mocked
|
|
85
|
+
* import graph via `dual-scope-db.ts` → `writer-lease.ts` — reaches a
|
|
86
|
+
* `vi.mock('../../logger.js')` factory before its module-scoped spy `const` has
|
|
87
|
+
* been initialized, throwing a TDZ `ReferenceError`. Deferring the call to the
|
|
88
|
+
* first log site keeps import-time side-effect-free and matches the
|
|
89
|
+
* call-inside-function pattern used by `dual-scope-db.ts` and
|
|
90
|
+
* `brain-writer-thread.ts`.
|
|
91
|
+
*
|
|
92
|
+
* @task T11627
|
|
93
|
+
*/
|
|
94
|
+
let _log = null;
|
|
95
|
+
function log() {
|
|
96
|
+
if (_log === null)
|
|
97
|
+
_log = getLogger('writer-lease');
|
|
98
|
+
return _log;
|
|
99
|
+
}
|
|
100
|
+
// ── Mode resolution ───────────────────────────────────────────────────────────
|
|
101
|
+
let _cachedMode = null;
|
|
102
|
+
let _supervisorDemotionLogged = false;
|
|
103
|
+
/**
|
|
104
|
+
* Resolve the writer-lease mode from `CLEO_WRITER_LEASE_MODE`, once per process.
|
|
105
|
+
*
|
|
106
|
+
* Unknown / unset values resolve to `'local'` — the production-safe default while
|
|
107
|
+
* the supervisor daemon is disabled.
|
|
108
|
+
*
|
|
109
|
+
* @returns The resolved {@link LeaseMode}.
|
|
110
|
+
*
|
|
111
|
+
* @task T11627
|
|
112
|
+
*/
|
|
113
|
+
export function resolveLeaseMode() {
|
|
114
|
+
if (_cachedMode !== null)
|
|
115
|
+
return _cachedMode;
|
|
116
|
+
const raw = process.env.CLEO_WRITER_LEASE_MODE;
|
|
117
|
+
switch (raw) {
|
|
118
|
+
case 'supervisor':
|
|
119
|
+
case 'local':
|
|
120
|
+
case 'off':
|
|
121
|
+
case 'require':
|
|
122
|
+
_cachedMode = raw;
|
|
123
|
+
break;
|
|
124
|
+
default:
|
|
125
|
+
_cachedMode = 'local';
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
return _cachedMode;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The mode actually used for arbitration. `supervisor` demotes to `local` in ST-2
|
|
132
|
+
* because the IPC client is not wired yet — a dead/absent arbiter must never
|
|
133
|
+
* deadlock a write. Logged once.
|
|
134
|
+
*/
|
|
135
|
+
function effectiveMode() {
|
|
136
|
+
const mode = resolveLeaseMode();
|
|
137
|
+
if (mode === 'supervisor') {
|
|
138
|
+
if (!_supervisorDemotionLogged) {
|
|
139
|
+
_supervisorDemotionLogged = true;
|
|
140
|
+
log().info('CLEO_WRITER_LEASE_MODE=supervisor but no IPC client is wired (ST-2); ' +
|
|
141
|
+
'demoting to local-mode arbitration for the process lifetime.');
|
|
142
|
+
}
|
|
143
|
+
return 'local';
|
|
144
|
+
}
|
|
145
|
+
return mode;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Reset cached process-global state (mode + demotion flag + grant memo). Tests
|
|
149
|
+
* only — production resolves these once and never resets.
|
|
150
|
+
*
|
|
151
|
+
* @internal
|
|
152
|
+
*/
|
|
153
|
+
export function _resetWriterLeaseStateForTest() {
|
|
154
|
+
_cachedMode = null;
|
|
155
|
+
_supervisorDemotionLogged = false;
|
|
156
|
+
_grantMemo.clear();
|
|
157
|
+
_inflightAcquire.clear();
|
|
158
|
+
_nativeDbResolver = defaultNativeDbResolver;
|
|
159
|
+
_dbPathResolver = resolveDualScopeDbPath;
|
|
160
|
+
_activeScope = null;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Default resolver: route through the dual-scope chokepoint so the lease
|
|
164
|
+
* migration is applied (tables + raw partial-unique index), then extract the
|
|
165
|
+
* native handle drizzle holds on `$client` together with the handle's resolved
|
|
166
|
+
* `dbPath` (the lease-scope discriminator). When an explicit `dbPath` is supplied
|
|
167
|
+
* the cached path-aware opener is used so the lease row lands in THAT file (the
|
|
168
|
+
* multi-project-in-one-process case — Finding 1).
|
|
169
|
+
*/
|
|
170
|
+
const defaultNativeDbResolver = async (scope, dbPath) => {
|
|
171
|
+
let handle;
|
|
172
|
+
if (dbPath !== undefined && dbPath !== resolveDualScopeDbPath(scope)) {
|
|
173
|
+
// Explicit non-canonical path → open (cached) at that exact file.
|
|
174
|
+
handle =
|
|
175
|
+
scope === 'project'
|
|
176
|
+
? await openDualScopeDbAtPath('project', dbPath)
|
|
177
|
+
: await openDualScopeDbAtPath('global', dbPath);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
handle =
|
|
181
|
+
scope === 'project' ? await openDualScopeDb('project') : await openDualScopeDb('global');
|
|
182
|
+
}
|
|
183
|
+
const native = handle.db.$client;
|
|
184
|
+
// `handle.dbPath` is the path this open resolved; fall back to the scope→path
|
|
185
|
+
// resolver defensively (the handle always carries it in practice).
|
|
186
|
+
const resolvedPath = handle.dbPath ?? dbPath ?? resolveDualScopeDbPath(scope);
|
|
187
|
+
return { native, dbPath: resolvedPath };
|
|
188
|
+
};
|
|
189
|
+
let _nativeDbResolver = defaultNativeDbResolver;
|
|
190
|
+
/**
|
|
191
|
+
* Override how the engine obtains a scope's native `cleo.db` handle + path. Tests
|
|
192
|
+
* inject a temp-dir handle here so arbitration runs against an isolated fixture
|
|
193
|
+
* with no supervisor and no canonical-path side effects.
|
|
194
|
+
*
|
|
195
|
+
* For backward-compatibility a resolver that returns a bare `DatabaseSync` is
|
|
196
|
+
* accepted and adapted to a {@link LeaseTarget} whose `dbPath` is a stable
|
|
197
|
+
* synthetic per-scope token (`test://<scope>`) — sufficient for single-file test
|
|
198
|
+
* fixtures, which key one file per scope.
|
|
199
|
+
*
|
|
200
|
+
* @param resolver - The resolver to use, or `undefined` to restore the default.
|
|
201
|
+
* @internal
|
|
202
|
+
*/
|
|
203
|
+
export function _setNativeDbResolverForTest(resolver) {
|
|
204
|
+
if (resolver === undefined) {
|
|
205
|
+
_nativeDbResolver = defaultNativeDbResolver;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
_nativeDbResolver = async (scope, dbPath) => {
|
|
209
|
+
const out = await resolver(scope, dbPath);
|
|
210
|
+
// Adapt a bare native handle to a LeaseTarget with a stable synthetic path.
|
|
211
|
+
return 'native' in out ? out : { native: out, dbPath: dbPath ?? scopePathToken(scope) };
|
|
212
|
+
};
|
|
213
|
+
// Keep the (I/O-free) path resolver consistent with the injected native one so
|
|
214
|
+
// the memo key derived before the resolver await matches the resolved target.
|
|
215
|
+
_dbPathResolver = (scope) => scopePathToken(scope);
|
|
216
|
+
}
|
|
217
|
+
/** Stable synthetic dbPath token for a scope under a test resolver injection. */
|
|
218
|
+
function scopePathToken(scope) {
|
|
219
|
+
return `test://${scope}`;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Resolve the absolute dbPath for a scope WITHOUT opening the handle. Used to
|
|
223
|
+
* build the memo key on the re-entrant fast path (before the native resolver
|
|
224
|
+
* await). Defaults to the canonical scope→path resolver (deterministic, no I/O);
|
|
225
|
+
* a test native-resolver injection swaps in the matching synthetic token so the
|
|
226
|
+
* key built here equals the {@link LeaseTarget.dbPath} the resolver returns.
|
|
227
|
+
*/
|
|
228
|
+
let _dbPathResolver = resolveDualScopeDbPath;
|
|
229
|
+
/**
|
|
230
|
+
* `Map<\`${scope}::${dbPath}::${lane}\`, GrantEntry>` — the active grant per
|
|
231
|
+
* (scope, dbPath, lane). Keyed on the EXISTING `cacheKey(scope, dbPath)` composite
|
|
232
|
+
* (spec §6 Seam 0) so two DIFFERENT project files in one process are distinct
|
|
233
|
+
* lease scopes — the in-process grant is NEVER mis-shared across projects.
|
|
234
|
+
*/
|
|
235
|
+
const _grantMemo = new Map();
|
|
236
|
+
/**
|
|
237
|
+
* In-flight FIRST-acquisition promises per memo key (Finding 2 — single-flight).
|
|
238
|
+
*
|
|
239
|
+
* The re-entrant fast path memoizes the active grant, but the FIRST acquisition
|
|
240
|
+
* resolves the native handle (`await _nativeDbResolver`) BEFORE writing the memo
|
|
241
|
+
* entry. Two callers racing the first acquire would both observe an empty memo
|
|
242
|
+
* across that await and both run the full claim path — the loser then spins the
|
|
243
|
+
* whole acquire window and degrades to a lease-less write. This map makes the
|
|
244
|
+
* first acquisition single-flight: concurrent callers await the SAME in-flight
|
|
245
|
+
* acquire and share the resulting grant (refcount++), exactly like a nested
|
|
246
|
+
* same-frame re-entry.
|
|
247
|
+
*/
|
|
248
|
+
const _inflightAcquire = new Map();
|
|
249
|
+
/**
|
|
250
|
+
* Build the lease-scope memo key. Keyed on `${scope}::${dbPath}::${lane}` — the
|
|
251
|
+
* `${scope}::${dbPath}` prefix is byte-equal to `cacheKey(scope, dbPath)` in
|
|
252
|
+
* `dual-scope-db.ts` so the lease scope and the handle cache agree on identity.
|
|
253
|
+
*/
|
|
254
|
+
function memoKey(scope, dbPath, lane) {
|
|
255
|
+
return `${scope}::${dbPath}::${lane}`;
|
|
256
|
+
}
|
|
257
|
+
// ── Active-scope registry (ST-3 · Seam 1 — process-local, no signature change) ──
|
|
258
|
+
/**
|
|
259
|
+
* The scope + resolved dbPath of the most-recent canonical cold-open in this
|
|
260
|
+
* process.
|
|
261
|
+
*
|
|
262
|
+
* The chokepoint write primitives ({@link insertIdempotent} /
|
|
263
|
+
* {@link upsertIdempotent} in `dual-scope-db.ts`) receive only a drizzle handle —
|
|
264
|
+
* NOT the scope or a {@link LeaseHandle}. To gate them through the writer lease
|
|
265
|
+
* (Seam 1) WITHOUT a signature change, the cold-open path records its scope AND
|
|
266
|
+
* dbPath here (mirroring the `getRecordedExodusAbort` registry pattern already
|
|
267
|
+
* used by those primitives) and the primitive reads them back via
|
|
268
|
+
* {@link activeScope} / {@link activeScopeDbPath}.
|
|
269
|
+
*
|
|
270
|
+
* Recording the dbPath (not just the abstract scope LABEL) means the chokepoint
|
|
271
|
+
* leases against the lease ROW in the SAME file the open targeted — so two
|
|
272
|
+
* different projects opened in one process lease in their OWN cleo.db, never the
|
|
273
|
+
* cwd-default file (Finding 1).
|
|
274
|
+
*
|
|
275
|
+
* `null` until the first canonical cold-open records a scope.
|
|
276
|
+
*/
|
|
277
|
+
let _activeScope = null;
|
|
278
|
+
let _activeScopeDbPath = null;
|
|
279
|
+
/**
|
|
280
|
+
* Record the scope (and optionally the resolved dbPath) of the cold-open
|
|
281
|
+
* currently in progress / most recently opened. Called by `dual-scope-db.ts` at
|
|
282
|
+
* the head of its cold-open critical section (Seam 0). Idempotent — last writer
|
|
283
|
+
* wins; a project open after a global open makes `'project'` the active scope,
|
|
284
|
+
* which is correct because the chokepoint write primitives only ever write the
|
|
285
|
+
* project-tier `tasks_*` tables.
|
|
286
|
+
*
|
|
287
|
+
* @param scope - The scope of the in-progress cold-open.
|
|
288
|
+
* @param dbPath - The resolved on-disk path of that scope's cleo.db. When
|
|
289
|
+
* omitted, the active dbPath is cleared so {@link activeScopeDbPath} falls back
|
|
290
|
+
* to the canonical scope→path resolver.
|
|
291
|
+
* @internal
|
|
292
|
+
* @task T11627
|
|
293
|
+
*/
|
|
294
|
+
export function setActiveScope(scope, dbPath) {
|
|
295
|
+
_activeScope = scope;
|
|
296
|
+
_activeScopeDbPath = dbPath ?? null;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The scope the chokepoint write primitives should lease against. Defaults to
|
|
300
|
+
* `'project'` (the tasks chokepoint is project-tier) when no cold-open has
|
|
301
|
+
* recorded a scope yet — a write before any open is degenerate, but `'project'`
|
|
302
|
+
* is the only correct lease scope for `tasks_*` mutations.
|
|
303
|
+
*
|
|
304
|
+
* @returns The active {@link LeaseScope}.
|
|
305
|
+
* @internal
|
|
306
|
+
* @task T11627
|
|
307
|
+
*/
|
|
308
|
+
export function activeScope() {
|
|
309
|
+
return _activeScope ?? 'project';
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* The dbPath the chokepoint write primitives should lease their row in. Returns
|
|
313
|
+
* the path recorded by the most-recent cold-open, or — defensively — the
|
|
314
|
+
* canonical scope→path resolution for {@link activeScope} when none was recorded.
|
|
315
|
+
*
|
|
316
|
+
* @returns The active cleo.db on-disk path.
|
|
317
|
+
* @internal
|
|
318
|
+
* @task T11627
|
|
319
|
+
*/
|
|
320
|
+
export function activeScopeDbPath() {
|
|
321
|
+
return _activeScopeDbPath ?? _dbPathResolver(activeScope());
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Clear the recorded active scope + dbPath. Tests only — production records once
|
|
325
|
+
* per cold-open and never clears (the scope of the last canonical open remains
|
|
326
|
+
* the lease target for subsequent writes).
|
|
327
|
+
*
|
|
328
|
+
* @internal
|
|
329
|
+
*/
|
|
330
|
+
export function _clearActiveScopeForTest() {
|
|
331
|
+
_activeScope = null;
|
|
332
|
+
_activeScopeDbPath = null;
|
|
333
|
+
}
|
|
334
|
+
// ── Small helpers ─────────────────────────────────────────────────────────────
|
|
335
|
+
/**
|
|
336
|
+
* Asynchronously wait `ms` between contended claim retries. MUST be async (not
|
|
337
|
+
* `Atomics.wait`): the claim transaction itself is synchronous, but the BETWEEN-
|
|
338
|
+
* retry gap has to yield the event loop so a concurrent holder in THIS process can
|
|
339
|
+
* run its `release()` (which frees the row this waiter is spinning for). A
|
|
340
|
+
* synchronous spin would deadlock single-process re-entrant contention.
|
|
341
|
+
*/
|
|
342
|
+
function sleepAsync(ms) {
|
|
343
|
+
return new Promise((resolve) => setTimeout(resolve, Math.max(0, Math.round(ms))));
|
|
344
|
+
}
|
|
345
|
+
/** No-throw pid-liveness probe (`process.kill(pid, 0)`), mirrors gc/daemon.ts. */
|
|
346
|
+
function isPidAlive(pid) {
|
|
347
|
+
try {
|
|
348
|
+
process.kill(pid, 0);
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
// ESRCH => no such process (dead). EPERM => process exists, not ours (alive).
|
|
353
|
+
return err.code === 'EPERM';
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/** This process's stable holder identity for a lane. */
|
|
357
|
+
function makeHolderId(lane) {
|
|
358
|
+
return `pid-${process.pid}:${lane}:${randomUUID().slice(0, 8)}`;
|
|
359
|
+
}
|
|
360
|
+
// ── Internal handle ───────────────────────────────────────────────────────────
|
|
361
|
+
/**
|
|
362
|
+
* Concrete {@link LeaseHandle} bound to a native handle + holder identity. Owns
|
|
363
|
+
* the heartbeat timer and the epoch-guarded release/heartbeat SQL.
|
|
364
|
+
*/
|
|
365
|
+
class InternalLeaseHandle {
|
|
366
|
+
scope;
|
|
367
|
+
lane;
|
|
368
|
+
epoch;
|
|
369
|
+
holderId;
|
|
370
|
+
/** The resolved on-disk path of the cleo.db this lease's row lives in. */
|
|
371
|
+
dbPath;
|
|
372
|
+
nativeDb;
|
|
373
|
+
ttlMs;
|
|
374
|
+
heartbeatTimer = null;
|
|
375
|
+
released = false;
|
|
376
|
+
constructor(args) {
|
|
377
|
+
this.scope = args.scope;
|
|
378
|
+
this.lane = args.lane;
|
|
379
|
+
this.epoch = args.epoch;
|
|
380
|
+
this.holderId = args.holderId;
|
|
381
|
+
this.dbPath = args.dbPath;
|
|
382
|
+
this.nativeDb = args.nativeDb;
|
|
383
|
+
this.ttlMs = args.ttlMs;
|
|
384
|
+
this.startHeartbeat();
|
|
385
|
+
}
|
|
386
|
+
/** Start the heartbeat timer at `ttl/3` (unref'd so it never holds the event loop open). */
|
|
387
|
+
startHeartbeat() {
|
|
388
|
+
const interval = Math.max(1, Math.floor(this.ttlMs / 3));
|
|
389
|
+
this.heartbeatTimer = setInterval(() => this.heartbeat(), interval);
|
|
390
|
+
// Never keep the process alive solely for a heartbeat.
|
|
391
|
+
this.heartbeatTimer.unref?.();
|
|
392
|
+
}
|
|
393
|
+
heartbeat() {
|
|
394
|
+
if (this.released)
|
|
395
|
+
return;
|
|
396
|
+
// The heartbeat fires from an unref'd timer; if the underlying native handle
|
|
397
|
+
// was closed out from under a still-held lease (cache eviction / shutdown /
|
|
398
|
+
// test teardown), `prepare()` throws `database is not open` INSIDE the timer
|
|
399
|
+
// callback — an UNCAUGHT exception that would crash Node. Tolerate a closed
|
|
400
|
+
// handle the same way `tryClaimOnce` tolerates a rolled-back txn: stop the
|
|
401
|
+
// timer, mark released, and no-op. A reclaimed holder (new epoch) updates 0
|
|
402
|
+
// rows → no-op via the epoch guard.
|
|
403
|
+
try {
|
|
404
|
+
this.nativeDb
|
|
405
|
+
.prepare(`UPDATE ${WRITER_LEASES_TABLE} SET heartbeat_at = ? ` +
|
|
406
|
+
`WHERE scope = ? AND lane = ? AND holder_id = ? AND epoch = ? AND active = 1`)
|
|
407
|
+
.run(Date.now(), this.scope, this.lane, this.holderId, this.epoch);
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
// Closed handle (or any heartbeat-write failure): stop heartbeating rather
|
|
411
|
+
// than throw on every subsequent tick. The row is freed by whoever closed
|
|
412
|
+
// the DB (close drops the file lock) or reclaimed via TTL by a peer.
|
|
413
|
+
this.released = true;
|
|
414
|
+
if (this.heartbeatTimer) {
|
|
415
|
+
clearInterval(this.heartbeatTimer);
|
|
416
|
+
this.heartbeatTimer = null;
|
|
417
|
+
}
|
|
418
|
+
log().debug({ scope: this.scope, lane: this.lane, err: err instanceof Error ? err.message : err }, 'writer-lease heartbeat failed (native handle likely closed); stopping heartbeat');
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/** Free the row (`active = 0`) under the epoch guard, stop the heartbeat. */
|
|
422
|
+
releaseRow() {
|
|
423
|
+
if (this.released)
|
|
424
|
+
return;
|
|
425
|
+
this.released = true;
|
|
426
|
+
if (this.heartbeatTimer) {
|
|
427
|
+
clearInterval(this.heartbeatTimer);
|
|
428
|
+
this.heartbeatTimer = null;
|
|
429
|
+
}
|
|
430
|
+
// Tolerate a closed native handle on release (same race as the heartbeat):
|
|
431
|
+
// the lease row is freed implicitly when the DB closes / dies, so a failed
|
|
432
|
+
// free-row UPDATE must not throw out of `release()`.
|
|
433
|
+
try {
|
|
434
|
+
this.nativeDb
|
|
435
|
+
.prepare(`UPDATE ${WRITER_LEASES_TABLE} SET active = 0, reentrancy_depth = 0 ` +
|
|
436
|
+
`WHERE scope = ? AND lane = ? AND holder_id = ? AND epoch = ? AND active = 1`)
|
|
437
|
+
.run(this.scope, this.lane, this.holderId, this.epoch);
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
log().debug({ scope: this.scope, lane: this.lane, err: err instanceof Error ? err.message : err }, 'writer-lease releaseRow failed (native handle likely closed); row freed by close/TTL');
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async release() {
|
|
444
|
+
await releaseGrant(this.scope, this.dbPath, this.lane);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// ── Error ─────────────────────────────────────────────────────────────────────
|
|
448
|
+
/**
|
|
449
|
+
* Thrown when `require` mode cannot acquire the lease, or (future) when a
|
|
450
|
+
* supervisor explicitly denies. Carries a stable `codeName` for envelope mapping.
|
|
451
|
+
*
|
|
452
|
+
* @public
|
|
453
|
+
*/
|
|
454
|
+
export class LeaseUnavailableError extends Error {
|
|
455
|
+
/** Stable string error code for envelope `codeName` / log correlation. */
|
|
456
|
+
codeName = 'E_LEASE_UNAVAILABLE';
|
|
457
|
+
constructor(scope, lane, reason) {
|
|
458
|
+
super(`E_LEASE_UNAVAILABLE: could not acquire ${scope}/${lane} writer lease ` +
|
|
459
|
+
`(mode=require): ${reason}`);
|
|
460
|
+
this.name = 'LeaseUnavailableError';
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Thrown when a write primitive opens the WRITE handle without a held lease
|
|
465
|
+
* (T11627 ST-4 · Seam 2 · AC4). The brain worker (and any other dedicated-handle
|
|
466
|
+
* writer outside the chokepoint) MUST hold its lane's grant before it writes — a
|
|
467
|
+
* lease-less write is exactly the multi-writer race the lease exists to kill.
|
|
468
|
+
*
|
|
469
|
+
* Enforced (not merely advised): the brain writer bootstrap calls
|
|
470
|
+
* {@link assertWriterLeaseHeld} before draining, and a lease-less drain throws
|
|
471
|
+
* this. `off` mode (the rollback escape hatch) is exempt — there is no lease to
|
|
472
|
+
* hold and the underlying `busy_timeout` serializes writes as before.
|
|
473
|
+
*
|
|
474
|
+
* @public
|
|
475
|
+
* @task T11627
|
|
476
|
+
*/
|
|
477
|
+
export class WriterLeaseRequiredError extends Error {
|
|
478
|
+
/** Stable string error code for envelope `codeName` / log correlation. */
|
|
479
|
+
codeName = 'E_WRITER_LEASE_REQUIRED';
|
|
480
|
+
constructor(scope, lane) {
|
|
481
|
+
super(`E_WRITER_LEASE_REQUIRED: cannot open the ${scope}/${lane} writer handle ` +
|
|
482
|
+
'without a held writer lease (AC4) — wrap the write in withWriterLease/' +
|
|
483
|
+
'acquireWriterLease, or set CLEO_WRITER_LEASE_MODE=off to bypass.');
|
|
484
|
+
this.name = 'WriterLeaseRequiredError';
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Whether THIS process currently holds an active grant for `(scope, lane)` in the
|
|
489
|
+
* process-local grant memo. Used by dedicated-handle writers (the brain worker) to
|
|
490
|
+
* assert AC4 without re-running the claim txn.
|
|
491
|
+
*
|
|
492
|
+
* @param scope - The cleo.db scope.
|
|
493
|
+
* @param lane - The write lane.
|
|
494
|
+
* @returns `true` iff a memoized grant with `refcount > 0` exists for the key.
|
|
495
|
+
*
|
|
496
|
+
* @task T11627
|
|
497
|
+
*/
|
|
498
|
+
export function hasActiveGrant(scope, lane) {
|
|
499
|
+
const entry = _grantMemo.get(memoKey(scope, _dbPathResolver(scope), lane));
|
|
500
|
+
return entry !== undefined && entry.refcount > 0;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* AC4 guard. Assert this process holds the `(scope, lane)` writer lease before it
|
|
504
|
+
* opens a dedicated WRITE handle. In `off` mode the assertion is a no-op (there is
|
|
505
|
+
* no lease; `busy_timeout` serializes). In every other mode a missing grant throws
|
|
506
|
+
* {@link WriterLeaseRequiredError}.
|
|
507
|
+
*
|
|
508
|
+
* @param scope - The cleo.db scope the handle writes to.
|
|
509
|
+
* @param lane - The write lane that must be held.
|
|
510
|
+
* @throws {WriterLeaseRequiredError} when no grant is held and mode is not `off`.
|
|
511
|
+
*
|
|
512
|
+
* @task T11627
|
|
513
|
+
*/
|
|
514
|
+
export function assertWriterLeaseHeld(scope, lane) {
|
|
515
|
+
if (effectiveMode() === 'off')
|
|
516
|
+
return;
|
|
517
|
+
if (!hasActiveGrant(scope, lane)) {
|
|
518
|
+
throw new WriterLeaseRequiredError(scope, lane);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// ── Claim transaction (shared primitive — local mode) ─────────────────────────
|
|
522
|
+
/**
|
|
523
|
+
* Attempt ONE `BEGIN IMMEDIATE` claim against the active row. Returns the granted
|
|
524
|
+
* epoch on success, or `null` if a live holder owns the row (caller backs off and
|
|
525
|
+
* retries; the queue row was enqueued on the first miss).
|
|
526
|
+
*/
|
|
527
|
+
function tryClaimOnce(nativeDb, scope, lane, holderId, ttlMs) {
|
|
528
|
+
const now = Date.now();
|
|
529
|
+
nativeDb.exec('BEGIN IMMEDIATE TRANSACTION');
|
|
530
|
+
try {
|
|
531
|
+
const active = nativeDb
|
|
532
|
+
.prepare(`SELECT id, holder_id, holder_pid, epoch, heartbeat_at, ttl_ms, reentrancy_depth ` +
|
|
533
|
+
`FROM ${WRITER_LEASES_TABLE} WHERE scope = ? AND lane = ? AND active = 1`)
|
|
534
|
+
.get(scope, lane);
|
|
535
|
+
if (active === undefined) {
|
|
536
|
+
// No active holder — take a fresh row with the next epoch.
|
|
537
|
+
const nextEpoch = nativeDb
|
|
538
|
+
.prepare(`SELECT COALESCE(MAX(epoch), 0) + 1 AS e FROM ${WRITER_LEASES_TABLE} WHERE scope = ? AND lane = ?`)
|
|
539
|
+
.get(scope, lane)?.e ?? 1;
|
|
540
|
+
nativeDb
|
|
541
|
+
.prepare(`INSERT INTO ${WRITER_LEASES_TABLE} ` +
|
|
542
|
+
`(scope, lane, holder_id, holder_pid, epoch, acquired_at, heartbeat_at, ttl_ms, reentrancy_depth, active) ` +
|
|
543
|
+
`VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1)`)
|
|
544
|
+
.run(scope, lane, holderId, process.pid, nextEpoch, now, now, ttlMs);
|
|
545
|
+
nativeDb.exec('COMMIT');
|
|
546
|
+
return nextEpoch;
|
|
547
|
+
}
|
|
548
|
+
if (active.holder_id === holderId) {
|
|
549
|
+
// Same holder (defensive — same-process re-entrancy is handled by the memo
|
|
550
|
+
// before reaching here). Bump the durable depth and re-assert the epoch.
|
|
551
|
+
nativeDb
|
|
552
|
+
.prepare(`UPDATE ${WRITER_LEASES_TABLE} SET reentrancy_depth = reentrancy_depth + 1, heartbeat_at = ? ` +
|
|
553
|
+
`WHERE id = ?`)
|
|
554
|
+
.run(now, active.id);
|
|
555
|
+
nativeDb.exec('COMMIT');
|
|
556
|
+
return active.epoch;
|
|
557
|
+
}
|
|
558
|
+
// A different holder owns the row. Reclaim IFF it is stale (TTL expired AND
|
|
559
|
+
// its pid is dead) — SQLite serializes this inside BEGIN IMMEDIATE so two
|
|
560
|
+
// reclaimers cannot both win; the loser sees the new epoch and re-queues.
|
|
561
|
+
const stale = now - active.heartbeat_at > active.ttl_ms && !isPidAlive(active.holder_pid);
|
|
562
|
+
if (stale) {
|
|
563
|
+
const reclaimedEpoch = active.epoch + 1;
|
|
564
|
+
nativeDb
|
|
565
|
+
.prepare(`UPDATE ${WRITER_LEASES_TABLE} ` +
|
|
566
|
+
`SET holder_id = ?, holder_pid = ?, epoch = ?, acquired_at = ?, heartbeat_at = ?, ttl_ms = ?, reentrancy_depth = 1 ` +
|
|
567
|
+
`WHERE id = ? AND epoch = ?`)
|
|
568
|
+
.run(holderId, process.pid, reclaimedEpoch, now, now, ttlMs, active.id, active.epoch);
|
|
569
|
+
nativeDb.exec('COMMIT');
|
|
570
|
+
return reclaimedEpoch;
|
|
571
|
+
}
|
|
572
|
+
// Live holder — give up this attempt.
|
|
573
|
+
nativeDb.exec('ROLLBACK');
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
try {
|
|
578
|
+
nativeDb.exec('ROLLBACK');
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
// already rolled back / no active txn — ignore
|
|
582
|
+
}
|
|
583
|
+
throw err;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/** Enqueue a waiter row (idempotent per holder) for FIFO+priority ordering / aging. */
|
|
587
|
+
function enqueueWaiter(nativeDb, scope, lane, holderId, priority, ttlMs) {
|
|
588
|
+
const now = Date.now();
|
|
589
|
+
const existing = nativeDb
|
|
590
|
+
.prepare(`SELECT ticket FROM ${WRITER_QUEUE_TABLE} WHERE scope = ? AND lane = ? AND holder_id = ?`)
|
|
591
|
+
.get(scope, lane, holderId);
|
|
592
|
+
if (existing)
|
|
593
|
+
return;
|
|
594
|
+
nativeDb
|
|
595
|
+
.prepare(`INSERT INTO ${WRITER_QUEUE_TABLE} (scope, lane, holder_id, priority, enqueued_at, deadline_at) ` +
|
|
596
|
+
`VALUES (?, ?, ?, ?, ?, ?)`)
|
|
597
|
+
.run(scope, lane, holderId, priority, now, now + ttlMs);
|
|
598
|
+
}
|
|
599
|
+
/** Remove this holder's waiter row once granted (or on give-up). */
|
|
600
|
+
function dequeueWaiter(nativeDb, scope, lane, holderId) {
|
|
601
|
+
nativeDb
|
|
602
|
+
.prepare(`DELETE FROM ${WRITER_QUEUE_TABLE} WHERE scope = ? AND lane = ? AND holder_id = ?`)
|
|
603
|
+
.run(scope, lane, holderId);
|
|
604
|
+
}
|
|
605
|
+
// ── Acquire / release surface ─────────────────────────────────────────────────
|
|
606
|
+
/**
|
|
607
|
+
* Acquire (or re-enter) the writer lease for `(scope, lane)`. Caller MUST call
|
|
608
|
+
* `release()` on the returned handle (use {@link withWriterLease} to do so
|
|
609
|
+
* automatically).
|
|
610
|
+
*
|
|
611
|
+
* - `off` mode → returns a no-op handle (pass-through; no row written).
|
|
612
|
+
* - `local`/`supervisor` mode → arbitrates over the persisted row via
|
|
613
|
+
* `BEGIN IMMEDIATE` (+ epoch-CAS). A nested same-(scope,lane) acquire in this
|
|
614
|
+
* process re-enters the memoized grant (refcount++).
|
|
615
|
+
* - `require` mode → throws {@link LeaseUnavailableError} if the row cannot be
|
|
616
|
+
* taken within the acquire window (`min(ACQUIRE_DEADLINE_MS, ttlMs)`).
|
|
617
|
+
*
|
|
618
|
+
* @param scope - The cleo.db scope.
|
|
619
|
+
* @param lane - The write lane within the scope.
|
|
620
|
+
* @param opts - Priority / TTL / re-entrancy options.
|
|
621
|
+
* @returns A held {@link LeaseHandle}.
|
|
622
|
+
*
|
|
623
|
+
* @task T11627
|
|
624
|
+
*/
|
|
625
|
+
export async function acquireWriterLease(scope, lane, opts) {
|
|
626
|
+
const mode = effectiveMode();
|
|
627
|
+
const reentrant = opts?.reentrant ?? true;
|
|
628
|
+
// `off` mode — pure pass-through. No row written, no memo entry; busy_timeout on
|
|
629
|
+
// the underlying connection serializes writes exactly as before the lease.
|
|
630
|
+
if (mode === 'off') {
|
|
631
|
+
return makeNoopHandle(scope, lane);
|
|
632
|
+
}
|
|
633
|
+
// Build the lease-scope key up front from the explicit pinned dbPath (when the
|
|
634
|
+
// chokepoint pins a non-cwd-default file) or the I/O-free path resolver, so the
|
|
635
|
+
// re-entrant fast path AND the single-flight guard are decided SYNCHRONOUSLY,
|
|
636
|
+
// before any `await` yields the event loop. Keying on `${scope}::${dbPath}::
|
|
637
|
+
// ${lane}` means two different project files in one process are distinct lease
|
|
638
|
+
// scopes (Finding 1) and that the same (scope,dbPath,lane) never double-claims.
|
|
639
|
+
const dbPath = opts?.dbPath ?? _dbPathResolver(scope);
|
|
640
|
+
const key = memoKey(scope, dbPath, lane);
|
|
641
|
+
// Re-entrant fast path: an existing same-(scope,dbPath,lane) grant is shared
|
|
642
|
+
// (refcount++ + durable depth++) without a second claim txn.
|
|
643
|
+
if (reentrant) {
|
|
644
|
+
const existing = _grantMemo.get(key);
|
|
645
|
+
if (existing) {
|
|
646
|
+
existing.refcount += 1;
|
|
647
|
+
// Reflect the re-entry in the durable row depth (best-effort under epoch guard).
|
|
648
|
+
const { native } = await _nativeDbResolver(scope, dbPath);
|
|
649
|
+
native
|
|
650
|
+
.prepare(`UPDATE ${WRITER_LEASES_TABLE} SET reentrancy_depth = reentrancy_depth + 1 ` +
|
|
651
|
+
`WHERE scope = ? AND lane = ? AND holder_id = ? AND epoch = ? AND active = 1`)
|
|
652
|
+
.run(scope, lane, existing.handle.holderId, existing.handle.epoch);
|
|
653
|
+
return existing.handle;
|
|
654
|
+
}
|
|
655
|
+
// Single-flight FIRST acquisition (Finding 2): if another caller is already
|
|
656
|
+
// mid-acquire for this key, await its in-flight promise and share the grant
|
|
657
|
+
// (refcount++) instead of racing a second full claim that would spin the whole
|
|
658
|
+
// acquire window and degrade to a lease-less write. Decided synchronously here
|
|
659
|
+
// — no `await` has run since the memo check above, so the two checks are atomic.
|
|
660
|
+
const inflight = _inflightAcquire.get(key);
|
|
661
|
+
if (inflight) {
|
|
662
|
+
const shared = await inflight;
|
|
663
|
+
// Re-check the memo: the in-flight acquire may have already released (e.g.
|
|
664
|
+
// immediate withWriterLease) or degraded to a no-op (not memoized).
|
|
665
|
+
const entry = _grantMemo.get(key);
|
|
666
|
+
if (entry && entry.handle === shared) {
|
|
667
|
+
entry.refcount += 1;
|
|
668
|
+
// `entry.handle` is the concrete InternalLeaseHandle (holderId/epoch).
|
|
669
|
+
const { native } = await _nativeDbResolver(scope, dbPath);
|
|
670
|
+
native
|
|
671
|
+
.prepare(`UPDATE ${WRITER_LEASES_TABLE} SET reentrancy_depth = reentrancy_depth + 1 ` +
|
|
672
|
+
`WHERE scope = ? AND lane = ? AND holder_id = ? AND epoch = ? AND active = 1`)
|
|
673
|
+
.run(scope, lane, entry.handle.holderId, entry.handle.epoch);
|
|
674
|
+
return entry.handle;
|
|
675
|
+
}
|
|
676
|
+
// The shared acquire is no longer active — fall through to a fresh acquire.
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// First acquisition for this key. Register the in-flight promise BEFORE the
|
|
680
|
+
// first `await` so a concurrent reentrant caller (above) can single-flight onto
|
|
681
|
+
// it. Only the reentrant path participates; reentrant:false callers (test cross-
|
|
682
|
+
// holder contention) deliberately bypass the memo + single-flight.
|
|
683
|
+
const acquirePromise = performFirstAcquire(scope, dbPath, lane, mode, key, opts);
|
|
684
|
+
if (reentrant) {
|
|
685
|
+
_inflightAcquire.set(key, acquirePromise);
|
|
686
|
+
}
|
|
687
|
+
try {
|
|
688
|
+
return await acquirePromise;
|
|
689
|
+
}
|
|
690
|
+
finally {
|
|
691
|
+
// Always clear our in-flight entry. A degraded acquire resolves to a no-op
|
|
692
|
+
// handle (never memoized) and `require` mode rejects — in both cases the
|
|
693
|
+
// single-flight followers re-check the memo, find nothing, and acquire freshly,
|
|
694
|
+
// so a stale in-flight entry must never linger.
|
|
695
|
+
if (reentrant && _inflightAcquire.get(key) === acquirePromise) {
|
|
696
|
+
_inflightAcquire.delete(key);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Run ONE full first-acquisition for `(scope, dbPath, lane)` against the persisted
|
|
702
|
+
* row: resolve the native handle, claim/queue/backoff under the bounded acquire
|
|
703
|
+
* window, and on success memoize the grant. Returns a degraded no-op handle on
|
|
704
|
+
* deadline (local) or throws {@link LeaseUnavailableError} (`require`).
|
|
705
|
+
*
|
|
706
|
+
* Factored out of {@link acquireWriterLease} so the single-flight guard can wrap a
|
|
707
|
+
* single in-flight promise per key — the resolved handle (a memoized
|
|
708
|
+
* {@link InternalLeaseHandle}, or a no-op handle on degrade) is what concurrent
|
|
709
|
+
* reentrant followers re-check the memo against before sharing.
|
|
710
|
+
*/
|
|
711
|
+
async function performFirstAcquire(scope, dbPath, lane, mode, key, opts) {
|
|
712
|
+
const { native } = await _nativeDbResolver(scope, dbPath);
|
|
713
|
+
// Defensive bootstrap assert: the partial-unique active index MUST exist or AC1
|
|
714
|
+
// is unenforced. Cheap single-row sqlite_master lookup, runs on first acquire.
|
|
715
|
+
assertWriterLeaseActiveIndexPresent(native);
|
|
716
|
+
const holderId = makeHolderId(lane);
|
|
717
|
+
const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;
|
|
718
|
+
const priority = opts?.priority ?? DEFAULT_PRIORITY;
|
|
719
|
+
// Wait at most the lease TTL (but never longer than the hard cap): a caller must
|
|
720
|
+
// not block for a grant longer than the lease itself would live. busy_timeout on
|
|
721
|
+
// the IMMEDIATE lock still backstops every individual claim attempt.
|
|
722
|
+
const acquireWindowMs = Math.min(ACQUIRE_DEADLINE_MS, Math.max(1, ttlMs));
|
|
723
|
+
const deadline = Date.now() + acquireWindowMs;
|
|
724
|
+
let enqueued = false;
|
|
725
|
+
for (;;) {
|
|
726
|
+
const epoch = tryClaimOnce(native, scope, lane, holderId, ttlMs);
|
|
727
|
+
if (epoch !== null) {
|
|
728
|
+
if (enqueued)
|
|
729
|
+
dequeueWaiter(native, scope, lane, holderId);
|
|
730
|
+
const handle = new InternalLeaseHandle({
|
|
731
|
+
scope,
|
|
732
|
+
lane,
|
|
733
|
+
epoch,
|
|
734
|
+
holderId,
|
|
735
|
+
dbPath,
|
|
736
|
+
nativeDb: native,
|
|
737
|
+
ttlMs,
|
|
738
|
+
});
|
|
739
|
+
_grantMemo.set(key, { handle, refcount: 1 });
|
|
740
|
+
return handle;
|
|
741
|
+
}
|
|
742
|
+
// Contended by a live holder. Enqueue once for ordering/aging, then back off.
|
|
743
|
+
if (!enqueued) {
|
|
744
|
+
enqueueWaiter(native, scope, lane, holderId, priority, ttlMs);
|
|
745
|
+
enqueued = true;
|
|
746
|
+
}
|
|
747
|
+
if (Date.now() >= deadline) {
|
|
748
|
+
if (enqueued)
|
|
749
|
+
dequeueWaiter(native, scope, lane, holderId);
|
|
750
|
+
if (mode === 'require') {
|
|
751
|
+
throw new LeaseUnavailableError(scope, lane, 'live holder did not release within deadline');
|
|
752
|
+
}
|
|
753
|
+
// local: degrade to today's behaviour — proceed without a lease. busy_timeout
|
|
754
|
+
// on the connection still serializes the actual write. A no-op handle writes
|
|
755
|
+
// no row, starts no heartbeat, and is never memoized — so single-flight
|
|
756
|
+
// followers find no memo entry and acquire freshly.
|
|
757
|
+
log().warn({ scope, lane }, 'writer-lease acquire deadline exceeded; proceeding under busy_timeout fallback (degraded)');
|
|
758
|
+
return makeNoopHandle(scope, lane);
|
|
759
|
+
}
|
|
760
|
+
await sleepAsync(CLAIM_RETRY_DELAY_MS);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
/** Decrement the memoized grant; free the row at depth 0. */
|
|
764
|
+
async function releaseGrant(scope, dbPath, lane) {
|
|
765
|
+
const key = memoKey(scope, dbPath, lane);
|
|
766
|
+
const entry = _grantMemo.get(key);
|
|
767
|
+
if (!entry)
|
|
768
|
+
return; // off-mode / no-op handle / already released
|
|
769
|
+
entry.refcount -= 1;
|
|
770
|
+
if (entry.refcount > 0) {
|
|
771
|
+
// Still re-entered above this frame — decrement the durable depth only. Pin the
|
|
772
|
+
// resolver at the grant's own dbPath so the depth-write lands in the SAME file
|
|
773
|
+
// the lease row lives in (matches the memo key, not the cwd-default).
|
|
774
|
+
const { native } = await _nativeDbResolver(scope, dbPath);
|
|
775
|
+
native
|
|
776
|
+
.prepare(`UPDATE ${WRITER_LEASES_TABLE} SET reentrancy_depth = reentrancy_depth - 1 ` +
|
|
777
|
+
`WHERE scope = ? AND lane = ? AND holder_id = ? AND epoch = ? AND active = 1`)
|
|
778
|
+
.run(scope, lane, entry.handle.holderId, entry.handle.epoch);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
// Depth 0 — free the row and evict the memo.
|
|
782
|
+
_grantMemo.delete(key);
|
|
783
|
+
entry.handle.releaseRow();
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* A no-op handle for `off` mode and degraded-fallback returns: it holds no row,
|
|
787
|
+
* starts no heartbeat, and `release()` is a no-op. `epoch` is `0` (sentinel).
|
|
788
|
+
*/
|
|
789
|
+
function makeNoopHandle(scope, lane) {
|
|
790
|
+
return {
|
|
791
|
+
scope,
|
|
792
|
+
lane,
|
|
793
|
+
epoch: 0,
|
|
794
|
+
async release() {
|
|
795
|
+
/* pass-through */
|
|
796
|
+
},
|
|
797
|
+
heartbeat() {
|
|
798
|
+
/* pass-through */
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Primary surface: acquire → run `fn` → release (always, even on throw).
|
|
804
|
+
*
|
|
805
|
+
* Refcounted + re-entrant by `(scope, lane)`: a nested same-lane write in the same
|
|
806
|
+
* process re-enters the memoized grant rather than re-running the claim txn.
|
|
807
|
+
*
|
|
808
|
+
* - `off` mode → pass-through (runs `fn` under today's busy_timeout, no row).
|
|
809
|
+
* - `require` mode → an unacquirable lease throws {@link LeaseUnavailableError}
|
|
810
|
+
* before `fn` runs.
|
|
811
|
+
*
|
|
812
|
+
* @param scope - The cleo.db scope.
|
|
813
|
+
* @param lane - The write lane.
|
|
814
|
+
* @param fn - The work to run while holding the lease; receives the handle.
|
|
815
|
+
* @param opts - Priority / TTL / re-entrancy options.
|
|
816
|
+
* @returns The resolved value of `fn`.
|
|
817
|
+
*
|
|
818
|
+
* @task T11627
|
|
819
|
+
*/
|
|
820
|
+
export async function withWriterLease(scope, lane, fn, opts) {
|
|
821
|
+
const handle = await acquireWriterLease(scope, lane, opts);
|
|
822
|
+
try {
|
|
823
|
+
return await fn(handle);
|
|
824
|
+
}
|
|
825
|
+
finally {
|
|
826
|
+
await handle.release();
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// ── Cold-open lease (ST-3 · Seam 0 — the T5158 heal) ──────────────────────────
|
|
830
|
+
/**
|
|
831
|
+
* The lease-table bootstrap DDL, applied idempotently to a native handle BEFORE
|
|
832
|
+
* the cold-open claim txn. It is byte-equivalent (modulo `IF NOT EXISTS`) to the
|
|
833
|
+
* `_t11891-writer-leases` migration so the claim can run before the full migration
|
|
834
|
+
* (which is what creates these tables) executes inside the leased section. The
|
|
835
|
+
* partial-unique index is identical to the migration's — AC1 holds whether the
|
|
836
|
+
* tables came from here or from the migration.
|
|
837
|
+
*/
|
|
838
|
+
const COLD_OPEN_LEASE_BOOTSTRAP_DDL = [
|
|
839
|
+
// Reserve rootpage 2 for drizzle's migration journal — the FIRST table drizzle
|
|
840
|
+
// `migrate()` creates on a fresh DB — BEFORE the lease tables claim it.
|
|
841
|
+
//
|
|
842
|
+
// SQLite assigns b-tree rootpages sequentially: the first `CREATE TABLE` on an
|
|
843
|
+
// empty file takes page 2. Bootstrapping the lease tables here (a prerequisite of
|
|
844
|
+
// the cold-open claim txn, which runs BEFORE the data migrations inside `fn`)
|
|
845
|
+
// would otherwise plant the EMPTY `_writer_leases` table at page 2. After the
|
|
846
|
+
// cold-open lease releases (`active = 0`), that page is an empty leaf — so a DB
|
|
847
|
+
// whose page-2 bytes are later scribbled is NOT flagged by `PRAGMA
|
|
848
|
+
// integrity_check` (no cells to traverse), silently masking real corruption that
|
|
849
|
+
// `cleo doctor` / project-health probes must detect. Pre-creating the journal
|
|
850
|
+
// table (identical DDL to migration-manager's reconcile path) keeps page 2 owned
|
|
851
|
+
// by the always-populated `__drizzle_migrations` table exactly as on a lease-free
|
|
852
|
+
// open — the corruption-detection contract is preserved and the heal is unchanged.
|
|
853
|
+
// `IF NOT EXISTS` → a no-op re-running over an already-migrated DB.
|
|
854
|
+
//
|
|
855
|
+
// @task T11627
|
|
856
|
+
`CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
|
|
857
|
+
id INTEGER PRIMARY KEY,
|
|
858
|
+
hash text NOT NULL,
|
|
859
|
+
created_at numeric,
|
|
860
|
+
name text,
|
|
861
|
+
applied_at TEXT
|
|
862
|
+
)`,
|
|
863
|
+
`CREATE TABLE IF NOT EXISTS ${WRITER_LEASES_TABLE} (
|
|
864
|
+
id INTEGER PRIMARY KEY,
|
|
865
|
+
scope TEXT NOT NULL,
|
|
866
|
+
lane TEXT NOT NULL,
|
|
867
|
+
holder_id TEXT NOT NULL,
|
|
868
|
+
holder_pid INTEGER NOT NULL,
|
|
869
|
+
epoch INTEGER NOT NULL,
|
|
870
|
+
acquired_at INTEGER NOT NULL,
|
|
871
|
+
heartbeat_at INTEGER NOT NULL,
|
|
872
|
+
ttl_ms INTEGER NOT NULL,
|
|
873
|
+
reentrancy_depth INTEGER NOT NULL DEFAULT 1,
|
|
874
|
+
active INTEGER NOT NULL DEFAULT 1
|
|
875
|
+
)`,
|
|
876
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS ${WRITER_LEASES_ACTIVE_INDEX} ON ${WRITER_LEASES_TABLE} (scope, lane) WHERE active = 1`,
|
|
877
|
+
`CREATE TABLE IF NOT EXISTS ${WRITER_QUEUE_TABLE} (
|
|
878
|
+
ticket INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
879
|
+
scope TEXT NOT NULL,
|
|
880
|
+
lane TEXT NOT NULL,
|
|
881
|
+
holder_id TEXT NOT NULL,
|
|
882
|
+
priority INTEGER NOT NULL DEFAULT 100,
|
|
883
|
+
enqueued_at INTEGER NOT NULL,
|
|
884
|
+
deadline_at INTEGER NOT NULL
|
|
885
|
+
)`,
|
|
886
|
+
`CREATE INDEX IF NOT EXISTS ix_writer_queue_order ON ${WRITER_QUEUE_TABLE} (scope, lane, priority ASC, ticket ASC)`,
|
|
887
|
+
];
|
|
888
|
+
/**
|
|
889
|
+
* Idempotently create the lease tables + the raw partial-unique active index on
|
|
890
|
+
* `nativeDb`. Safe to call before any migration has run (the migration that
|
|
891
|
+
* normally creates these tables executes INSIDE the leased cold-open section, so
|
|
892
|
+
* the claim txn needs the tables present first). Each statement is `IF NOT EXISTS`
|
|
893
|
+
* so a re-open over an already-migrated DB is a no-op.
|
|
894
|
+
*/
|
|
895
|
+
function ensureColdOpenLeaseTables(nativeDb) {
|
|
896
|
+
for (const stmt of COLD_OPEN_LEASE_BOOTSTRAP_DDL) {
|
|
897
|
+
nativeDb.exec(stmt);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Lease the dual-scope-db COLD-OPEN critical section (Seam 0 — the T5158 heal).
|
|
902
|
+
*
|
|
903
|
+
* This is the high-value gate: it serializes the cold-open `reconcileJournal` +
|
|
904
|
+
* `migrateWithRetry` write-txn — the precise span that races in T5158 — so EXACTLY
|
|
905
|
+
* ONE process per scope runs it while peers `BEGIN IMMEDIATE`-queue and then observe
|
|
906
|
+
* a ready DB. It heals the T5158 `E_NOT_INITIALIZED` / `E_INTERNAL` corruption
|
|
907
|
+
* **with the supervisor daemon disabled** (`local` mode default). The caller
|
|
908
|
+
* (`dual-scope-db.ts`) runs the exodus-on-open hook AFTER this lease releases — the
|
|
909
|
+
* exodus engine owns its own single-flight lock + dedicated connections and
|
|
910
|
+
* closes/re-opens handles, so it must not run under this row lease.
|
|
911
|
+
*
|
|
912
|
+
* Unlike {@link withWriterLease} it operates DIRECTLY on the already-opened native
|
|
913
|
+
* handle — it never routes through the default resolver (which would re-enter
|
|
914
|
+
* `openDualScopeDb` and recurse) and it bootstraps the lease tables first (the full
|
|
915
|
+
* migration that creates them runs inside `fn`). It also records the scope in the
|
|
916
|
+
* Seam-1 active-scope registry ({@link setActiveScope}) so chokepoint write
|
|
917
|
+
* primitives lease against the right scope.
|
|
918
|
+
*
|
|
919
|
+
* Mode semantics mirror {@link withWriterLease}:
|
|
920
|
+
* - `off` → pass-through: runs `fn` under today's `busy_timeout=30000`, byte-
|
|
921
|
+
* identical to pre-lease cold-open behaviour.
|
|
922
|
+
* - `local` / `supervisor` (demoted) → claim the row via `BEGIN IMMEDIATE`; on a
|
|
923
|
+
* contended live holder, spin under the busy-timeout-backstopped deadline, then
|
|
924
|
+
* degrade to running `fn` (busy_timeout still serializes the migrate write-txn).
|
|
925
|
+
* - `require` → throw {@link LeaseUnavailableError} if the row cannot be taken.
|
|
926
|
+
*
|
|
927
|
+
* @param scope - The cleo.db scope being cold-opened.
|
|
928
|
+
* @param nativeDb - The native handle the cold-open just created (pragmas applied).
|
|
929
|
+
* @param fn - The cold-open body to run while holding the lease.
|
|
930
|
+
* @param opts - Priority / TTL options + the resolved `dbPath` of this cold-open.
|
|
931
|
+
* `dbPath` is recorded in the Seam-1 active-scope registry so the chokepoint
|
|
932
|
+
* write primitives lease their row in the SAME file this open targeted (correct
|
|
933
|
+
* when more than one project is open in one process — Finding 1). Cold-open
|
|
934
|
+
* defaults to highest priority ({@link SCHEMA_BOOTSTRAP_PRIORITY}) and a 60s TTL
|
|
935
|
+
* (schema bootstrap can be slow).
|
|
936
|
+
* @returns The resolved value of `fn`.
|
|
937
|
+
*
|
|
938
|
+
* @task T11627
|
|
939
|
+
*/
|
|
940
|
+
export async function withColdOpenLease(scope, nativeDb, fn, opts) {
|
|
941
|
+
// Seam 1 wiring: record the scope + resolved dbPath of this cold-open so
|
|
942
|
+
// chokepoint write primitives (insertIdempotent/upsertIdempotent) lease against
|
|
943
|
+
// the right scope AND the right file.
|
|
944
|
+
setActiveScope(scope, opts?.dbPath);
|
|
945
|
+
const mode = effectiveMode();
|
|
946
|
+
// `off` mode — pure pass-through. busy_timeout=30000 on the connection still
|
|
947
|
+
// serializes the migrate/reconcile write-txn exactly as before the lease.
|
|
948
|
+
if (mode === 'off') {
|
|
949
|
+
return fn();
|
|
950
|
+
}
|
|
951
|
+
// The lease tables MUST exist before the claim txn — the migration that creates
|
|
952
|
+
// them runs inside `fn`, so bootstrap them idempotently here first.
|
|
953
|
+
ensureColdOpenLeaseTables(nativeDb);
|
|
954
|
+
// Defensive: the partial-unique active index MUST be present or AC1 is unenforced.
|
|
955
|
+
assertWriterLeaseActiveIndexPresent(nativeDb);
|
|
956
|
+
const lane = 'tasks';
|
|
957
|
+
const holderId = makeHolderId(lane);
|
|
958
|
+
const ttlMs = opts?.ttlMs ?? 60_000;
|
|
959
|
+
const priority = opts?.priority ?? SCHEMA_BOOTSTRAP_PRIORITY;
|
|
960
|
+
// Bounded acquire window — never wait longer than the lease TTL. busy_timeout on
|
|
961
|
+
// the IMMEDIATE lock backstops every individual claim attempt.
|
|
962
|
+
const acquireWindowMs = Math.min(ACQUIRE_DEADLINE_MS, Math.max(1, ttlMs));
|
|
963
|
+
const deadline = Date.now() + acquireWindowMs;
|
|
964
|
+
let enqueued = false;
|
|
965
|
+
let epoch = null;
|
|
966
|
+
for (;;) {
|
|
967
|
+
epoch = tryClaimOnce(nativeDb, scope, lane, holderId, ttlMs);
|
|
968
|
+
if (epoch !== null) {
|
|
969
|
+
if (enqueued)
|
|
970
|
+
dequeueWaiter(nativeDb, scope, lane, holderId);
|
|
971
|
+
break;
|
|
972
|
+
}
|
|
973
|
+
if (!enqueued) {
|
|
974
|
+
enqueueWaiter(nativeDb, scope, lane, holderId, priority, ttlMs);
|
|
975
|
+
enqueued = true;
|
|
976
|
+
}
|
|
977
|
+
if (Date.now() >= deadline) {
|
|
978
|
+
if (enqueued)
|
|
979
|
+
dequeueWaiter(nativeDb, scope, lane, holderId);
|
|
980
|
+
if (mode === 'require') {
|
|
981
|
+
throw new LeaseUnavailableError(scope, lane, 'cold-open: live holder did not release within deadline');
|
|
982
|
+
}
|
|
983
|
+
// local/supervisor: degrade to today's behaviour — run the cold-open under
|
|
984
|
+
// busy_timeout, which still serializes the migrate write-txn.
|
|
985
|
+
log().warn({ scope }, 'cold-open writer-lease acquire deadline exceeded; proceeding under ' +
|
|
986
|
+
'busy_timeout fallback (degraded)');
|
|
987
|
+
return fn();
|
|
988
|
+
}
|
|
989
|
+
await sleepAsync(CLAIM_RETRY_DELAY_MS);
|
|
990
|
+
}
|
|
991
|
+
// Held — run the cold-open body, then free the row (epoch-guarded) on the way out.
|
|
992
|
+
try {
|
|
993
|
+
return await fn();
|
|
994
|
+
}
|
|
995
|
+
finally {
|
|
996
|
+
nativeDb
|
|
997
|
+
.prepare(`UPDATE ${WRITER_LEASES_TABLE} SET active = 0, reentrancy_depth = 0 ` +
|
|
998
|
+
`WHERE scope = ? AND lane = ? AND holder_id = ? AND epoch = ? AND active = 1`)
|
|
999
|
+
.run(scope, lane, holderId, epoch);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
//# sourceMappingURL=writer-lease.js.map
|