@bybrawe/opencode-loop 0.6.7 → 0.6.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 CHANGED
@@ -4,6 +4,14 @@
4
4
 
5
5
  No unreleased changes yet.
6
6
 
7
+ ## 0.6.8 - 2026-10-13
8
+
9
+ - Fix #172: fall back to exclusive `wx` lock claims where exFAT/FAT or other volumes reject `fs.link()`, including the reported `EISDIR` error.
10
+ - Preserve live-owner exclusion, dead-owner cleanup elections, crash-safe state updates and fail-closed corrupted locks.
11
+ - Exercise simulated unsupported hard-link errors across independent workers, merged writes, recovered dead owners and unexpected errors.
12
+ - Pair the new release with independently installed Goal 1.3.49 and require Windows/Linux and native OpenCode 2 release gates.
13
+ - The reported physical Windows exFAT case still needs re-testing on its actual disk.
14
+
7
15
  ## 0.6.7 - 2026-10-09
8
16
 
9
17
  - Recover the native Loop+Goal coexistence canary's cold Windows server bootstrap with bounded read-only session polling and distinguish server startup hangs from actual autonomous-ownership conflicts.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenCode Loop
2
2
 
3
- ## Native OpenCode 2 (0.6.7)
3
+ ## Native OpenCode 2 (0.6.8)
4
4
 
5
5
  The npm and V2 entrypoints use the official `@opencode/plugin` `Plugin.define()` contract. The installer now registers the exact `@bybrawe/opencode-loop` package in OpenCode's plural `plugins` configuration instead of copying a loose global plugin directory. OpenCode therefore resolves `@opencode/plugin` from Loop's own production dependency graph; explicit `--legacy-v1` remains a separate compatibility path.
6
6
 
@@ -17,7 +17,7 @@ Prompt/command timers, watch and stop conditions, verification, preflight/postru
17
17
 
18
18
  OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, verification/checkpoints, and the `opencode-loopd` background daemon.
19
19
 
20
- > **Current stable release: `0.6.7`.**
20
+ > **Current stable release: `0.6.8`.**
21
21
 
22
22
  ## Install or update
23
23
 
@@ -0,0 +1,18 @@
1
+ # OpenCode Loop 0.6.8
2
+
3
+ ## exFAT/FAT project volume lock compatibility (#172)
4
+
5
+ When `fs.link()` cannot claim a Loop session lock because hard links are unsupported (`EISDIR` on the reported Windows exFAT case, or `ENOTSUP`/`EOPNOTSUPP`/`ENOSYS`/`EPERM`/`EACCES`), Loop attempts an atomic `wx` exclusive-file claim instead.
6
+
7
+ The fallback also applies to dead-owner cleanup elections. A different live owner always wins; malformed lock owner metadata remains fail-closed, and unexpected I/O errors are not ignored. Short empty-owner publication windows get bounded retries instead of false corruption reports.
8
+
9
+ Tests exercise synthetic exFAT-style errors in independent Node processes, concurrent updates, abandoned locks and broken metadata. Windows/Linux, real OpenCode 2 and production artifact validation remain release gates. **Physical Windows exFAT and Desktop GUI behavior require separate field verification.**
10
+
11
+ ## Independent installation
12
+
13
+ ```powershell
14
+ npx -y @bybrawe/opencode-loop@0.6.8
15
+ npx -y @bybrawe/opencode-goal@1.3.49
16
+ ```
17
+
18
+ Fully exit and restart OpenCode. From the affected exFAT project, verify `/loop-status`, `/loop-doctor`, a short `/loop` job, and `/goal status`. Dedicated Goal remains a separately installed plugin; `/loop-goal*` compatibility commands are opt-in.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
4
4
  "description": "Idle-safe /loop scheduling and background automation for OpenCode, with optional legacy Loop Goal compatibility and a separately installable OpenCode Goal companion.",
5
5
  "type": "module",
6
6
  "main": "src/plugin.js",
@@ -24,8 +24,8 @@
24
24
  "build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool && bun build src/source/server.js --outfile=src/server.js --target=bun --format=esm --external=@opencode-ai/plugin/tool && bun build src/source/native.js --outfile=src/native.js --target=bun --format=esm",
