@gajae-code/coding-agent 0.18.7 → 0.18.9
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/CHANGELOG.md +377 -1
- package/dist/types/async/endpoint-id.d.ts +16 -0
- package/dist/types/async/job-manager.d.ts +1 -1
- package/dist/types/async/support.d.ts +0 -14
- package/dist/types/capability/mcp.d.ts +2 -0
- package/dist/types/capability/skill.d.ts +2 -0
- package/dist/types/capability/types.d.ts +2 -0
- package/dist/types/cli/public-command-errors.d.ts +3 -1
- package/dist/types/cli/setup-cli.d.ts +1 -1
- package/dist/types/commands/deep-interview.d.ts +3 -0
- package/dist/types/commands/mcp-serve.d.ts +4 -0
- package/dist/types/config/autorouting-tier-map.d.ts +348 -9
- package/dist/types/config/file-lock.d.ts +1 -0
- package/dist/types/config/model-bindings-applier.d.ts +2 -0
- package/dist/types/config/model-profile-activation.d.ts +11 -3
- package/dist/types/config/model-registry.d.ts +4 -9
- package/dist/types/config/skill-settings-defaults.d.ts +2 -0
- package/dist/types/cursor-delete-path.d.ts +1 -0
- package/dist/types/cursor-mcp-name.d.ts +8 -0
- package/dist/types/customization/inventory.d.ts +2 -0
- package/dist/types/debug/report-bundle-parent.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.d.ts +31 -0
- package/dist/types/defaults/gjc-skills.generated.d.ts +5 -6
- package/dist/types/discovery/helpers.d.ts +6 -1
- package/dist/types/discovery/mcp-json.d.ts +1 -0
- package/dist/types/eval/py/kernel.d.ts +18 -0
- package/dist/types/export/html/css-color.d.ts +2 -0
- package/dist/types/export/html/html-export-destination.d.ts +2 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +5 -0
- package/dist/types/extensibility/extensions/types.d.ts +7 -0
- package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +14 -1
- package/dist/types/extensibility/plugins/marketplace/cache.d.ts +2 -0
- package/dist/types/extensibility/plugins/plugin-link-path.d.ts +2 -0
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +3 -1
- package/dist/types/extensibility/skill-management.d.ts +4 -1
- package/dist/types/extensibility/skills.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-crystallize.d.ts +74 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +11 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -3
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +0 -5
- package/dist/types/gjc-runtime/sidecar-bootstrap-env.d.ts +6 -0
- package/dist/types/gjc-runtime/state-argv.d.ts +1 -1
- package/dist/types/gjc-runtime/state-migrations.d.ts +8 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +95 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +14 -1
- package/dist/types/gjc-runtime/tmux-common.d.ts +8 -5
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -0
- package/dist/types/internal-urls/skill-path.d.ts +1 -0
- package/dist/types/lsp/config.d.ts +8 -5
- package/dist/types/lsp/workspace-path.d.ts +1 -0
- package/dist/types/modes/acp/acp-agent.d.ts +7 -0
- package/dist/types/modes/components/custom-editor.d.ts +6 -0
- package/dist/types/modes/components/customization/customization-dashboard.d.ts +1 -0
- package/dist/types/modes/components/extensions/state-manager.d.ts +4 -3
- package/dist/types/modes/controllers/input-controller.d.ts +9 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/utils/interrupt-ownership.d.ts +3 -0
- package/dist/types/modes/utils/keybinding-matchers.d.ts +1 -0
- package/dist/types/modes/utils/macos-option-forwarding.d.ts +17 -0
- package/dist/types/progress/collect-project-progress.d.ts +38 -0
- package/dist/types/progress/progress-contract.d.ts +154 -0
- package/dist/types/progress/progress-snapshot.d.ts +11 -0
- package/dist/types/progress/project-progress.d.ts +141 -0
- package/dist/types/progress/render-progress.d.ts +28 -0
- package/dist/types/runtime-mcp/manager.d.ts +2 -1
- package/dist/types/runtime-mcp/mcp-redirect.d.ts +9 -0
- package/dist/types/runtime-mcp/oauth-public-url.d.ts +3 -0
- package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -0
- package/dist/types/runtime-mcp/project-host-values.d.ts +27 -0
- package/dist/types/runtime-mcp/smithery-registry.d.ts +24 -0
- package/dist/types/runtime-mcp/types.d.ts +6 -0
- package/dist/types/sdk/acp/adapter.d.ts +1 -1
- package/dist/types/sdk/broker/broker-exit.d.ts +6 -2
- package/dist/types/sdk/broker/broker.d.ts +24 -2
- package/dist/types/sdk/broker/doctor-restart.d.ts +5 -0
- package/dist/types/sdk/broker/ensure.d.ts +14 -1
- package/dist/types/sdk/broker/session-index.d.ts +12 -6
- package/dist/types/sdk/bus/index.d.ts +12 -1
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -1
- package/dist/types/sdk/client/client.d.ts +2 -0
- package/dist/types/sdk/host/query/handlers.d.ts +3 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +2 -0
- package/dist/types/sdk/session-list.d.ts +1 -1
- package/dist/types/sdk/session.d.ts +8 -0
- package/dist/types/session/agent-session.d.ts +38 -9
- package/dist/types/session/artifacts.d.ts +2 -2
- package/dist/types/session/internal/managed-gc-retirement-codec.d.ts +32 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +79 -2
- package/dist/types/session/internal/managed-session-storage.d.ts +26 -1
- package/dist/types/session/internal/managed-task-owner-cleanup.d.ts +62 -0
- package/dist/types/session/internal/task-artifact-owner-access.d.ts +2 -0
- package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +9 -1
- package/dist/types/session/session-manager.d.ts +31 -5
- package/dist/types/session/session-retirement.d.ts +31 -7
- package/dist/types/session/session-storage.d.ts +69 -2
- package/dist/types/session/task-artifact-owner-codec.d.ts +2 -0
- package/dist/types/setup/credential-auto-import.d.ts +1 -1
- package/dist/types/setup/credential-import.d.ts +6 -4
- package/dist/types/skill-state/active-state.d.ts +38 -0
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +3 -0
- package/dist/types/slash-commands/helpers/progress-report.d.ts +8 -0
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/task/isolation-dir.d.ts +26 -0
- package/dist/types/tools/ast-edit-write-path.d.ts +13 -0
- package/dist/types/tools/bash-skill-urls.d.ts +1 -1
- package/dist/types/tools/browser/cdp-url.d.ts +2 -0
- package/dist/types/tools/github-side-effect.d.ts +22 -0
- package/dist/types/tools/index.d.ts +3 -0
- package/dist/types/tools/monitor-mutation.d.ts +9 -0
- package/dist/types/tools/python.d.ts +9 -1
- package/dist/types/tools/recipe/recipe-guard.d.ts +7 -0
- package/dist/types/tools/restricted-cwd.d.ts +1 -0
- package/dist/types/tools/todo-write.d.ts +6 -0
- package/dist/types/utils/tools-checksum.d.ts +7 -0
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +21 -0
- package/scripts/verify-managed-empty-stop.ts +28 -0
- package/src/async/endpoint-id.ts +26 -0
- package/src/async/job-manager.ts +247 -132
- package/src/async/support.ts +0 -25
- package/src/capability/index.ts +15 -0
- package/src/capability/mcp.ts +2 -0
- package/src/capability/skill.ts +2 -0
- package/src/capability/types.ts +2 -0
- package/src/cli/auth-broker-cli.ts +1 -1
- package/src/cli/customize-doctor.ts +15 -2
- package/src/cli/doctor/repairs.ts +4 -1
- package/src/cli/doctor-supervisor.ts +3 -0
- package/src/cli/public-command-errors.ts +21 -1
- package/src/cli/public-command-registry.ts +28 -6
- package/src/cli/setup-cli.ts +4 -4
- package/src/cli/skills-cli.ts +5 -0
- package/src/commands/deep-interview.ts +47 -8
- package/src/commands/mcp-serve.ts +7 -0
- package/src/commands/sdk.ts +455 -397
- package/src/config/autorouting-tier-map.ts +117 -7
- package/src/config/file-lock.ts +316 -79
- package/src/config/mcp-schema.json +4 -0
- package/src/config/model-bindings-applier.ts +20 -0
- package/src/config/model-profile-activation.ts +240 -46
- package/src/config/model-registry.ts +47 -153
- package/src/config/skill-settings-defaults.ts +7 -0
- package/src/coordinator-mcp/policy.ts +3 -2
- package/src/coordinator-mcp/server.ts +2 -0
- package/src/cursor-delete-path.ts +10 -0
- package/src/cursor-mcp-name.ts +15 -0
- package/src/cursor.ts +13 -2
- package/src/customization/inventory.ts +4 -13
- package/src/dap/config.ts +2 -2
- package/src/debug/report-bundle-parent.ts +22 -0
- package/src/debug/report-bundle.ts +3 -0
- package/src/debug/system-info.ts +13 -1
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +10 -6
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +67 -4
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.ts +80 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +105 -10
- package/src/defaults/gjc/skills/ralplan/SKILL.md +6 -4
- package/src/defaults/gjc-defaults.test.ts +18 -1
- package/src/defaults/gjc-defaults.ts +3 -0
- package/src/defaults/gjc-skills.generated.ts +58 -36
- package/src/discovery/agents.ts +6 -1
- package/src/discovery/builtin.ts +13 -1
- package/src/discovery/helpers.ts +171 -29
- package/src/discovery/mcp-json.ts +26 -12
- package/src/discovery/ssh.ts +1 -1
- package/src/discovery/substitute-plugin-root.ts +3 -1
- package/src/edit/file-read-cache.ts +1 -1
- package/src/eval/py/executor.ts +796 -154
- package/src/eval/py/kernel.ts +79 -13
- package/src/eval/py/tool-bridge.ts +4 -0
- package/src/export/html/css-color.ts +9 -0
- package/src/export/html/html-export-destination.ts +16 -0
- package/src/export/html/index.ts +7 -10
- package/src/export/html/template.generated.ts +1 -1
- package/src/extensibility/extensions/function-hooks-internal.ts +27 -0
- package/src/extensibility/extensions/runner.ts +15 -1
- package/src/extensibility/extensions/types.ts +7 -0
- package/src/extensibility/gjc-plugins/mcp-policy.ts +2 -1
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +85 -8
- package/src/extensibility/plugins/manager.ts +3 -2
- package/src/extensibility/plugins/marketplace/cache.ts +15 -0
- package/src/extensibility/plugins/marketplace/manager.ts +11 -3
- package/src/extensibility/plugins/plugin-link-path.ts +12 -0
- package/src/extensibility/runtime-skill-discovery.ts +20 -2
- package/src/extensibility/skill-management.ts +9 -2
- package/src/extensibility/skills.ts +6 -1
- package/src/gjc-runtime/deep-interview-crystallize.ts +1814 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +20 -2
- package/src/gjc-runtime/deep-interview-runtime.ts +1415 -13
- package/src/gjc-runtime/deep-interview-stage.ts +98 -21
- package/src/gjc-runtime/gc-runtime.ts +206 -9
- package/src/gjc-runtime/launch-worktree.ts +70 -9
- package/src/gjc-runtime/psmux-detect.ts +28 -4
- package/src/gjc-runtime/ralplan-runtime.ts +447 -52
- package/src/gjc-runtime/session-state-sidecar.ts +3 -2
- package/src/gjc-runtime/sidecar-bootstrap-env.ts +19 -0
- package/src/gjc-runtime/state-argv.ts +2 -0
- package/src/gjc-runtime/state-migrations.ts +91 -29
- package/src/gjc-runtime/state-runtime.ts +3716 -135
- package/src/gjc-runtime/state-writer.ts +105 -16
- package/src/gjc-runtime/tmux-common.ts +8 -5
- package/src/gjc-runtime/ultragoal-runtime.ts +16 -4
- package/src/gjc-runtime/workflow-manifest.generated.json +70 -42
- package/src/gjc-runtime/workflow-manifest.ts +54 -21
- package/src/harness-control-plane/operate.ts +12 -4
- package/src/harness-control-plane/owner.ts +5 -1
- package/src/hashline/parser.ts +48 -9
- package/src/hashline/recovery.ts +13 -4
- package/src/internal-urls/docs-index.generated.ts +20 -19
- package/src/internal-urls/skill-path.ts +19 -0
- package/src/internal-urls/skill-protocol.ts +2 -0
- package/src/lsp/config.ts +12 -63
- package/src/lsp/edits.ts +6 -0
- package/src/lsp/workspace-path.ts +51 -0
- package/src/main.ts +195 -31
- package/src/memories/index.ts +88 -7
- package/src/modes/acp/acp-agent.ts +88 -4
- package/src/modes/components/custom-editor.ts +14 -0
- package/src/modes/components/customization/customization-dashboard.ts +12 -0
- package/src/modes/components/extensions/extension-dashboard.ts +2 -2
- package/src/modes/components/extensions/state-manager.ts +15 -5
- package/src/modes/components/hook-editor.ts +1 -1
- package/src/modes/components/hook-selector.ts +1 -1
- package/src/modes/components/model-selector.ts +1 -4
- package/src/modes/components/provider-onboarding-selector.ts +1 -1
- package/src/modes/components/status-line/segments.ts +4 -0
- package/src/modes/controllers/event-controller.ts +11 -4
- package/src/modes/controllers/input-controller.ts +76 -31
- package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -16
- package/src/modes/controllers/selector-controller.ts +24 -15
- package/src/modes/controllers/todo-command-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +5 -1
- package/src/modes/runtime-init.ts +4 -0
- package/src/modes/utils/abort-message.ts +3 -1
- package/src/modes/utils/interrupt-ownership.ts +12 -0
- package/src/modes/utils/keybinding-matchers.ts +4 -0
- package/src/modes/utils/macos-option-forwarding.ts +80 -0
- package/src/progress/collect-project-progress.ts +153 -0
- package/src/progress/progress-contract.ts +172 -0
- package/src/progress/progress-snapshot.ts +140 -0
- package/src/progress/project-progress.ts +446 -0
- package/src/progress/render-progress.ts +209 -0
- package/src/prompts/system/master-mode.md +1 -0
- package/src/prompts/tools/skill-discovery.md +1 -1
- package/src/runtime-mcp/client.ts +4 -2
- package/src/runtime-mcp/config-writer.ts +10 -6
- package/src/runtime-mcp/config.ts +1 -0
- package/src/runtime-mcp/json-rpc.ts +2 -1
- package/src/runtime-mcp/manager.ts +29 -11
- package/src/runtime-mcp/mcp-redirect.ts +73 -0
- package/src/runtime-mcp/oauth-flow.ts +14 -2
- package/src/runtime-mcp/oauth-public-url.ts +14 -0
- package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
- package/src/runtime-mcp/pool.ts +6 -0
- package/src/runtime-mcp/project-host-values.ts +46 -0
- package/src/runtime-mcp/smithery-registry.ts +22 -3
- package/src/runtime-mcp/transports/http.ts +4 -3
- package/src/runtime-mcp/types.ts +6 -0
- package/src/sdk/acp/adapter.ts +95 -8
- package/src/sdk/broker/broker-exit.ts +52 -23
- package/src/sdk/broker/broker.ts +81 -6
- package/src/sdk/broker/doctor-restart.ts +4 -0
- package/src/sdk/broker/ensure.ts +31 -2
- package/src/sdk/broker/lifecycle.ts +711 -66
- package/src/sdk/broker/session-index.ts +191 -81
- package/src/sdk/bus/chat-daemon-cli.ts +38 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +238 -43
- package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
- package/src/sdk/cli/session-cli.ts +17 -3
- package/src/sdk/client/client.ts +28 -8
- package/src/sdk/host/query/handlers.ts +9 -1
- package/src/sdk/host/session-runtime.test.ts +2705 -303
- package/src/sdk/host/session-runtime.ts +490 -143
- package/src/sdk/host/surface-policy.ts +1 -0
- package/src/sdk/index.ts +8 -0
- package/src/sdk/lifecycle-session.ts +3 -1
- package/src/sdk/prompt-deadline-manager.ts +15 -0
- package/src/sdk/protocol/operation-inventory.generated.json +146 -0
- package/src/sdk/protocol/operation-registry.ts +9 -2
- package/src/sdk/router/session-router.ts +82 -18
- package/src/sdk/session-list.ts +1 -0
- package/src/sdk/session.ts +69 -45
- package/src/session/agent-session.ts +679 -238
- package/src/session/artifacts.ts +176 -71
- package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
- package/src/session/internal/managed-session-scope.ts +3779 -462
- package/src/session/internal/managed-session-storage.ts +501 -78
- package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
- package/src/session/internal/task-artifact-owner-access.ts +19 -3
- package/src/session/internal/task-artifact-owner-transcript.ts +547 -1
- package/src/session/session-manager.ts +951 -133
- package/src/session/session-retirement.ts +573 -14
- package/src/session/session-storage.ts +447 -15
- package/src/session/task-artifact-owner-codec.ts +71 -8
- package/src/session/task-artifact-owner-retirement.ts +21 -19
- package/src/setup/credential-auto-import.ts +12 -3
- package/src/setup/credential-import.ts +176 -5
- package/src/setup/paseo/announce.ts +6 -1
- package/src/skill-state/active-state.ts +145 -2
- package/src/skill-state/workflow-mutation-guard.ts +102 -10
- package/src/slash-commands/builtin-registry.ts +39 -9
- package/src/slash-commands/helpers/mcp.ts +8 -4
- package/src/slash-commands/helpers/progress-report.ts +23 -0
- package/src/stt/setup.ts +3 -5
- package/src/task/commands.ts +3 -1
- package/src/task/executor.ts +4 -0
- package/src/task/index.ts +6 -0
- package/src/task/isolation-dir.ts +143 -0
- package/src/task/worktree.ts +16 -7
- package/src/tools/ask.ts +205 -6
- package/src/tools/ast-edit-write-path.ts +68 -0
- package/src/tools/ast-edit.ts +10 -1
- package/src/tools/bash-allowed-prefixes.ts +15 -0
- package/src/tools/bash-skill-urls.ts +56 -3
- package/src/tools/bash.ts +48 -79
- package/src/tools/browser/cdp-url.ts +18 -0
- package/src/tools/browser.ts +8 -1
- package/src/tools/descriptors.ts +5 -1
- package/src/tools/eval.ts +56 -36
- package/src/tools/gh.ts +10 -0
- package/src/tools/github-side-effect.ts +112 -0
- package/src/tools/image-gen.ts +1 -1
- package/src/tools/index.ts +3 -0
- package/src/tools/monitor-mutation.ts +27 -0
- package/src/tools/monitor.ts +7 -0
- package/src/tools/python.ts +188 -66
- package/src/tools/recipe/index.ts +9 -0
- package/src/tools/recipe/recipe-guard.ts +19 -0
- package/src/tools/restricted-cwd.ts +10 -0
- package/src/tools/skill-discovery.ts +5 -0
- package/src/tools/skill.ts +28 -1
- package/src/tools/todo-write.ts +10 -1
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/tools/write.ts +1 -0
- package/src/utils/external-editor.ts +3 -2
- package/src/utils/tools-checksum.ts +34 -0
- package/src/utils/tools-manager.ts +24 -6
- package/src/web/insane/url-guard.ts +2 -1
- package/src/web/search/providers/codex.ts +3 -2
package/src/config/file-lock.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
} from "@gajae-code/natives";
|
|
13
13
|
import {
|
|
14
14
|
exactRestore,
|
|
15
|
+
exactUnlinkDirect,
|
|
15
16
|
openRecoveryFsRoot,
|
|
16
17
|
renameDirectoryNoReplacePathAsync,
|
|
17
18
|
renameNoReplacePathAsync,
|
|
@@ -66,7 +67,8 @@ export class FileLockAcquireError extends Error {
|
|
|
66
67
|
readonly orphanPath?: string,
|
|
67
68
|
readonly removalFailure?: FileLockStaleRemovalFailure,
|
|
68
69
|
) {
|
|
69
|
-
const detail =
|
|
70
|
+
const detail =
|
|
71
|
+
reason === "orphan_transition" && orphanPath ? `blocked by retained removal transition ${orphanPath}` : holder;
|
|
70
72
|
const removalDetail = removalFailure
|
|
71
73
|
? ` The dead owner's lock directory could not be reaped on this host (${removalFailure.message}${
|
|
72
74
|
removalFailure.code ? ` [${removalFailure.code}]` : ""
|
|
@@ -105,6 +107,9 @@ const DEFAULT_OPTIONS: Required<
|
|
|
105
107
|
const PUBLICATION_SHARING_RETRY_ATTEMPTS = 3;
|
|
106
108
|
const PUBLICATION_SHARING_RETRY_DELAY_MS = 10;
|
|
107
109
|
|
|
110
|
+
/** Ownerless empty residue must outlive both this grace and the acquire budget. */
|
|
111
|
+
const REMOVAL_TRANSITION_GRACE_MS = 10_000;
|
|
112
|
+
|
|
108
113
|
/** Release retries cover transient handle denial and a competing exact-removal quarantine cleanup. */
|
|
109
114
|
export const FILE_LOCK_RELEASE_RETRY_ATTEMPTS = 20;
|
|
110
115
|
export const FILE_LOCK_RELEASE_RETRY_DELAY_MS = 25;
|
|
@@ -127,6 +132,7 @@ type LockInfo = FileLockOwnerToken;
|
|
|
127
132
|
|
|
128
133
|
export const FileLockTestHooks: {
|
|
129
134
|
afterParentMkdir?: (lockPath: string) => void | Promise<void>;
|
|
135
|
+
beforeRemovalOwnerPublish?: (transitionPath: string) => void | Promise<void>;
|
|
130
136
|
nativePublicationBindings?: () => {
|
|
131
137
|
renameNoReplacePathAsync: typeof renameNoReplacePathAsync;
|
|
132
138
|
renameDirectoryNoReplacePathAsync: typeof renameDirectoryNoReplacePathAsync;
|
|
@@ -258,6 +264,19 @@ type LockInfoPathState = {
|
|
|
258
264
|
file: LockInfoFileState;
|
|
259
265
|
};
|
|
260
266
|
|
|
267
|
+
/** Bun/libuv may expose Windows file IDs as i64; native snapshots use u64. */
|
|
268
|
+
function canonicalLockFileId(value: bigint): bigint {
|
|
269
|
+
if (value < -(1n << 63n) || value > (1n << 64n) - 1n) throw new Error("file_lock_identity_out_of_range");
|
|
270
|
+
return BigInt.asUintN(64, value);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Windows volume serials are u32 even when Bun returns a signed `stat.dev`. */
|
|
274
|
+
function canonicalLockDeviceId(value: bigint): bigint {
|
|
275
|
+
if (process.platform !== "win32") return canonicalLockFileId(value);
|
|
276
|
+
if (value < -(1n << 31n) || value > (1n << 32n) - 1n) throw new Error("file_lock_identity_out_of_range");
|
|
277
|
+
return BigInt.asUintN(32, value);
|
|
278
|
+
}
|
|
279
|
+
|
|
261
280
|
function lockInfoFileState(stats: BigIntStats): LockInfoFileState | null {
|
|
262
281
|
if (stats.isSymbolicLink() || !stats.isFile()) return null;
|
|
263
282
|
// Some supported filesystems report creation time as missing or epoch zero. Keep
|
|
@@ -265,8 +284,8 @@ function lockInfoFileState(stats: BigIntStats): LockInfoFileState | null {
|
|
|
265
284
|
// acquire/release/GC on a metadata field the filesystem cannot provide.
|
|
266
285
|
const birthtimeNs = typeof stats.birthtimeNs === "bigint" && stats.birthtimeNs > 0n ? stats.birthtimeNs : 0n;
|
|
267
286
|
return {
|
|
268
|
-
dev: stats.dev,
|
|
269
|
-
ino: stats.ino,
|
|
287
|
+
dev: canonicalLockDeviceId(stats.dev),
|
|
288
|
+
ino: canonicalLockFileId(stats.ino),
|
|
270
289
|
mode: stats.mode,
|
|
271
290
|
size: stats.size,
|
|
272
291
|
mtimeNs: stats.mtimeNs,
|
|
@@ -310,7 +329,7 @@ async function lockInfoPathState(lockPath: string): Promise<LockInfoPathState |
|
|
|
310
329
|
const file = lockInfoFileState(info);
|
|
311
330
|
if (!file) return null;
|
|
312
331
|
return {
|
|
313
|
-
root: { dev: root.dev, ino: root.ino, mode: root.mode },
|
|
332
|
+
root: { dev: canonicalLockDeviceId(root.dev), ino: canonicalLockFileId(root.ino), mode: root.mode },
|
|
314
333
|
file,
|
|
315
334
|
};
|
|
316
335
|
}
|
|
@@ -326,10 +345,10 @@ function sameLockInfoPathState(left: LockInfoPathState, right: LockInfoPathState
|
|
|
326
345
|
|
|
327
346
|
function fileLockDirIdentityFromPathState(state: LockInfoPathState, bytes: string): GenericFileLockDirIdentity {
|
|
328
347
|
return {
|
|
329
|
-
rootDev:
|
|
330
|
-
rootIno:
|
|
331
|
-
infoDev:
|
|
332
|
-
infoIno:
|
|
348
|
+
rootDev: canonicalLockDeviceId(state.root.dev).toString(),
|
|
349
|
+
rootIno: canonicalLockFileId(state.root.ino).toString(),
|
|
350
|
+
infoDev: canonicalLockDeviceId(state.file.dev).toString(),
|
|
351
|
+
infoIno: canonicalLockFileId(state.file.ino).toString(),
|
|
333
352
|
infoNlink: String(state.file.nlink),
|
|
334
353
|
infoSize: String(state.file.size),
|
|
335
354
|
infoMtimeNs: String(state.file.mtimeNs),
|
|
@@ -539,8 +558,8 @@ async function removeDetachedLockQuarantineOnDisk(
|
|
|
539
558
|
if (
|
|
540
559
|
!current.isDirectory() ||
|
|
541
560
|
current.isSymbolicLink() ||
|
|
542
|
-
current.dev.toString() !== rootDev ||
|
|
543
|
-
current.ino.toString() !== rootIno
|
|
561
|
+
canonicalLockDeviceId(current.dev).toString() !== rootDev ||
|
|
562
|
+
canonicalLockFileId(current.ino).toString() !== rootIno
|
|
544
563
|
) {
|
|
545
564
|
return false;
|
|
546
565
|
}
|
|
@@ -556,8 +575,8 @@ async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boo
|
|
|
556
575
|
if (
|
|
557
576
|
!current.isDirectory() ||
|
|
558
577
|
current.isSymbolicLink() ||
|
|
559
|
-
current.dev.toString() !== pending.rootDev ||
|
|
560
|
-
current.ino.toString() !== pending.rootIno
|
|
578
|
+
canonicalLockDeviceId(current.dev).toString() !== pending.rootDev ||
|
|
579
|
+
canonicalLockFileId(current.ino).toString() !== pending.rootIno
|
|
561
580
|
) {
|
|
562
581
|
throw new Error("Detached file lock cleanup identity changed; refusing removal");
|
|
563
582
|
}
|
|
@@ -576,6 +595,7 @@ async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boo
|
|
|
576
595
|
if (!isEnoent(error)) throw error;
|
|
577
596
|
}
|
|
578
597
|
pendingDetachedLockCleanups.delete(owner);
|
|
598
|
+
await clearRemovalRecord(pending.path);
|
|
579
599
|
return true;
|
|
580
600
|
}
|
|
581
601
|
|
|
@@ -587,10 +607,57 @@ function fileLockRemovalTransitionPath(lockPath: string): string {
|
|
|
587
607
|
return `${lockPath}.removing`;
|
|
588
608
|
}
|
|
589
609
|
|
|
610
|
+
type RemovalTransitionOwnerRecord = {
|
|
611
|
+
owner: FileLockOwnerToken;
|
|
612
|
+
rootDev: string;
|
|
613
|
+
rootIno: string;
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const localRemovalRecords = new Map<string, { bytes: string; resumable: boolean }>();
|
|
617
|
+
|
|
618
|
+
function sameRemovalRecordBytes(left: string, right: string): boolean {
|
|
619
|
+
try {
|
|
620
|
+
const a = JSON.parse(left) as RemovalTransitionOwnerRecord;
|
|
621
|
+
const b = JSON.parse(right) as RemovalTransitionOwnerRecord;
|
|
622
|
+
const aOwner = parseLockInfoBytes(JSON.stringify(a.owner));
|
|
623
|
+
const bOwner = parseLockInfoBytes(JSON.stringify(b.owner));
|
|
624
|
+
return (
|
|
625
|
+
aOwner !== null &&
|
|
626
|
+
bOwner !== null &&
|
|
627
|
+
a.rootDev === b.rootDev &&
|
|
628
|
+
a.rootIno === b.rootIno &&
|
|
629
|
+
JSON.stringify(aOwner) === JSON.stringify(bOwner)
|
|
630
|
+
);
|
|
631
|
+
} catch {
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function isResumableLocalRemoval(transitionPath: string, record: RemovalTransitionOwnerRecord): boolean {
|
|
637
|
+
const local = localRemovalRecords.get(`${transitionPath}.owner`);
|
|
638
|
+
return local?.resumable === true && sameRemovalRecordBytes(local.bytes, JSON.stringify(record));
|
|
639
|
+
}
|
|
640
|
+
|
|
590
641
|
type FileLockRemovalTransitionState = "active" | "abandoned" | "orphan_transition";
|
|
591
642
|
type FileLockOrphanTransition = { kind: "orphan_transition"; path: string };
|
|
592
643
|
type FileLockAcquisitionResult = LockInfo | FileLockOrphanTransition | null;
|
|
593
644
|
|
|
645
|
+
async function readRemovalTransitionOwnerRecord(transitionPath: string): Promise<RemovalTransitionOwnerRecord | null> {
|
|
646
|
+
try {
|
|
647
|
+
const stat = await fs.lstat(`${transitionPath}.owner`);
|
|
648
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return null;
|
|
649
|
+
const record = JSON.parse(await Bun.file(`${transitionPath}.owner`).text()) as RemovalTransitionOwnerRecord;
|
|
650
|
+
const owner = parseLockInfoBytes(JSON.stringify(record.owner));
|
|
651
|
+
return owner && typeof record.rootDev === "string" && typeof record.rootIno === "string"
|
|
652
|
+
? { owner, rootDev: record.rootDev, rootIno: record.rootIno }
|
|
653
|
+
: null;
|
|
654
|
+
} catch (error) {
|
|
655
|
+
if (isEnoent(error)) return null;
|
|
656
|
+
logger.debug("Failed to read removal transition owner record", { error });
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
594
661
|
async function classifyFileLockRemovalTransition(
|
|
595
662
|
lockPath: string,
|
|
596
663
|
orphanAgeMs: number,
|
|
@@ -598,16 +665,51 @@ async function classifyFileLockRemovalTransition(
|
|
|
598
665
|
previousOwnerHostIds: readonly string[] = [],
|
|
599
666
|
): Promise<FileLockRemovalTransitionState | null> {
|
|
600
667
|
const transitionPath = fileLockRemovalTransitionPath(lockPath);
|
|
668
|
+
let transition: BigIntStats;
|
|
601
669
|
try {
|
|
602
|
-
|
|
670
|
+
transition = await fs.lstat(transitionPath, { bigint: true });
|
|
603
671
|
if (!transition.isDirectory() || transition.isSymbolicLink()) return "active";
|
|
604
672
|
} catch (error) {
|
|
605
673
|
if (isEnoent(error)) return null;
|
|
606
674
|
throw error;
|
|
607
675
|
}
|
|
608
676
|
|
|
677
|
+
// A sibling record survives rename and scrub without mutating the captured tree.
|
|
678
|
+
const recordedOwner = await readRemovalTransitionOwnerRecord(transitionPath);
|
|
679
|
+
if (recordedOwner) {
|
|
680
|
+
if (
|
|
681
|
+
recordedOwner.owner.owner_host_id !== undefined &&
|
|
682
|
+
lockRecordIsForeignHost(recordedOwner.owner, ownerHostId, previousOwnerHostIds)
|
|
683
|
+
)
|
|
684
|
+
return "active";
|
|
685
|
+
if (
|
|
686
|
+
canonicalLockDeviceId(transition.dev).toString() !== recordedOwner.rootDev ||
|
|
687
|
+
canonicalLockFileId(transition.ino).toString() !== recordedOwner.rootIno
|
|
688
|
+
)
|
|
689
|
+
return "active";
|
|
690
|
+
if (isResumableLocalRemoval(transitionPath, recordedOwner)) return "orphan_transition"; // The remover itself may resume its own interrupted cleanup.
|
|
691
|
+
return ownerIsAlive(recordedOwner.owner) || ownerLiveness(recordedOwner.owner.pid) === "unknown"
|
|
692
|
+
? "active"
|
|
693
|
+
: "orphan_transition";
|
|
694
|
+
}
|
|
695
|
+
try {
|
|
696
|
+
await fs.lstat(`${transitionPath}.owner`);
|
|
697
|
+
return "active";
|
|
698
|
+
} catch (error) {
|
|
699
|
+
if (!isEnoent(error)) return "active";
|
|
700
|
+
}
|
|
701
|
+
|
|
609
702
|
const observation = await readLockInfoObservation(transitionPath);
|
|
610
|
-
if (!observation)
|
|
703
|
+
if (!observation) {
|
|
704
|
+
try {
|
|
705
|
+
if (Date.now() - Number(transition.mtimeMs) >= orphanAgeMs && (await fs.readdir(transitionPath)).length === 0)
|
|
706
|
+
return "orphan_transition";
|
|
707
|
+
} catch (error) {
|
|
708
|
+
if (isEnoent(error)) return null;
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
711
|
+
return "active";
|
|
712
|
+
}
|
|
611
713
|
const info = parseLockInfoBytes(observation.bytes);
|
|
612
714
|
if (info) {
|
|
613
715
|
const stale = await staleLockSnapshot(transitionPath, 0, ownerHostId, previousOwnerHostIds, undefined, true);
|
|
@@ -622,46 +724,152 @@ function isFileLockOrphanTransition(value: FileLockAcquisitionResult): value is
|
|
|
622
724
|
return value !== null && "kind" in value && value.kind === "orphan_transition";
|
|
623
725
|
}
|
|
624
726
|
|
|
625
|
-
/**
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
function
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
727
|
+
/** Without an owner, only an aged, completely empty directory is reclaimable. */
|
|
728
|
+
function isEmptyRemovalTransition(snapshot: NativeDirectoryTreeSnapshot, graceMs: number): boolean {
|
|
729
|
+
const root = snapshot.entries[0];
|
|
730
|
+
if (snapshot.entries.length !== 1 || root?.relativePath !== "" || root.kind !== "directory") return false;
|
|
731
|
+
const ageMs = Date.now() - Number(root.mtimeNs) / 1_000_000;
|
|
732
|
+
return Number.isFinite(ageMs) && ageMs >= graceMs;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function removeRemovalRecord(recordPath: string, expectedBytes: string): Promise<boolean> {
|
|
736
|
+
try {
|
|
737
|
+
const stat = await fs.lstat(recordPath, { bigint: true });
|
|
738
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return false;
|
|
739
|
+
const bytes = await Bun.file(recordPath).text();
|
|
740
|
+
if (!sameRemovalRecordBytes(bytes, expectedBytes)) return false;
|
|
741
|
+
return exactUnlinkDirect(recordPath, {
|
|
742
|
+
dev: canonicalLockDeviceId(stat.dev),
|
|
743
|
+
ino: canonicalLockFileId(stat.ino),
|
|
744
|
+
size: stat.size,
|
|
745
|
+
mtimeNs: stat.mtimeNs,
|
|
746
|
+
nlink: stat.nlink,
|
|
747
|
+
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
748
|
+
quarantineName: `${path.basename(recordPath)}.retired.${crypto.randomUUID()}`,
|
|
749
|
+
}).ok;
|
|
750
|
+
} catch (error) {
|
|
751
|
+
if (isEnoent(error)) return true;
|
|
752
|
+
throw error;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function clearRemovalRecord(transitionPath: string): Promise<void> {
|
|
757
|
+
const recordPath = `${transitionPath}.owner`;
|
|
758
|
+
const bytes = localRemovalRecords.get(recordPath)?.bytes;
|
|
759
|
+
if (!bytes) return;
|
|
760
|
+
try {
|
|
761
|
+
await fs.lstat(transitionPath);
|
|
762
|
+
return;
|
|
763
|
+
} catch (error) {
|
|
764
|
+
if (!isEnoent(error)) throw error;
|
|
765
|
+
}
|
|
766
|
+
if (await removeRemovalRecord(recordPath, bytes)) localRemovalRecords.delete(recordPath);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function publishRemovalRecord(
|
|
770
|
+
transitionPath: string,
|
|
771
|
+
snapshot: NativeDirectoryTreeSnapshot,
|
|
772
|
+
ownerHostId?: string,
|
|
773
|
+
resumable = false,
|
|
774
|
+
): Promise<boolean> {
|
|
775
|
+
const recordPath = `${transitionPath}.owner`;
|
|
776
|
+
if (localRemovalRecords.has(recordPath)) return false;
|
|
777
|
+
const bytes = JSON.stringify({
|
|
778
|
+
owner: lockInfo(ownerHostId, crypto.randomUUID()),
|
|
779
|
+
rootDev: snapshot.rootDev,
|
|
780
|
+
rootIno: snapshot.rootIno,
|
|
781
|
+
});
|
|
782
|
+
const staged = `${recordPath}.${process.pid}.${crypto.randomUUID()}`;
|
|
783
|
+
await Bun.write(staged, bytes, { mode: 0o600 });
|
|
784
|
+
try {
|
|
785
|
+
await fs.chmod(staged, 0o600);
|
|
786
|
+
await FileLockTestHooks.beforeRemovalOwnerPublish?.(transitionPath);
|
|
787
|
+
const published = await renameNoReplacePathAsync(staged, recordPath);
|
|
788
|
+
if (!published.ok) return false;
|
|
789
|
+
localRemovalRecords.set(recordPath, { bytes, resumable });
|
|
790
|
+
return true;
|
|
791
|
+
} finally {
|
|
792
|
+
await fs.rm(staged, { force: true });
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Publish outside the captured tree, atomically and before native rename. */
|
|
797
|
+
async function exactRemoveLockTree(
|
|
798
|
+
lockPath: string,
|
|
799
|
+
snapshot: NativeDirectoryTreeSnapshot,
|
|
800
|
+
ownerHostId?: string,
|
|
801
|
+
parentIdentity?: { dev: bigint; ino: bigint },
|
|
802
|
+
detachOnly?: boolean,
|
|
803
|
+
): Promise<NativeExactUnlinkResult> {
|
|
804
|
+
if (process.platform === "win32" || lockPath.endsWith(".removing"))
|
|
805
|
+
return nativeFileLockBindings().exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
|
|
806
|
+
const transitionPath = fileLockRemovalTransitionPath(lockPath);
|
|
807
|
+
const recordPath = `${transitionPath}.owner`;
|
|
808
|
+
const prior = await readRemovalTransitionOwnerRecord(transitionPath);
|
|
809
|
+
if (prior?.owner.owner_host_id !== undefined && lockRecordIsForeignHost(prior.owner, ownerHostId, []))
|
|
810
|
+
return { ok: false, code: "identity_mismatch" };
|
|
811
|
+
if (prior && !ownerIsAlive(prior.owner) && ownerLiveness(prior.owner.pid) !== "unknown") {
|
|
812
|
+
try {
|
|
813
|
+
await fs.lstat(transitionPath);
|
|
814
|
+
return { ok: false, code: "identity_mismatch" };
|
|
815
|
+
} catch (error) {
|
|
816
|
+
if (!isEnoent(error)) throw error;
|
|
817
|
+
}
|
|
818
|
+
if (!(await removeRemovalRecord(recordPath, JSON.stringify(prior))))
|
|
819
|
+
return { ok: false, code: "identity_mismatch" };
|
|
820
|
+
}
|
|
821
|
+
if (!(await publishRemovalRecord(transitionPath, snapshot, ownerHostId)))
|
|
822
|
+
return { ok: false, code: "identity_mismatch" };
|
|
823
|
+
try {
|
|
824
|
+
return nativeFileLockBindings().exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
|
|
825
|
+
} finally {
|
|
826
|
+
await clearRemovalRecord(transitionPath);
|
|
827
|
+
}
|
|
638
828
|
}
|
|
639
829
|
|
|
640
830
|
/**
|
|
641
831
|
* Adopt-and-finish a provably orphaned POSIX removal transition.
|
|
642
832
|
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
* no token or liveness proof can identify the owner. Ownership is therefore
|
|
649
|
-
* proven by the object: a plain `.removing` directory whose snapshot is the
|
|
650
|
-
* native scrub residue and whose `info` mtime is at least the full acquisition
|
|
651
|
-
* budget old. Finishing that removal deletes only already-scrubbed residue and
|
|
652
|
-
* never live lock state; any successor, placeholder, unscrubbed tree, or
|
|
653
|
-
* transient native refusal is returned unadopted so the caller keeps the typed
|
|
654
|
-
* `orphan_transition` diagnostic.
|
|
833
|
+
* The prepublished sibling carries the remover's process generation and the
|
|
834
|
+
* captured root identity until cleanup finishes. A dead remover permits replay
|
|
835
|
+
* even when death preceded payload scrub. Without that evidence only an aged
|
|
836
|
+
* empty root is safe: zero-byte files alone do not prove abandonment. Native
|
|
837
|
+
* identity verification and the durable-scrub receipt still govern completion.
|
|
655
838
|
*/
|
|
656
|
-
async function adoptOrphanedFileLockRemovalTransition(
|
|
839
|
+
async function adoptOrphanedFileLockRemovalTransition(
|
|
840
|
+
lockPath: string,
|
|
841
|
+
orphanAgeMs: number,
|
|
842
|
+
ownerHostId?: string,
|
|
843
|
+
previousOwnerHostIds: readonly string[] = [],
|
|
844
|
+
): Promise<boolean> {
|
|
657
845
|
const transitionPath = fileLockRemovalTransitionPath(lockPath);
|
|
846
|
+
const recordedOwner = await readRemovalTransitionOwnerRecord(transitionPath);
|
|
847
|
+
if (recordedOwner && ownerIsAlive(recordedOwner.owner) && !isResumableLocalRemoval(transitionPath, recordedOwner))
|
|
848
|
+
return false;
|
|
658
849
|
// Re-validate at the moment of adoption: a successor that published a parsed
|
|
659
850
|
// owner or replaced the tree must never inherit an earlier orphan verdict.
|
|
660
|
-
if (
|
|
851
|
+
if (
|
|
852
|
+
(await classifyFileLockRemovalTransition(lockPath, orphanAgeMs, ownerHostId, previousOwnerHostIds)) !==
|
|
853
|
+
"orphan_transition"
|
|
854
|
+
)
|
|
855
|
+
return false;
|
|
661
856
|
const captured = snapshotDirectoryTree(transitionPath);
|
|
662
857
|
if (!captured.ok || !captured.snapshot) return false;
|
|
663
858
|
const snapshot = captured.snapshot;
|
|
664
|
-
if (!
|
|
859
|
+
if (!recordedOwner && !isEmptyRemovalTransition(snapshot, orphanAgeMs)) return false;
|
|
860
|
+
if (recordedOwner && (snapshot.rootDev !== recordedOwner.rootDev || snapshot.rootIno !== recordedOwner.rootIno))
|
|
861
|
+
return false;
|
|
862
|
+
// Claim an ownerless generation without replacing any live publisher's record.
|
|
863
|
+
// The snapshot binds both the grace verdict and subsequent native removal.
|
|
864
|
+
if (!recordedOwner && !(await publishRemovalRecord(transitionPath, snapshot, ownerHostId, true))) return false;
|
|
865
|
+
const finish = async (): Promise<boolean> => {
|
|
866
|
+
if (recordedOwner) {
|
|
867
|
+
localRemovalRecords.set(`${transitionPath}.owner`, { bytes: JSON.stringify(recordedOwner), resumable: true });
|
|
868
|
+
}
|
|
869
|
+
await clearRemovalRecord(transitionPath);
|
|
870
|
+
logger.info(`file lock: reaped 1 stale lock artifact (${transitionPath})`);
|
|
871
|
+
return true;
|
|
872
|
+
};
|
|
665
873
|
let removal: NativeExactUnlinkResult;
|
|
666
874
|
try {
|
|
667
875
|
removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
|
|
@@ -670,14 +878,14 @@ async function adoptOrphanedFileLockRemovalTransition(lockPath: string, orphanAg
|
|
|
670
878
|
throw error;
|
|
671
879
|
}
|
|
672
880
|
if (removal.ok === true) {
|
|
673
|
-
return removal.code === undefined && Object.keys(removal).every(key => key === "ok");
|
|
881
|
+
return removal.code === undefined && Object.keys(removal).every(key => key === "ok") && (await finish());
|
|
674
882
|
}
|
|
675
883
|
if (
|
|
676
884
|
removal.ok === false &&
|
|
677
885
|
removal.code === "not_found" &&
|
|
678
886
|
Object.keys(removal).every(key => key === "ok" || key === "code")
|
|
679
887
|
)
|
|
680
|
-
return
|
|
888
|
+
return await finish();
|
|
681
889
|
// POSIX cannot bind a namespace unlink to the verified descriptor, so the
|
|
682
890
|
// primitive retains the scrubbed tree under its deterministic name and the
|
|
683
891
|
// caller finishes the on-disk removal. Finish only the exact tree this call
|
|
@@ -693,8 +901,10 @@ async function adoptOrphanedFileLockRemovalTransition(lockPath: string, orphanAg
|
|
|
693
901
|
removal.retainedPlaceholderPath === undefined &&
|
|
694
902
|
removal.retainedUnknownPath === undefined &&
|
|
695
903
|
Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
|
|
696
|
-
)
|
|
697
|
-
|
|
904
|
+
) {
|
|
905
|
+
const finished = await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
|
|
906
|
+
return finished && (await finish());
|
|
907
|
+
}
|
|
698
908
|
return false;
|
|
699
909
|
}
|
|
700
910
|
|
|
@@ -704,6 +914,8 @@ async function adoptAbandonedFileLockRemovalTransition(
|
|
|
704
914
|
previousOwnerHostIds: readonly string[] = [],
|
|
705
915
|
): Promise<boolean> {
|
|
706
916
|
const transitionPath = fileLockRemovalTransitionPath(lockPath);
|
|
917
|
+
const recordedOwner = await readRemovalTransitionOwnerRecord(transitionPath);
|
|
918
|
+
if (recordedOwner) return false; // The sibling-owner adoption path owns this generation.
|
|
707
919
|
const stale = await staleLockSnapshot(transitionPath, 0, ownerHostId, previousOwnerHostIds, undefined, true);
|
|
708
920
|
if (!stale.stale || !stale.identity) return false;
|
|
709
921
|
if (!(await isNativeExactRemovalUsable())) return false;
|
|
@@ -738,6 +950,9 @@ async function adoptAbandonedFileLockRemovalTransition(
|
|
|
738
950
|
!nativeFileLockInfoMatchesContentEvidence(infoEntry, stale.identity)
|
|
739
951
|
)
|
|
740
952
|
return false;
|
|
953
|
+
// Legacy info proves the old owner dead, but native scrub destroys that info.
|
|
954
|
+
// Commit this remover's sibling identity before allowing any payload mutation.
|
|
955
|
+
if (!(await publishRemovalRecord(transitionPath, snapshot, ownerHostId, true))) return false;
|
|
741
956
|
|
|
742
957
|
let removal: NativeExactUnlinkResult;
|
|
743
958
|
try {
|
|
@@ -746,7 +961,7 @@ async function adoptAbandonedFileLockRemovalTransition(
|
|
|
746
961
|
if (isTransientReleaseError(error)) return false;
|
|
747
962
|
throw error;
|
|
748
963
|
}
|
|
749
|
-
|
|
964
|
+
let finished = removal.ok === true && removal.code === undefined && Object.keys(removal).every(key => key === "ok");
|
|
750
965
|
if (
|
|
751
966
|
removal.ok === false &&
|
|
752
967
|
removal.code === "cleanup_pending" &&
|
|
@@ -758,12 +973,18 @@ async function adoptAbandonedFileLockRemovalTransition(
|
|
|
758
973
|
removal.retainedUnknownPath === undefined &&
|
|
759
974
|
Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
|
|
760
975
|
)
|
|
761
|
-
|
|
762
|
-
|
|
976
|
+
finished = await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
|
|
977
|
+
if (
|
|
763
978
|
removal.ok === false &&
|
|
764
979
|
removal.code === "not_found" &&
|
|
765
980
|
Object.keys(removal).every(key => key === "ok" || key === "code")
|
|
766
|
-
)
|
|
981
|
+
)
|
|
982
|
+
finished = true;
|
|
983
|
+
if (finished) {
|
|
984
|
+
await clearRemovalRecord(transitionPath);
|
|
985
|
+
logger.info(`file lock: reaped 1 stale lock artifact (${transitionPath})`);
|
|
986
|
+
}
|
|
987
|
+
return finished;
|
|
767
988
|
}
|
|
768
989
|
|
|
769
990
|
function sameFileLockTreeAfterPublication(
|
|
@@ -859,8 +1080,8 @@ async function rollbackPublishedFileLock(
|
|
|
859
1080
|
if (
|
|
860
1081
|
!current.isDirectory() ||
|
|
861
1082
|
current.isSymbolicLink() ||
|
|
862
|
-
current.dev !== BigInt(`0x${identity[1]}`) ||
|
|
863
|
-
current.ino !== BigInt(`0x${identity[2]}`)
|
|
1083
|
+
canonicalLockDeviceId(current.dev) !== BigInt(`0x${identity[1]}`) ||
|
|
1084
|
+
canonicalLockFileId(current.ino) !== BigInt(`0x${identity[2]}`)
|
|
864
1085
|
) {
|
|
865
1086
|
throw new Error("File lock rollback placeholder identity changed; refusing removal");
|
|
866
1087
|
}
|
|
@@ -1147,7 +1368,6 @@ function ownerGenerationIsDead(owner: FileLockOwnerToken): boolean {
|
|
|
1147
1368
|
|
|
1148
1369
|
/** Outcome of a guarded lock-dir removal attempt (`removeFileLockDirForGc`). */
|
|
1149
1370
|
export type FileLockGcRemoval = "removed" | "owner_changed" | "missing" | "cleanup_failed";
|
|
1150
|
-
|
|
1151
1371
|
type LockStaleSnapshot =
|
|
1152
1372
|
| { stale: false }
|
|
1153
1373
|
| { stale: true; owner: FileLockOwnerToken; identity: GenericFileLockDirIdentity };
|
|
@@ -1261,10 +1481,11 @@ async function removeVerifiedLockDirWithoutNative(
|
|
|
1261
1481
|
if (isTransientReleaseError(error)) throw error;
|
|
1262
1482
|
return "cleanup_failed";
|
|
1263
1483
|
}
|
|
1264
|
-
const removal =
|
|
1484
|
+
const removal = await exactRemoveLockTree(
|
|
1265
1485
|
lockDir,
|
|
1266
1486
|
expected,
|
|
1267
|
-
|
|
1487
|
+
owner?.owner_host_id,
|
|
1488
|
+
{ dev: canonicalLockDeviceId(parent.dev), ino: canonicalLockFileId(parent.ino) },
|
|
1268
1489
|
true,
|
|
1269
1490
|
);
|
|
1270
1491
|
if (removal.ok && !removal.detachedPath) return "removed";
|
|
@@ -1289,9 +1510,10 @@ async function removeVerifiedLockDirWithoutNative(
|
|
|
1289
1510
|
removal.retainedUnknownPath !== undefined
|
|
1290
1511
|
)
|
|
1291
1512
|
return "cleanup_failed";
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1513
|
+
if (!(await removeDetachedLockQuarantineOnDisk(removal.detachedPath, expected.rootDev, expected.rootIno)))
|
|
1514
|
+
return "cleanup_failed";
|
|
1515
|
+
await clearRemovalRecord(removal.detachedPath);
|
|
1516
|
+
return "removed";
|
|
1295
1517
|
}
|
|
1296
1518
|
|
|
1297
1519
|
async function removeVerifiedOwnedLockDirWithoutNative(
|
|
@@ -1433,7 +1655,7 @@ export async function removeFileLockDirForGc(
|
|
|
1433
1655
|
return "owner_changed";
|
|
1434
1656
|
let removed: NativeExactUnlinkResult;
|
|
1435
1657
|
try {
|
|
1436
|
-
removed =
|
|
1658
|
+
removed = await exactRemoveLockTree(nativeCapturePath, captured.snapshot, expected.owner_host_id);
|
|
1437
1659
|
const retainedPath = fileLockRemovalTransitionPath(nativeCapturePath);
|
|
1438
1660
|
if (
|
|
1439
1661
|
process.platform === "linux" &&
|
|
@@ -1487,6 +1709,7 @@ export async function removeFileLockDirForGc(
|
|
|
1487
1709
|
// contract gc-runtime applies to its own exact removals. Any other outcome —
|
|
1488
1710
|
// including a retained successor or placeholder — leaves the judged object (or
|
|
1489
1711
|
// its replacement) in place and reports the removal as refused.
|
|
1712
|
+
//
|
|
1490
1713
|
const detachedPath = removed.detachedPath;
|
|
1491
1714
|
const verifiedDetach =
|
|
1492
1715
|
detachedPath !== undefined &&
|
|
@@ -1585,12 +1808,9 @@ async function staleLockSnapshot(
|
|
|
1585
1808
|
throw error;
|
|
1586
1809
|
}
|
|
1587
1810
|
if (!info) {
|
|
1588
|
-
//
|
|
1589
|
-
//
|
|
1590
|
-
//
|
|
1591
|
-
// case carries liveness evidence, so elapsed time and byte stability must
|
|
1592
|
-
// never make this namespace reclaimable: only an independently committed
|
|
1593
|
-
// owner record proving its process generation dead authorizes removal.
|
|
1811
|
+
// An ownerless directory has no generation or liveness proof. Age and
|
|
1812
|
+
// emptiness cannot distinguish interrupted cleanup from a live legacy holder
|
|
1813
|
+
// or a paused acquisition, so every platform fails closed.
|
|
1594
1814
|
return { stale: false };
|
|
1595
1815
|
}
|
|
1596
1816
|
|
|
@@ -1633,8 +1853,6 @@ async function staleLockSnapshot(
|
|
|
1633
1853
|
return { stale: false };
|
|
1634
1854
|
}
|
|
1635
1855
|
|
|
1636
|
-
type StaleLockRemovalAttempt = { removed: true } | { removed: false; failure?: FileLockStaleRemovalFailure };
|
|
1637
|
-
|
|
1638
1856
|
type RecordedStaleRemovalFailure = {
|
|
1639
1857
|
owner: FileLockOwnerToken;
|
|
1640
1858
|
identity: GenericFileLockDirIdentity;
|
|
@@ -1678,10 +1896,12 @@ function manualLockCleanupCommand(lockPath: string): string {
|
|
|
1678
1896
|
return `rm -rf -- '${quotedPath}'`;
|
|
1679
1897
|
}
|
|
1680
1898
|
|
|
1899
|
+
type FileLockRemovalAttempt = { removed: true } | { removed: false; failure?: FileLockStaleRemovalFailure };
|
|
1900
|
+
|
|
1681
1901
|
async function removeStaleLockForAcquire(
|
|
1682
1902
|
lockPath: string,
|
|
1683
1903
|
snapshot: LockStaleSnapshot,
|
|
1684
|
-
): Promise<
|
|
1904
|
+
): Promise<FileLockRemovalAttempt> {
|
|
1685
1905
|
if (!snapshot.stale) return { removed: false };
|
|
1686
1906
|
try {
|
|
1687
1907
|
const outcome = await removeFileLockDirForGc(lockPath, snapshot.owner, snapshot.identity);
|
|
@@ -1818,7 +2038,14 @@ async function tryAcquireLock(
|
|
|
1818
2038
|
// A scrubbed, aged transition is the residue of a removal whose owner died
|
|
1819
2039
|
// before its final on-disk cleanup. Adopt and finish it here so the wedged
|
|
1820
2040
|
// namespace heals instead of aborting (or re-spinning the whole budget).
|
|
1821
|
-
if (
|
|
2041
|
+
if (
|
|
2042
|
+
!(await adoptOrphanedFileLockRemovalTransition(
|
|
2043
|
+
destinationPath,
|
|
2044
|
+
orphanTransitionAgeMs,
|
|
2045
|
+
ownerHostId,
|
|
2046
|
+
previousOwnerHostIds,
|
|
2047
|
+
))
|
|
2048
|
+
)
|
|
1822
2049
|
return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
|
|
1823
2050
|
transitionState = await classifyFileLockRemovalTransition(
|
|
1824
2051
|
destinationPath,
|
|
@@ -1901,7 +2128,9 @@ async function tryAcquireLock(
|
|
|
1901
2128
|
}
|
|
1902
2129
|
}
|
|
1903
2130
|
if (!publishedSuccessfully) {
|
|
1904
|
-
if (published.reason === "destination_exists")
|
|
2131
|
+
if (published.reason === "destination_exists") {
|
|
2132
|
+
return null;
|
|
2133
|
+
}
|
|
1905
2134
|
const failure = new Error(
|
|
1906
2135
|
`Failed to publish file lock: ${published.code ?? published.reason ?? "unknown"}.`,
|
|
1907
2136
|
) as NodeJS.ErrnoException;
|
|
@@ -2047,7 +2276,7 @@ async function quarantineReleasedLock(
|
|
|
2047
2276
|
if (infoEntry.sha256 !== expectedDigest) return false;
|
|
2048
2277
|
let removed: NativeExactUnlinkResult;
|
|
2049
2278
|
try {
|
|
2050
|
-
removed =
|
|
2279
|
+
removed = await exactRemoveLockTree(nativeCapturePath, captured.snapshot, owner.owner_host_id);
|
|
2051
2280
|
} catch (error) {
|
|
2052
2281
|
if (isTransientReleaseError(error)) return false;
|
|
2053
2282
|
throw error;
|
|
@@ -2198,7 +2427,7 @@ async function lockHolderDescription(
|
|
|
2198
2427
|
if (transitionState === "abandoned")
|
|
2199
2428
|
return `blocked by abandoned removal transition at ${fileLockRemovalTransitionPath(lockPath)}; inspect and remove the directory manually once no publisher remains`;
|
|
2200
2429
|
if (transitionState === "active")
|
|
2201
|
-
return
|
|
2430
|
+
return `blocked by retained removal transition ${fileLockRemovalTransitionPath(lockPath)}; retry the owning process cleanup or inspect the exact orphan manually; unproven transition ownership is never removed`;
|
|
2202
2431
|
}
|
|
2203
2432
|
let info = await readLockInfo(lockPath);
|
|
2204
2433
|
let bytes: string | null = null;
|
|
@@ -2268,7 +2497,7 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2268
2497
|
if (options.previousOwnerHostIds?.some(hostId => !hostId))
|
|
2269
2498
|
throw new Error("previousOwnerHostIds must contain only non-empty identities");
|
|
2270
2499
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
2271
|
-
const orphanTransitionAgeMs = Math.max(
|
|
2500
|
+
const orphanTransitionAgeMs = Math.max(REMOVAL_TRANSITION_GRACE_MS, opts.retries * opts.retryDelayMs);
|
|
2272
2501
|
|
|
2273
2502
|
if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
|
|
2274
2503
|
const lockPath = getLockPath(filePath);
|
|
@@ -2362,7 +2591,14 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2362
2591
|
opts.retries,
|
|
2363
2592
|
await lockHolderDescription(lockPath, orphanTransitionAgeMs, opts.ownerHostId, opts.previousOwnerHostIds ?? []),
|
|
2364
2593
|
"acquire_timeout",
|
|
2365
|
-
|
|
2594
|
+
(await classifyFileLockRemovalTransition(
|
|
2595
|
+
lockPath,
|
|
2596
|
+
orphanTransitionAgeMs,
|
|
2597
|
+
opts.ownerHostId,
|
|
2598
|
+
opts.previousOwnerHostIds ?? [],
|
|
2599
|
+
))
|
|
2600
|
+
? fileLockRemovalTransitionPath(lockPath)
|
|
2601
|
+
: undefined,
|
|
2366
2602
|
await staleRemovalFailureForCurrentGeneration(lockPath, staleRemovalFailure),
|
|
2367
2603
|
);
|
|
2368
2604
|
}
|
|
@@ -2441,8 +2677,8 @@ export async function inspectFileLockStagingDir(
|
|
|
2441
2677
|
if (
|
|
2442
2678
|
!captured.ok ||
|
|
2443
2679
|
!captured.snapshot ||
|
|
2444
|
-
captured.snapshot.rootDev !== root.dev.toString() ||
|
|
2445
|
-
captured.snapshot.rootIno !== root.ino.toString()
|
|
2680
|
+
captured.snapshot.rootDev !== canonicalLockDeviceId(root.dev).toString() ||
|
|
2681
|
+
captured.snapshot.rootIno !== canonicalLockFileId(root.ino).toString()
|
|
2446
2682
|
)
|
|
2447
2683
|
return kept;
|
|
2448
2684
|
const observation = await readFileLockObservationForGc(canonical);
|
|
@@ -2487,7 +2723,7 @@ export async function inspectFileLockStagingDir(
|
|
|
2487
2723
|
const removal = await removeVerifiedLockDirWithoutNative(canonical, captured.snapshot);
|
|
2488
2724
|
return { ...result, removed: removal === "removed", reason: removal };
|
|
2489
2725
|
}
|
|
2490
|
-
const removal =
|
|
2726
|
+
const removal = await exactRemoveLockTree(canonical, captured.snapshot);
|
|
2491
2727
|
if (
|
|
2492
2728
|
removal.detachedPath &&
|
|
2493
2729
|
path.resolve(removal.detachedPath) !== path.resolve(canonical) &&
|
|
@@ -2505,14 +2741,15 @@ export async function inspectFileLockStagingDir(
|
|
|
2505
2741
|
if (
|
|
2506
2742
|
detached?.isDirectory() &&
|
|
2507
2743
|
!detached.isSymbolicLink() &&
|
|
2508
|
-
detached.dev.toString() === captured.snapshot.rootDev &&
|
|
2509
|
-
detached.ino.toString() === captured.snapshot.rootIno
|
|
2744
|
+
canonicalLockDeviceId(detached.dev).toString() === captured.snapshot.rootDev &&
|
|
2745
|
+
canonicalLockFileId(detached.ino).toString() === captured.snapshot.rootIno
|
|
2510
2746
|
) {
|
|
2511
2747
|
try {
|
|
2512
2748
|
await fs.rmdir(removal.detachedPath);
|
|
2513
2749
|
} catch (error) {
|
|
2514
2750
|
if (!isEnoent(error)) throw error;
|
|
2515
2751
|
}
|
|
2752
|
+
await clearRemovalRecord(removal.detachedPath);
|
|
2516
2753
|
return { ...result, removed: true, reason: "removed" };
|
|
2517
2754
|
}
|
|
2518
2755
|
}
|