@gajae-code/coding-agent 0.18.6 → 0.18.8
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 +318 -1
- package/dist/types/async/job-manager.d.ts +1 -1
- 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 +6 -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/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/settings.d.ts +4 -0
- 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 +8 -3
- package/dist/types/export/html/css-color.d.ts +2 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- 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 +3 -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/ralplan-runtime.d.ts +0 -5
- 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 +11 -1
- 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/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/elicitation.d.ts +12 -0
- package/dist/types/runtime-mcp/oauth-public-url.d.ts +1 -0
- package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -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 +18 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +9 -0
- package/dist/types/sdk/broker/session-index.d.ts +12 -6
- package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
- 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/bus/telegram-daemon.d.ts +3 -2
- package/dist/types/sdk/client/client.d.ts +2 -0
- package/dist/types/sdk/host/host.d.ts +6 -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 +9 -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 +36 -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 +19 -0
- package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +11 -0
- package/dist/types/session/session-manager.d.ts +31 -5
- package/dist/types/session/session-retirement.d.ts +71 -0
- package/dist/types/session/session-storage.d.ts +62 -48
- package/dist/types/session/session-transcript-header.d.ts +2 -0
- package/dist/types/session/task-artifact-owner-codec.d.ts +109 -0
- package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
- package/dist/types/session/task-artifact-owner.d.ts +10 -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/slash-commands/helpers/progress-report.d.ts +8 -0
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/tools/bash-skill-urls.d.ts +1 -1
- package/dist/types/tools/index.d.ts +3 -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/resident-memory-bench.ts +174 -20
- package/scripts/verify-managed-empty-stop.ts +28 -0
- package/src/async/job-manager.ts +228 -122
- package/src/capability/index.ts +18 -0
- package/src/capability/mcp.ts +2 -0
- package/src/capability/skill.ts +2 -0
- package/src/capability/types.ts +6 -0
- package/src/cli/auth-broker-cli.ts +1 -1
- package/src/cli/config-cli.ts +8 -18
- package/src/cli/customize-doctor.ts +15 -2
- package/src/cli/public-command-errors.ts +21 -1
- package/src/cli/setup-cli.ts +4 -4
- package/src/cli/skills-cli.ts +5 -0
- package/src/cli-main.ts +7 -8
- package/src/commands/deep-interview.ts +47 -8
- package/src/commands/sdk.ts +140 -29
- package/src/config/autorouting-tier-map.ts +117 -7
- package/src/config/file-lock.ts +307 -68
- 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/settings.ts +44 -4
- package/src/config/skill-settings-defaults.ts +7 -0
- package/src/coordinator-mcp/policy.ts +3 -2
- 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 +3 -0
- 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/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 +10 -0
- package/src/discovery/helpers.ts +168 -23
- package/src/discovery/mcp-json.ts +6 -0
- package/src/discovery/ssh.ts +9 -4
- package/src/discovery/substitute-plugin-root.ts +3 -1
- package/src/edit/file-read-cache.ts +1 -1
- package/src/eval/py/executor.ts +380 -120
- package/src/export/html/css-color.ts +9 -0
- package/src/export/html/index.ts +5 -4
- package/src/export/html/template.generated.ts +1 -1
- package/src/extensibility/extensions/runner.ts +4 -0
- package/src/extensibility/extensions/types.ts +7 -0
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +73 -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 +6 -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 -8
- package/src/gjc-runtime/launch-worktree.ts +14 -4
- package/src/gjc-runtime/ralplan-runtime.ts +447 -52
- 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 +83 -8
- 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/hindsight/config.ts +9 -3
- package/src/internal-urls/docs-index.generated.ts +19 -18
- package/src/internal-urls/skill-path.ts +19 -0
- package/src/internal-urls/skill-protocol.ts +2 -0
- package/src/lsp/edits.ts +6 -0
- package/src/lsp/workspace-path.ts +51 -0
- package/src/main.ts +195 -31
- package/src/modes/acp/acp-agent.ts +163 -7
- 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/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 +74 -29
- package/src/modes/controllers/runtime-mcp-command-controller.ts +8 -3
- package/src/modes/controllers/selector-controller.ts +28 -15
- package/src/modes/interactive-mode.ts +4 -0
- 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 +13 -4
- package/src/runtime-mcp/config-writer.ts +10 -6
- package/src/runtime-mcp/config.ts +1 -0
- package/src/runtime-mcp/elicitation.ts +142 -0
- package/src/runtime-mcp/oauth-flow.ts +5 -0
- package/src/runtime-mcp/oauth-public-url.ts +8 -0
- package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
- package/src/runtime-mcp/smithery-registry.ts +22 -3
- package/src/runtime-mcp/transports/http.ts +2 -2
- package/src/runtime-mcp/types.ts +6 -0
- package/src/sdk/acp/adapter.ts +93 -8
- package/src/sdk/broker/broker-exit.ts +52 -23
- package/src/sdk/broker/broker.ts +83 -7
- package/src/sdk/broker/ensure.ts +2 -0
- package/src/sdk/broker/lifecycle.ts +820 -73
- package/src/sdk/broker/session-index.ts +191 -81
- package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
- package/src/sdk/bus/chat-daemon-cli.ts +38 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
- package/src/sdk/bus/index.ts +241 -46
- package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
- package/src/sdk/bus/telegram-daemon.ts +4 -2
- package/src/sdk/cli/session-cli.ts +17 -3
- package/src/sdk/client/client.ts +28 -8
- package/src/sdk/host/host.ts +6 -0
- package/src/sdk/host/query/handlers.ts +9 -1
- package/src/sdk/host/session-runtime.test.ts +2805 -411
- package/src/sdk/host/session-runtime.ts +428 -111
- 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/mcp/server.ts +15 -3
- 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 +94 -14
- package/src/sdk/session-list.ts +1 -0
- package/src/sdk/session.ts +81 -38
- package/src/session/agent-session.ts +674 -224
- package/src/session/artifacts.ts +174 -70
- package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
- package/src/session/internal/managed-session-scope.ts +3457 -462
- package/src/session/internal/managed-session-storage.ts +482 -61
- package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
- package/src/session/internal/task-artifact-owner-access.ts +245 -0
- package/src/session/internal/task-artifact-owner-transcript.ts +614 -0
- package/src/session/session-manager.ts +939 -133
- package/src/session/session-retirement.ts +714 -0
- package/src/session/session-storage.ts +418 -174
- package/src/session/session-transcript-header.ts +17 -0
- package/src/session/task-artifact-owner-codec.ts +641 -0
- package/src/session/task-artifact-owner-retirement.ts +477 -0
- package/src/session/task-artifact-owner.ts +289 -0
- 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 +132 -2
- package/src/slash-commands/builtin-registry.ts +39 -9
- 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/tools/ask.ts +205 -6
- package/src/tools/ast-edit.ts +4 -0
- 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/descriptors.ts +5 -1
- package/src/tools/eval.ts +56 -36
- package/src/tools/image-gen.ts +1 -1
- package/src/tools/index.ts +3 -0
- package/src/tools/python.ts +187 -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/tools-checksum.ts +34 -0
- package/src/utils/tools-manager.ts +24 -6
- 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
|
}
|
|
@@ -1261,10 +1482,11 @@ async function removeVerifiedLockDirWithoutNative(
|
|
|
1261
1482
|
if (isTransientReleaseError(error)) throw error;
|
|
1262
1483
|
return "cleanup_failed";
|
|
1263
1484
|
}
|
|
1264
|
-
const removal =
|
|
1485
|
+
const removal = await exactRemoveLockTree(
|
|
1265
1486
|
lockDir,
|
|
1266
1487
|
expected,
|
|
1267
|
-
|
|
1488
|
+
owner?.owner_host_id,
|
|
1489
|
+
{ dev: canonicalLockDeviceId(parent.dev), ino: canonicalLockFileId(parent.ino) },
|
|
1268
1490
|
true,
|
|
1269
1491
|
);
|
|
1270
1492
|
if (removal.ok && !removal.detachedPath) return "removed";
|
|
@@ -1289,9 +1511,10 @@ async function removeVerifiedLockDirWithoutNative(
|
|
|
1289
1511
|
removal.retainedUnknownPath !== undefined
|
|
1290
1512
|
)
|
|
1291
1513
|
return "cleanup_failed";
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1514
|
+
if (!(await removeDetachedLockQuarantineOnDisk(removal.detachedPath, expected.rootDev, expected.rootIno)))
|
|
1515
|
+
return "cleanup_failed";
|
|
1516
|
+
await clearRemovalRecord(removal.detachedPath);
|
|
1517
|
+
return "removed";
|
|
1295
1518
|
}
|
|
1296
1519
|
|
|
1297
1520
|
async function removeVerifiedOwnedLockDirWithoutNative(
|
|
@@ -1433,7 +1656,7 @@ export async function removeFileLockDirForGc(
|
|
|
1433
1656
|
return "owner_changed";
|
|
1434
1657
|
let removed: NativeExactUnlinkResult;
|
|
1435
1658
|
try {
|
|
1436
|
-
removed =
|
|
1659
|
+
removed = await exactRemoveLockTree(nativeCapturePath, captured.snapshot, expected.owner_host_id);
|
|
1437
1660
|
const retainedPath = fileLockRemovalTransitionPath(nativeCapturePath);
|
|
1438
1661
|
if (
|
|
1439
1662
|
process.platform === "linux" &&
|
|
@@ -1487,6 +1710,7 @@ export async function removeFileLockDirForGc(
|
|
|
1487
1710
|
// contract gc-runtime applies to its own exact removals. Any other outcome —
|
|
1488
1711
|
// including a retained successor or placeholder — leaves the judged object (or
|
|
1489
1712
|
// its replacement) in place and reports the removal as refused.
|
|
1713
|
+
//
|
|
1490
1714
|
const detachedPath = removed.detachedPath;
|
|
1491
1715
|
const verifiedDetach =
|
|
1492
1716
|
detachedPath !== undefined &&
|
|
@@ -1818,7 +2042,14 @@ async function tryAcquireLock(
|
|
|
1818
2042
|
// A scrubbed, aged transition is the residue of a removal whose owner died
|
|
1819
2043
|
// before its final on-disk cleanup. Adopt and finish it here so the wedged
|
|
1820
2044
|
// namespace heals instead of aborting (or re-spinning the whole budget).
|
|
1821
|
-
if (
|
|
2045
|
+
if (
|
|
2046
|
+
!(await adoptOrphanedFileLockRemovalTransition(
|
|
2047
|
+
destinationPath,
|
|
2048
|
+
orphanTransitionAgeMs,
|
|
2049
|
+
ownerHostId,
|
|
2050
|
+
previousOwnerHostIds,
|
|
2051
|
+
))
|
|
2052
|
+
)
|
|
1822
2053
|
return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
|
|
1823
2054
|
transitionState = await classifyFileLockRemovalTransition(
|
|
1824
2055
|
destinationPath,
|
|
@@ -2047,7 +2278,7 @@ async function quarantineReleasedLock(
|
|
|
2047
2278
|
if (infoEntry.sha256 !== expectedDigest) return false;
|
|
2048
2279
|
let removed: NativeExactUnlinkResult;
|
|
2049
2280
|
try {
|
|
2050
|
-
removed =
|
|
2281
|
+
removed = await exactRemoveLockTree(nativeCapturePath, captured.snapshot, owner.owner_host_id);
|
|
2051
2282
|
} catch (error) {
|
|
2052
2283
|
if (isTransientReleaseError(error)) return false;
|
|
2053
2284
|
throw error;
|
|
@@ -2198,7 +2429,7 @@ async function lockHolderDescription(
|
|
|
2198
2429
|
if (transitionState === "abandoned")
|
|
2199
2430
|
return `blocked by abandoned removal transition at ${fileLockRemovalTransitionPath(lockPath)}; inspect and remove the directory manually once no publisher remains`;
|
|
2200
2431
|
if (transitionState === "active")
|
|
2201
|
-
return
|
|
2432
|
+
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
2433
|
}
|
|
2203
2434
|
let info = await readLockInfo(lockPath);
|
|
2204
2435
|
let bytes: string | null = null;
|
|
@@ -2268,7 +2499,7 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2268
2499
|
if (options.previousOwnerHostIds?.some(hostId => !hostId))
|
|
2269
2500
|
throw new Error("previousOwnerHostIds must contain only non-empty identities");
|
|
2270
2501
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
2271
|
-
const orphanTransitionAgeMs = Math.max(
|
|
2502
|
+
const orphanTransitionAgeMs = Math.max(REMOVAL_TRANSITION_GRACE_MS, opts.retries * opts.retryDelayMs);
|
|
2272
2503
|
|
|
2273
2504
|
if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
|
|
2274
2505
|
const lockPath = getLockPath(filePath);
|
|
@@ -2362,7 +2593,14 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
2362
2593
|
opts.retries,
|
|
2363
2594
|
await lockHolderDescription(lockPath, orphanTransitionAgeMs, opts.ownerHostId, opts.previousOwnerHostIds ?? []),
|
|
2364
2595
|
"acquire_timeout",
|
|
2365
|
-
|
|
2596
|
+
(await classifyFileLockRemovalTransition(
|
|
2597
|
+
lockPath,
|
|
2598
|
+
orphanTransitionAgeMs,
|
|
2599
|
+
opts.ownerHostId,
|
|
2600
|
+
opts.previousOwnerHostIds ?? [],
|
|
2601
|
+
))
|
|
2602
|
+
? fileLockRemovalTransitionPath(lockPath)
|
|
2603
|
+
: undefined,
|
|
2366
2604
|
await staleRemovalFailureForCurrentGeneration(lockPath, staleRemovalFailure),
|
|
2367
2605
|
);
|
|
2368
2606
|
}
|
|
@@ -2441,8 +2679,8 @@ export async function inspectFileLockStagingDir(
|
|
|
2441
2679
|
if (
|
|
2442
2680
|
!captured.ok ||
|
|
2443
2681
|
!captured.snapshot ||
|
|
2444
|
-
captured.snapshot.rootDev !== root.dev.toString() ||
|
|
2445
|
-
captured.snapshot.rootIno !== root.ino.toString()
|
|
2682
|
+
captured.snapshot.rootDev !== canonicalLockDeviceId(root.dev).toString() ||
|
|
2683
|
+
captured.snapshot.rootIno !== canonicalLockFileId(root.ino).toString()
|
|
2446
2684
|
)
|
|
2447
2685
|
return kept;
|
|
2448
2686
|
const observation = await readFileLockObservationForGc(canonical);
|
|
@@ -2487,7 +2725,7 @@ export async function inspectFileLockStagingDir(
|
|
|
2487
2725
|
const removal = await removeVerifiedLockDirWithoutNative(canonical, captured.snapshot);
|
|
2488
2726
|
return { ...result, removed: removal === "removed", reason: removal };
|
|
2489
2727
|
}
|
|
2490
|
-
const removal =
|
|
2728
|
+
const removal = await exactRemoveLockTree(canonical, captured.snapshot);
|
|
2491
2729
|
if (
|
|
2492
2730
|
removal.detachedPath &&
|
|
2493
2731
|
path.resolve(removal.detachedPath) !== path.resolve(canonical) &&
|
|
@@ -2505,14 +2743,15 @@ export async function inspectFileLockStagingDir(
|
|
|
2505
2743
|
if (
|
|
2506
2744
|
detached?.isDirectory() &&
|
|
2507
2745
|
!detached.isSymbolicLink() &&
|
|
2508
|
-
detached.dev.toString() === captured.snapshot.rootDev &&
|
|
2509
|
-
detached.ino.toString() === captured.snapshot.rootIno
|
|
2746
|
+
canonicalLockDeviceId(detached.dev).toString() === captured.snapshot.rootDev &&
|
|
2747
|
+
canonicalLockFileId(detached.ino).toString() === captured.snapshot.rootIno
|
|
2510
2748
|
) {
|
|
2511
2749
|
try {
|
|
2512
2750
|
await fs.rmdir(removal.detachedPath);
|
|
2513
2751
|
} catch (error) {
|
|
2514
2752
|
if (!isEnoent(error)) throw error;
|
|
2515
2753
|
}
|
|
2754
|
+
await clearRemovalRecord(removal.detachedPath);
|
|
2516
2755
|
return { ...result, removed: true, reason: "removed" };
|
|
2517
2756
|
}
|
|
2518
2757
|
}
|
|
@@ -217,6 +217,10 @@
|
|
|
217
217
|
"headers": {
|
|
218
218
|
"$ref": "#/$defs/stringMap",
|
|
219
219
|
"description": "HTTP headers sent with MCP requests. Public CLI output redacts values."
|
|
220
|
+
},
|
|
221
|
+
"publicNetwork": {
|
|
222
|
+
"type": "boolean",
|
|
223
|
+
"description": "When true, MCP HTTP requests and redirects must stay on public addresses."
|
|
220
224
|
}
|
|
221
225
|
},
|
|
222
226
|
"not": {
|