25
25
  "build:plugin:npm": "npm run build:plugin",
26
26
  "prepack": "node --check src/plugin.js && node --check src/v2.js && node --check src/index.js && node --check src/server.js && node --check src/native.js",
27
- "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/server.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check src/server.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/pulse-check.mjs && node --check scripts/state-process-test.mjs && node --check scripts/state-integrity-test.mjs && node --check scripts/install-test.mjs && node --check scripts/desktop-command-diagnostics.mjs && node --check scripts/desktop-command-diagnostics-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/server-entry-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/session-error-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-goal-bootstrap.mjs && node --check scripts/host-goal-bootstrap-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs && node --check scripts/goal-compaction-safety-test.mjs && node --check src/source/native.js && node --check src/source/opencode2/native-plugin.js && node --check src/source/opencode2/native-runtime.js && node --check src/source/opencode2/native-policy.js && node --check src/native.js && node --check scripts/native-entry-test.mjs && node --check scripts/package-native-smoke.mjs && node --check src/v1.js && node --check scripts/native-cli-io-test.mjs && node --check scripts/native-lifecycle-test.mjs && node --check scripts/native-plugin-lifetime-test.mjs && node --check src/source/core/progress.js && node --check scripts/native-presets-test.mjs && node --check scripts/control-plane-until-test.mjs && node --check src/source/opencode2/session-location.js && node --check scripts/native-session-location-test.mjs && node --check src/plugin.js && node --check src/v2.js",
28
- "test": "node --test scripts/native-session-location-test.mjs && node --test scripts/control-plane-until-test.mjs && node --test scripts/native-presets-test.mjs && node --test scripts/native-plugin-lifetime-test.mjs && node --test scripts/native-cli-io-test.mjs scripts/native-lifecycle-test.mjs && node scripts/native-entry-test.mjs && node scripts/native-coexistence-test.mjs && node scripts/native-shell-test.mjs && node scripts/native-install-test.mjs && node scripts/native-v2-test.mjs && node scripts/goal-compaction-safety-test.mjs && node scripts/publish-workflow-test.mjs && node scripts/server-entry-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/session-error-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/state-process-test.mjs && node scripts/state-integrity-test.mjs && node scripts/install-test.mjs && node --test scripts/desktop-command-diagnostics-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node --test scripts/host-goal-bootstrap-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
27
+ "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/server.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check src/server.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/pulse-check.mjs && node --check scripts/state-process-test.mjs && node --check scripts/state-exfat-lock-test.mjs && node --check scripts/state-integrity-test.mjs && node --check scripts/install-test.mjs && node --check scripts/desktop-command-diagnostics.mjs && node --check scripts/desktop-command-diagnostics-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/server-entry-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/session-error-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-goal-bootstrap.mjs && node --check scripts/host-goal-bootstrap-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs && node --check scripts/goal-compaction-safety-test.mjs && node --check src/source/native.js && node --check src/source/opencode2/native-plugin.js && node --check src/source/opencode2/native-runtime.js && node --check src/source/opencode2/native-policy.js && node --check src/native.js && node --check scripts/native-entry-test.mjs && node --check scripts/package-native-smoke.mjs && node --check src/v1.js && node --check scripts/native-cli-io-test.mjs && node --check scripts/native-lifecycle-test.mjs && node --check scripts/native-plugin-lifetime-test.mjs && node --check src/source/core/progress.js && node --check scripts/native-presets-test.mjs && node --check scripts/control-plane-until-test.mjs && node --check src/source/opencode2/session-location.js && node --check scripts/native-session-location-test.mjs && node --check src/plugin.js && node --check src/v2.js",
28
+ "test": "node --test scripts/native-session-location-test.mjs && node --test scripts/control-plane-until-test.mjs && node --test scripts/native-presets-test.mjs && node --test scripts/native-plugin-lifetime-test.mjs && node --test scripts/native-cli-io-test.mjs scripts/native-lifecycle-test.mjs && node scripts/native-entry-test.mjs && node scripts/native-coexistence-test.mjs && node scripts/native-shell-test.mjs && node scripts/native-install-test.mjs && node scripts/native-v2-test.mjs && node scripts/goal-compaction-safety-test.mjs && node scripts/publish-workflow-test.mjs && node scripts/server-entry-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/session-error-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/state-process-test.mjs && node scripts/state-exfat-lock-test.mjs && node scripts/state-integrity-test.mjs && node scripts/install-test.mjs && node --test scripts/desktop-command-diagnostics-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node --test scripts/host-goal-bootstrap-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
29
29
  "canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs",
30
30
  "install:global": "node scripts/install-with-goals.mjs",
31
31
  "pack:zip": "node scripts/make-zip.mjs",
@@ -7,12 +7,12 @@ export function validateRelease(request, pkg, env) {
7
7
  assert.equal(env.GITHUB_REPOSITORY, "ByBrawe/opencode-loop", "wrong release repository")
8
8
  assert.equal(env.GITHUB_REF, "refs/heads/main", "npm release must come from main")
9
9
  assert.match(env.GITHUB_SHA ?? "", /^[a-f0-9]{40}$/, "immutable release SHA required")
10
- assert.equal(request?.version, "0.6.7", "release version is not authorized")
10
+ assert.equal(request?.version, "0.6.8", "release version is not authorized")
11
11
  assert.equal(request?.name, "@bybrawe/opencode-loop")
12
12
  assert.equal(pkg.name, request.name)
13
13
  assert.equal(pkg.version, request.version)
14
- assert.equal(request.predecessor, "0.6.6")
15
- assert.equal(request.goal?.version, "1.3.48")
14
+ assert.equal(request.predecessor, "0.6.7")
15
+ assert.equal(request.goal?.version, "1.3.49")
16
16
  assert.match(request.goal?.sha ?? "", /^[a-f0-9]{40}$/)
17
17
  return request
18
18
  }
@@ -48,14 +48,14 @@ assert.ok(workflow.includes('--prefer-online --cache "$VERIFY_DIR/npm-cache-$att
48
48
  assert.ok(workflow.includes("Published Loop+Goal clean-install validation did not converge"), "clean consumer must remain a hard release gate")
49
49
 
50
50
  const sha = "a".repeat(40)
51
- const request = { name: "@bybrawe/opencode-loop", version: "0.6.7", predecessor: "0.6.6", goal: { version: "1.3.48", sha: "b".repeat(40) } }
51
+ const request = { name: "@bybrawe/opencode-loop", version: "0.6.8", predecessor: "0.6.7", goal: { version: "1.3.49", sha: "b".repeat(40) } }
52
52
  const pkg = { name: request.name, version: request.version }
53
53
  const env = { GITHUB_REPOSITORY: "ByBrawe/opencode-loop", GITHUB_REF: "refs/heads/main", GITHUB_SHA: sha }
54
54
  assert.strictEqual(validateRelease(request, pkg, env), request)
55
55
  for (const change of [{ GITHUB_REPOSITORY: "someone/fork" }, { GITHUB_REF: "refs/heads/release" }, { GITHUB_SHA: "main" }]) assert.throws(() => validateRelease(request, pkg, { ...env, ...change }))
56
56
  assert.throws(() => validateRelease({ ...request, version: "9.0.0" }, pkg, env))
57
57
  assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.5" }, env))
58
- assert.equal(releaseDecision(404, null, request, sha, "0.6.6"), true)
58
+ assert.equal(releaseDecision(404, null, request, sha, "0.6.7"), true)
59
59
  for (const status of [401, 403, 429, 500, 503]) assert.throws(() => releaseDecision(status, null, request, sha, "0.6.0"))
60
60
  assert.throws(() => releaseDecision(404, null, request, sha, "0.7.0"))
61
61
  const manifest = { ...pkg, gitHead: sha }
@@ -0,0 +1,99 @@
1
+ import assert from "node:assert/strict"
2
+ import { spawn, spawnSync } from "node:child_process"
3
+ import { randomUUID } from "node:crypto"
4
+ import { promises as fs } from "node:fs"
5
+ import os from "node:os"
6
+ import path from "node:path"
7
+ import { fileURLToPath } from "node:url"
8
+ import { readState, writeState, stateDir } from "../src/source/core/state.js"
9
+
10
+ const source = fileURLToPath(import.meta.url)
11
+ const originalLink = fs.link
12
+ const linkError = (code) => Object.assign(new Error(`synthetic unsupported hard link: ${code}`), { code })
13
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
14
+ async function exists(file) { try { await fs.access(file); return true } catch (error) { if (error?.code === "ENOENT") return false; throw error } }
15
+ async function until(fn) { for (let i = 0; i < 500; i++) { if (await fn()) return; await wait(20) } throw new Error("worker barrier timeout") }
16
+
17
+ if (process.argv[2] === "--worker") {
18
+ fs.link = async () => { throw linkError("EISDIR") }
19
+ try {
20
+ const [, , , root, sessionID, label, gate] = process.argv
21
+ const snapshot = await readState(root, sessionID)
22
+ await fs.writeFile(`${gate}.${label}.ready`, "ready")
23
+ await until(() => exists(gate))
24
+ snapshot.jobs[0][label] = label
25
+ await writeState(root, sessionID, snapshot)
26
+ } finally { fs.link = originalLink }
27
+ } else {
28
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "loop-exfat-lock-"))
29
+ const sessionID = "exfat-session"
30
+ const canonical = path.join(stateDir(root), `${sessionID}.json.lock`)
31
+ fs.link = async () => { throw linkError("EISDIR") }
32
+ try {
33
+ const original = await readState(root, sessionID)
34
+ original.jobs = [{ id: "one", round: 1 }]
35
+ await writeState(root, sessionID, original)
36
+
37
+ // Workers each simulate an exFAT runtime: independent Node processes with
38
+ // no usable hard-link support must still merge their distinct writes.
39
+ const gate = path.join(root, "go")
40
+ const workers = []
41
+ for (let i = 0; i < 4; i++) {
42
+ const label = `worker${i}`
43
+ const child = spawn(process.execPath, [source, "--worker", root, sessionID, label, gate], { stdio: ["ignore", "ignore", "pipe"] })
44
+ let stderr = ""
45
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString() })
46
+ workers.push({
47
+ label,
48
+ done: new Promise((resolve, reject) => {
49
+ child.once("error", reject)
50
+ child.once("close", (code) => code === 0 ? resolve() : reject(new Error(`exFAT worker ${label} failed: ${stderr}`)))
51
+ }),
52
+ })
53
+ }
54
+ await until(async () => (await Promise.all(workers.map(w => exists(`${gate}.${w.label}.ready`)))).every(Boolean))
55
+ await fs.writeFile(gate, "go")
56
+ await Promise.all(workers.map(w => w.done))
57
+ const saved = (await readState(root, sessionID)).jobs[0]
58
+ for (const w of workers) assert.equal(saved[w.label], w.label)
59
+ assert.equal(await exists(canonical), false)
60
+
61
+ // Simulate an abandoned primary lock and an orphaned cleanup election,
62
+ // both written without hard links. Neither may prevent dead-PID recovery.
63
+ const dead = spawnSync(process.execPath, ["-e", "process.exit(0)"])
64
+ assert.equal(dead.status, 0)
65
+ const deadOwner = () => {
66
+ const token = randomUUID()
67
+ return { schemaVersion: 1, pid: dead.pid, token, acquiredAt: Date.now(),
68
+ candidateName: `.loop-lock-${dead.pid}-${token}.json` }
69
+ }
70
+ for (const suffix of ["", ".cleanup"]) {
71
+ const owner = deadOwner()
72
+ await fs.writeFile(path.join(stateDir(root), owner.candidateName), JSON.stringify(owner))
73
+ await fs.writeFile(`${canonical}${suffix}`, JSON.stringify(owner))
74
+ }
75
+ const revived = await readState(root, sessionID)
76
+ revived.jobs[0].recovered = true
77
+ await writeState(root, sessionID, revived)
78
+ assert.equal((await readState(root, sessionID)).jobs[0].recovered, true)
79
+ assert.equal(await exists(canonical), false)
80
+ assert.equal(await exists(`${canonical}.cleanup`), false)
81
+
82
+ await fs.writeFile(canonical, "invalid owner")
83
+ const corrupt = await readState(root, sessionID)
84
+ corrupt.jobs[0].forbidden = "never commit"
85
+ await assert.rejects(writeState(root, sessionID, corrupt), /state lock invalid_owner/)
86
+ assert.equal((await readState(root, sessionID)).jobs[0].forbidden, undefined)
87
+ await fs.rm(canonical)
88
+
89
+ // Unexpected failures must not be turned into 'contention' or ignored.
90
+ fs.link = async () => { throw linkError("ENOSPC") }
91
+ await assert.rejects(writeState(root, sessionID, await readState(root, sessionID)), { code: "ENOSPC" })
92
+ assert.equal(await exists(canonical), false)
93
+
94
+ console.log("Loop exFAT-style exclusive lock, process merge, dead-owner election and corrupt-state protections passed")
95
+ } finally {
96
+ fs.link = originalLink
97
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
98
+ }
99
+ }
package/src/index.js CHANGED
@@ -459,6 +459,18 @@ async function readStateProcessOwner(file) {
459
459
  await delay(10 * (attempt + 1));
460
460
  }
461
461
  }
462
+ if (raw.length === 0) {
463
+ for (let attempt = 0;attempt < 8 && raw.length === 0; attempt++) {
464
+ await delay(10 * (attempt + 1));
465
+ try {
466
+ raw = await fs.readFile(file, "utf8");
467
+ } catch (error) {
468
+ if (error?.code === "ENOENT")
469
+ return null;
470
+ throw error;
471
+ }
472
+ }
473
+ }
462
474
  let owner;
463
475
  try {
464
476
  owner = JSON.parse(raw);
@@ -499,6 +511,26 @@ async function claimStateProcessLock(candidate, canonical) {
499
511
  } catch (error) {
500
512
  if (error?.code === "EEXIST")
501
513
  return false;
514
+ if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
515
+ const contents = await fs.readFile(candidate);
516
+ let handle;
517
+ try {
518
+ handle = await fs.open(canonical, "wx", 384);
519
+ } catch (failure) {
520
+ if (failure?.code === "EEXIST")
521
+ return false;
522
+ throw failure;
523
+ }
524
+ try {
525
+ await handle.writeFile(contents);
526
+ } catch (failure) {
527
+ await handle.close().catch(() => {});
528
+ await removeStateProcessFile(canonical).catch(() => {});
529
+ throw failure;
530
+ }
531
+ await handle.close();
532
+ return true;
533
+ }
502
534
  if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
503
535
  throw error;
504
536
  await delay(10 * (attempt + 1));
package/src/native.js CHANGED
@@ -612,6 +612,18 @@ async function readStateProcessOwner(file) {
612
612
  await delay(10 * (attempt + 1));
613
613
  }
614
614
  }
615
+ if (raw.length === 0) {
616
+ for (let attempt = 0;attempt < 8 && raw.length === 0; attempt++) {
617
+ await delay(10 * (attempt + 1));
618
+ try {
619
+ raw = await fs.readFile(file, "utf8");
620
+ } catch (error) {
621
+ if (error?.code === "ENOENT")
622
+ return null;
623
+ throw error;
624
+ }
625
+ }
626
+ }
615
627
  let owner;
616
628
  try {
617
629
  owner = JSON.parse(raw);
@@ -652,6 +664,26 @@ async function claimStateProcessLock(candidate, canonical) {
652
664
  } catch (error) {
653
665
  if (error?.code === "EEXIST")
654
666
  return false;
667
+ if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
668
+ const contents = await fs.readFile(candidate);
669
+ let handle;
670
+ try {
671
+ handle = await fs.open(canonical, "wx", 384);
672
+ } catch (failure) {
673
+ if (failure?.code === "EEXIST")
674
+ return false;
675
+ throw failure;
676
+ }
677
+ try {
678
+ await handle.writeFile(contents);
679
+ } catch (failure) {
680
+ await handle.close().catch(() => {});
681
+ await removeStateProcessFile(canonical).catch(() => {});
682
+ throw failure;
683
+ }
684
+ await handle.close();
685
+ return true;
686
+ }
655
687
  if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
656
688
  throw error;
657
689
  await delay(10 * (attempt + 1));
package/src/server.js CHANGED
@@ -612,6 +612,18 @@ async function readStateProcessOwner(file) {
612
612
  await delay(10 * (attempt + 1));
613
613
  }
614
614
  }
615
+ if (raw.length === 0) {
616
+ for (let attempt = 0;attempt < 8 && raw.length === 0; attempt++) {
617
+ await delay(10 * (attempt + 1));
618
+ try {
619
+ raw = await fs.readFile(file, "utf8");
620
+ } catch (error) {
621
+ if (error?.code === "ENOENT")
622
+ return null;
623
+ throw error;
624
+ }
625
+ }
626
+ }
615
627
  let owner;
616
628
  try {
617
629
  owner = JSON.parse(raw);
@@ -652,6 +664,26 @@ async function claimStateProcessLock(candidate, canonical) {
652
664
  } catch (error) {
653
665
  if (error?.code === "EEXIST")
654
666
  return false;
667
+ if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
668
+ const contents = await fs.readFile(candidate);
669
+ let handle;
670
+ try {
671
+ handle = await fs.open(canonical, "wx", 384);
672
+ } catch (failure) {
673
+ if (failure?.code === "EEXIST")
674
+ return false;
675
+ throw failure;
676
+ }
677
+ try {
678
+ await handle.writeFile(contents);
679
+ } catch (failure) {
680
+ await handle.close().catch(() => {});
681
+ await removeStateProcessFile(canonical).catch(() => {});
682
+ throw failure;
683
+ }
684
+ await handle.close();
685
+ return true;
686
+ }
655
687
  if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
656
688
  throw error;
657
689
  await delay(10 * (attempt + 1));
@@ -68,6 +68,19 @@ async function readStateProcessOwner(file) {
68
68
  await delay(10 * (attempt + 1))
69
69
  }
70
70
  }
71
+ // An exclusive-file fallback publishes an empty lock briefly before
72
+ // writing its owner. Allow a bounded read retry; stale empty locks still
73
+ // fail closed rather than being taken over.
74
+ if (raw.length === 0) {
75
+ for (let attempt = 0; attempt < 8 && raw.length === 0; attempt++) {
76
+ await delay(10 * (attempt + 1))
77
+ try { raw = await fs.readFile(file, "utf8") }
78
+ catch (error) {
79
+ if (error?.code === "ENOENT") return null
80
+ throw error
81
+ }
82
+ }
83
+ }
71
84
  let owner
72
85
  try { owner = JSON.parse(raw) }
73
86
  catch { throw stateLockError("invalid_owner", `corrupt lock metadata at ${file}`) }
@@ -91,11 +104,31 @@ async function removeStateProcessFile(file) {
91
104
  }
92
105
  }
93
106
 
107
+ // Keep atomic hard links on capable volumes. FAT/exFAT and some remote volumes
108
+ // reject them, so fall back to exclusive creation (O_EXCL) without overwriting
109
+ // any existing live lock or stale-owner cleanup election.
94
110
  async function claimStateProcessLock(candidate, canonical) {
95
111
  for (let attempt = 0; ; attempt++) {
96
112
  try { await fs.link(candidate, canonical); return true }
97
113
  catch (error) {
98
114
  if (error?.code === "EEXIST") return false
115
+ if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
116
+ const contents = await fs.readFile(candidate)
117
+ let handle
118
+ try { handle = await fs.open(canonical, "wx", 0o600) }
119
+ catch (failure) {
120
+ if (failure?.code === "EEXIST") return false
121
+ throw failure
122
+ }
123
+ try { await handle.writeFile(contents) }
124
+ catch (failure) {
125
+ await handle.close().catch(() => {})
126
+ await removeStateProcessFile(canonical).catch(() => {})
127
+ throw failure
128
+ }
129
+ await handle.close()
130
+ return true
131
+ }
99
132
  if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5) throw error
100
133
  await delay(10 * (attempt + 1))
101
134
  }