@bybrawe/opencode-loop 0.6.7 → 0.6.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/scripts/loopd.mjs CHANGED
@@ -181,9 +181,11 @@ async function selectHost(project, options = {}) {
181
181
  async function listSessions(host, project, limit) {
182
182
  const argv = ["session", "list", "--format", "json", "-n", String(limit)]
183
183
  if (host.server) argv.push("--server", host.server)
184
+ // macOS aliases /var -> /private/var. OpenCode 2 needs the canonical
185
+ // project PWD to agree with process.cwd() and the native session location.
184
186
  const result = await run(host.binary, argv, project, {
185
187
  capture: true, timeoutMs: 15_000,
186
- ...(host.major === 2 ? { env: { ...process.env, PWD: project } } : {}),
188
+ ...(host.major === 2 ? { env: { ...process.env, PWD: process.platform === "win32" ? project : fs.realpathSync.native(project) } } : {}),
187
189
  })
188
190
  if (result.code !== 0 || result.timedOut) throw new Error("OpenCode session list failed; refusing to guess a session")
189
191
  return projectSessions(result.stdout, project, host.major === 2)
@@ -286,7 +288,7 @@ async function daemon(options = {}) {
286
288
  // Keep native runs project-bound and non-interactive without changing V1.
287
289
  const result = await run(host.binary, runArgs, project, {
288
290
  timeoutMs, closeStdin: host.major === 2,
289
- ...(host.major === 2 ? { env: { ...process.env, PWD: project } } : {}),
291
+ ...(host.major === 2 ? { env: { ...process.env, PWD: process.platform === "win32" ? project : fs.realpathSync.native(project) } } : {}),
290
292
  })
291
293
  const code = result.timedOut ? 124 : result.code
292
294
  const moreRunsRemain = maxRuns === 0 || count < maxRuns
@@ -1,7 +1,7 @@
1
1
  import test from "node:test"
2
2
  import assert from "node:assert/strict"
3
3
  import { spawn } from "node:child_process"
4
- import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"
4
+ import { mkdtemp, mkdir, writeFile, readFile, realpath, rm } from "node:fs/promises"
5
5
  import os from "node:os"
6
6
  import path from "node:path"
7
7
  import { fileURLToPath } from "node:url"
@@ -47,9 +47,12 @@ process.stdin.resume();
47
47
  assert.equal(result.code, 0, `${mode}: ${result.stdout}\n${result.stderr}`)
48
48
  const calls = (await readFile(log, "utf8")).trim().split(/\r?\n/).map(JSON.parse)
49
49
  if (mode === "pwd") {
50
+ // macOS /var/folders is an alias of /private/var/folders; the V2 host
51
+ // compares project PWD with the physical cwd, not the original alias.
52
+ const canonical = process.platform === "win32" ? project : await realpath(project)
50
53
  for (const call of calls.filter(call => ["session", "run"].includes(call.args[0]))) {
51
- assert.equal(call.pwd, project, "native lookup and run must use the selected project, not the caller's PWD")
52
- assert.equal(call.cwd, project)
54
+ assert.equal(call.pwd, canonical, "native lookup and run must use the canonical selected project, not the caller's PWD")
55
+ assert.equal(call.cwd, canonical)
53
56
  }
54
57
  }
55
58
  if (mode === "stdin") assert.match(result.stdout, /INPUT=\r?\n/)
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict"
2
2
  import { spawnSync } from "node:child_process"
3
- import { readFileSync } from "node:fs"
3
+ import { readFileSync, realpathSync } from "node:fs"
4
4
  import path from "node:path"
5
5
  import { fileURLToPath, pathToFileURL } from "node:url"
6
6
 
@@ -11,7 +11,9 @@ assert.ok(process.argv.length <= 3, "expected at most one consumer directory")
11
11
  const loop = pkg.name === "@bybrawe/opencode-loop"
12
12
  const cwd = process.argv[2] ? path.resolve(process.argv[2]) : root
13
13
  const packageRoot = process.argv[2] ? path.join(cwd, "node_modules", ...pkg.name.split("/")) : root
14
- const url = (relative) => pathToFileURL(path.join(packageRoot, relative)).href
14
+ // Node resolves ESM file URLs to real paths on macOS, where /var aliases /private/var.
15
+ // Guard and delegated V1 entry identities must use the same canonical URLs as the loader.
16
+ const url = (relative) => pathToFileURL(realpathSync.native(path.join(packageRoot, relative))).href
15
17
  const legacyEntries = loop ? [url("src/v1.js"), url("src/source/v1.js")] : [url("dist/legacy-v1.js")]
16
18
  const blocked = loop
17
19
  ? [...legacyEntries, url("src/index.js"), url("src/source/legacy-v1.js")]
@@ -23,15 +25,23 @@ const serverEntries = loop ? [`${pkg.name}/server`, url("src/source/server.js")]
23
25
  function probe(mode) {
24
26
  const stub = "globalThis.__legacyModuleLoads = (globalThis.__legacyModuleLoads || 0) + 1; export default async (...args) => { if (args[0]?.failure) throw args[0].failure; return { args }; }"
25
27
  const loader = `
26
- const blocked = ${JSON.stringify(blocked)};
27
- const legacyEntries = ${JSON.stringify(legacyEntries)};
28
+ import { realpathSync } from "node:fs";
29
+ import { fileURLToPath } from "node:url";
30
+ const physical = (url) => {
31
+ if (!url.startsWith("file:")) return url;
32
+ const name = realpathSync.native(fileURLToPath(url));
33
+ return process.platform === "win32" ? name.toLowerCase() : name;
34
+ };
35
+ const blocked = new Set(${JSON.stringify(blocked)}.map(physical));
36
+ const legacyEntries = new Set(${JSON.stringify(legacyEntries)}.map(physical));
28
37
  export async function resolve(specifier, context, nextResolve) {
29
38
  if (specifier.startsWith("@opencode-ai/")) throw new Error("V2_LEGACY_IMPORT: " + specifier);
30
39
  const result = await nextResolve(specifier, context);
31
- if (${JSON.stringify(mode)} === "delegate" && legacyEntries.includes(result.url)) {
40
+ const actual = physical(result.url);
41
+ if (${JSON.stringify(mode)} === "delegate" && legacyEntries.has(actual)) {
32
42
  return { url: ${JSON.stringify(`data:text/javascript,${encodeURIComponent(stub)}`)}, shortCircuit: true };
33
43
  }
34
- if (blocked.includes(result.url)) throw new Error("V2_LEGACY_IMPORT: " + result.url);
44
+ if (blocked.has(actual)) throw new Error("V2_LEGACY_IMPORT: " + result.url);
35
45
  return result;
36
46
  }
37
47
  `
@@ -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.9", "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.8")
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
  }
@@ -47,6 +47,67 @@ async function test(name, run, extra) {
47
47
  finally { await f.close() }
48
48
  }
49
49
 
50
+ await test("explicit checklist stop prevents the next V2 model turn", async (f) => {
51
+ await writeFile(path.join(f.directory, "TASK.md"), "- [ ] implement\n- [x] setup\n")
52
+ const added = await f.add("0s --progress-file TASK.md --until-checklist-complete implement --max-runs 3")
53
+ assert.equal(added.accepted, true)
54
+ await f.wake()
55
+ assert.equal(f.prompts.length, 1)
56
+ await writeFile(path.join(f.directory, "TASK.md"), "- [x] implement\n- [x] setup\n")
57
+ await f.complete()
58
+ const job = (await f.state()).jobs[0]
59
+ assert.equal(f.prompts.length, 1, "no redundant model prompt after host checks TASK.md")
60
+ assert.equal(job.enabled, false)
61
+ assert.equal(job.paused, true)
62
+ assert.equal(job.pauseReason, "checklist-complete")
63
+ assert.deepEqual(job.checklistStatus, { status: "complete", total: 2, pending: 0 })
64
+ })
65
+
66
+ await test("explicit checklist refuses missing project file instead of spending tokens", async (f) => {
67
+ assert.equal((await f.add("0s --progress-file MISSING.md --until-checklist-complete implement")).accepted, true)
68
+ await f.wake()
69
+ const job = (await f.state()).jobs[0]
70
+ assert.equal(f.prompts.length, 0)
71
+ assert.equal(job.paused, true)
72
+ assert.equal(job.pauseReason, "checklist-unavailable")
73
+ assert.equal(job.lastChecklistError, "file-missing")
74
+ })
75
+
76
+ await test("an already-complete checklist still requires host verification when requested", async (f) => {
77
+ await writeFile(path.join(f.directory, "TASK.md"), "- [x] done\n")
78
+ await f.add('0s --progress-file TASK.md --until-checklist-complete --verify "npm test" check work --max-runs 2')
79
+ await f.wake()
80
+ assert.equal(f.prompts.length, 1, "unobserved verify must not pass by default")
81
+ await f.complete()
82
+ const job = (await f.state()).jobs[0]
83
+ assert.equal(f.hooks.length, 1)
84
+ assert.equal(f.hooks[0], "npm test")
85
+ assert.equal(f.prompts.length, 1, "observed verify pass stops before another turn")
86
+ assert.equal(job.enabled, false)
87
+ assert.equal(job.pauseReason, "checklist-complete")
88
+ assert.equal(job.lastVerifyCode, 0)
89
+ })
90
+
91
+ await test("progress-file without opt-in still follows existing finite or infinite semantics", async (f) => {
92
+ await writeFile(path.join(f.directory, "TASK.md"), "- [x] all done\n")
93
+ await f.add("0s --progress-file TASK.md task --max-runs 1")
94
+ await f.wake()
95
+ assert.equal(f.prompts.length, 1)
96
+ assert.equal((await f.state()).jobs[0].untilChecklistComplete, false)
97
+ })
98
+
99
+ await test("native checklist flag requires explicit progress file and status reveals stop controls", async (f) => {
100
+ const rejected = await f.add("0s --until-checklist-complete implement")
101
+ assert.equal(rejected.accepted, false)
102
+ assert.match(rejected.error, /--progress-file/)
103
+ await writeFile(path.join(f.directory, "TASK.md"), "- [ ] todo\n")
104
+ await f.add("0s --progress-file TASK.md --until-checklist-complete implement --max-runs 3")
105
+ const status = await f.control("loop-status")
106
+ assert.match(status.status.text, /checklist-stop:TASK\.md/)
107
+ assert.match(status.status.text, /\/loop-stop/)
108
+ assert.match(status.status.text, /\/loop-clear/)
109
+ })
110
+
50
111
  await test("one durable prompt under concurrent idle notifications", async (f) => {
51
112
  assert.equal((await f.add("0s keep working --max-runs 2")).accepted, true)
52
113
  await Promise.all(Array.from({ length: 12 }, () => f.wake()))
@@ -224,7 +224,8 @@ function commandNames(payload) {
224
224
  }
225
225
 
226
226
  async function main() {
227
- assert.equal(process.platform, "linux", "the real OpenCode 2 Loop canary is intentionally Ubuntu-only")
227
+ const supported = process.platform === "linux" || (process.platform === "darwin" && process.env.OPENCODE2_MACOS_NATIVE_CANARY === "1")
228
+ assert.ok(supported, "real OpenCode 2 canary supports Linux or explicitly opted-in macOS CI only")
228
229
 
229
230
  const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-e2e-"))
230
231
  const home = path.join(workspace, ".home")
@@ -8,9 +8,9 @@ import { fileURLToPath } from "node:url"
8
8
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
9
9
  const npmCLI = process.env.npm_execpath
10
10
  assert.ok(npmCLI, "run this through npm run package:smoke")
11
- function run(command, args, cwd) {
11
+ function run(command, args, cwd, timeout = 180_000) {
12
12
  const result = spawnSync(command, args, {
13
- cwd, encoding: "utf8", timeout: 180_000, maxBuffer: 8 * 1024 * 1024,
13
+ cwd, encoding: "utf8", timeout, maxBuffer: 8 * 1024 * 1024,
14
14
  windowsHide: true,
15
15
  env: { ...process.env, npm_config_fetch_retries: "1", npm_config_fetch_timeout: "90000" },
16
16
  })
@@ -30,22 +30,54 @@ try {
30
30
  await writeFile(path.join(consumer, "package.json"), JSON.stringify({ private: true, type: "module" }))
31
31
  const packedTarball = path.join(temp, packed[0].filename)
32
32
  const installArgs = ["install", "--ignore-scripts", "--omit=dev", "--omit=peer", "--no-audit", "--no-fund"]
33
+ const strictInstall = process.env.OPENCODE_PACKAGE_SMOKE_REQUIRE_INSTALL === "1"
34
+ // A cache-only npm install should finish quickly. On Windows, a cache miss
35
+ // sometimes blocks inside npm for the entire 180-second subprocess deadline.
36
+ // Bound the best-effort offline attempt; strict opt-in keeps the full gate.
37
+ const offlineTimeout = process.platform === "win32" && !strictInstall ? 30_000 : 180_000
38
+ async function strictCleanOnlineInstall() {
39
+ // Busy Windows runners can stall fetching npm metadata even after npm ci.
40
+ // Retry once on subprocess ETIMEDOUT using a *new* empty consumer.
41
+ // This path never accepts the tar archive or CI-installed dependency tree
42
+ // as a substitute for a real npm install.
43
+ const maxAttempts = process.platform === "win32" ? 2 : 1
44
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
45
+ try {
46
+ const timeoutMs = process.platform === "win32" && attempt === 2 ? 270_000 : 180_000
47
+ run(process.execPath, [npmCLI, ...installArgs, "--prefer-offline", packedTarball], consumer, timeoutMs)
48
+ console.log("Strict clean npm tarball consumer install PASS")
49
+ return
50
+ } catch (error) {
51
+ if (attempt >= maxAttempts || error?.cause?.code !== "ETIMEDOUT") throw error
52
+ console.warn("Strict Windows npm install timed out; retrying once from a fresh empty consumer, not a fallback")
53
+ await rm(consumer, { recursive: true, force: true, maxRetries: 8, retryDelay: 150 })
54
+ await mkdir(consumer, { recursive: true })
55
+ await writeFile(path.join(consumer, "package.json"), JSON.stringify({ private: true, type: "module" }))
56
+ }
57
+ }
58
+ }
33
59
  try {
34
60
  // npm ci populated this job's npm cache from the lockfile. A cache-only
35
61
  // install remains an exact, clean npm consumer when all metadata is present.
36
- run(process.execPath, [npmCLI, ...installArgs, "--offline", packedTarball], consumer)
62
+ run(process.execPath, [npmCLI, ...installArgs, "--offline", packedTarball], consumer, offlineTimeout)
37
63
  console.log("Clean tarball npm install from local cache PASS")
38
64
  } catch (error) {
39
- if (!/ENOTCACHED|cache mode is .only-if-cached./i.test(String(error))) throw error
40
- if (process.platform !== "win32" || process.env.OPENCODE_PACKAGE_SMOKE_REQUIRE_INSTALL === "1") {
65
+ const cacheMissing = /ENOTCACHED|cache mode is .only-if-cached./i.test(String(error))
66
+ const windowsOfflineTimeout = process.platform === "win32" && !strictInstall && error?.cause?.code === "ETIMEDOUT"
67
+ if (!cacheMissing && !windowsOfflineTimeout) throw error
68
+ if (process.platform !== "win32" || strictInstall) {
41
69
  console.warn("Offline cache incomplete: performing strict bounded online npm consumer install")
42
- run(process.execPath, [npmCLI, ...installArgs, "--prefer-offline", packedTarball], consumer)
70
+ if (strictInstall) await strictCleanOnlineInstall()
71
+ else run(process.execPath, [npmCLI, ...installArgs, "--prefer-offline", packedTarball], consumer)
43
72
  } else {
44
73
  // GitHub Windows runners sporadically hang for minutes while npm fetches
45
74
  // transient dependency packuments. We still check the EXACT production
46
75
  // archive and isolated V2 imports on Windows, using the already-verified
47
76
  // npm-ci dependency tree. Linux continues to gate a real clean npm install.
48
77
  // This is intentionally NOT labelled a successful Windows npm install.
78
+ // An interrupted npm process may have left a partial consumer tree.
79
+ // Never combine that unverified tree with the deterministic archive test.
80
+ await rm(path.join(consumer, "node_modules"), { recursive: true, force: true, maxRetries: 8, retryDelay: 150 })
49
81
  const packageRoot = path.join(consumer, "node_modules", "@bybrawe", "opencode-loop")
50
82
  await mkdir(packageRoot, { recursive: true })
51
83
  run("tar", ["-xzf", packedTarball, "-C", packageRoot, "--strip-components=1"], temp)
@@ -56,7 +88,9 @@ try {
56
88
  path.join(scope, "plugin"),
57
89
  "junction",
58
90
  )
59
- console.warn("Windows npm cache lacks package metadata; testing packed archive with preinstalled dependency tree (not a clean npm install)")
91
+ console.warn(windowsOfflineTimeout
92
+ ? "Windows offline npm install exceeded 30s; checking the production archive with pinned CI dependencies (NOT a clean npm install)"
93
+ : "Windows npm cache lacks package metadata; checking the production archive with pinned CI dependencies (NOT a clean npm install)")
60
94
  }
61
95
  }
62
96
  console.log(run(process.execPath, [path.join(root, "scripts/native-entry-test.mjs"), consumer], root).trim())
@@ -20,6 +20,7 @@ assert.match(workflow, /needs:\s*\[native-regressions, real-native-pair\]/)
20
20
  assert.match(workflow, /node scripts\/native-release\.mjs ci/)
21
21
  assert.match(workflow, /git diff --exit-code -- src\/index.js src\/server.js src\/native.js/)
22
22
  assert.match(workflow, /npm run package:smoke/)
23
+ assert.match(workflow, /OPENCODE_PACKAGE_SMOKE_REQUIRE_INSTALL:\s*\x271\x27/, "Windows/Linux npm release gate must require a strict clean consumer install, never a tarball fallback")
23
24
  assert.match(workflow, /windows-latest/)
24
25
  assert.match(workflow, /Decide whether npm publish is needed/)
25
26
  assert.match(workflow, /steps\.npm-state\.outputs\.publish == 'true'/)
@@ -48,14 +49,14 @@ assert.ok(workflow.includes('--prefer-online --cache "$VERIFY_DIR/npm-cache-$att
48
49
  assert.ok(workflow.includes("Published Loop+Goal clean-install validation did not converge"), "clean consumer must remain a hard release gate")
49
50
 
50
51
  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) } }
52
+ const request = { name: "@bybrawe/opencode-loop", version: "0.6.9", predecessor: "0.6.8", goal: { version: "1.3.49", sha: "b".repeat(40) } }
52
53
  const pkg = { name: request.name, version: request.version }
53
54
  const env = { GITHUB_REPOSITORY: "ByBrawe/opencode-loop", GITHUB_REF: "refs/heads/main", GITHUB_SHA: sha }
54
55
  assert.strictEqual(validateRelease(request, pkg, env), request)
55
56
  for (const change of [{ GITHUB_REPOSITORY: "someone/fork" }, { GITHUB_REF: "refs/heads/release" }, { GITHUB_SHA: "main" }]) assert.throws(() => validateRelease(request, pkg, { ...env, ...change }))
56
57
  assert.throws(() => validateRelease({ ...request, version: "9.0.0" }, pkg, env))
57
58
  assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.5" }, env))
58
- assert.equal(releaseDecision(404, null, request, sha, "0.6.6"), true)
59
+ assert.equal(releaseDecision(404, null, request, sha, "0.6.8"), true)
59
60
  for (const status of [401, 403, 429, 500, 503]) assert.throws(() => releaseDecision(status, null, request, sha, "0.6.0"))
60
61
  assert.throws(() => releaseDecision(404, null, request, sha, "0.7.0"))
61
62
  const manifest = { ...pkg, gitHead: sha }
@@ -1,6 +1,9 @@
1
1
  import assert from "node:assert/strict"
2
2
  import path from "node:path"
3
+ import os from "node:os"
4
+ import { mkdtemp, writeFile, rm } from "node:fs/promises"
3
5
  import { createRunAdmissionRuntime } from "../src/source/runtime/run-admission.js"
6
+ import { inspectTaskChecklist } from "../src/source/runtime/checklist-completion.js"
4
7
 
5
8
  assert.throws(() => createRunAdmissionRuntime({}), /untilReached/)
6
9
  assert.throws(() => createRunAdmissionRuntime({ untilReached: async () => false }), /scheduleDueWork/)
@@ -130,4 +133,48 @@ assert.equal(admitted.paused, undefined)
130
133
  assert.equal(admitted.runNowRequestedAt, clock)
131
134
  assert.equal(logs.findLast((entry) => entry[1] === "preflight")?.[2].code, 0)
132
135
 
136
+ const project = await mkdtemp(path.join(os.tmpdir(), "loop-admission-checklist-"))
137
+ try {
138
+ await writeFile(path.join(project, "TASK.md"), "- [ ] code\n- [x] plan\n")
139
+ const checklistJob = { id: "checklist-job", enabled: true, progressFile: "TASK.md", untilChecklistComplete: true }
140
+ const checklistState = { jobs: [checklistJob] }
141
+ assert.equal((await runtime.admitJob(project, {}, "session-checklist", checklistState, checklistJob)).admitted, true)
142
+ assert.deepEqual(checklistJob.checklistStatus, { status: "pending", total: 2, pending: 1 })
143
+
144
+ await writeFile(path.join(project, "TASK.md"), "- [x] code\n- [x] plan\n")
145
+ assert.deepEqual(await runtime.admitJob(project, {}, "session-checklist", checklistState, checklistJob),
146
+ { admitted: false, reason: "checklist_complete" })
147
+ assert.equal(checklistState.jobs.length, 0)
148
+ assert.equal(notifications.at(-1)[2], "checklist_complete")
149
+
150
+ const verifyJob = { id: "verify-checklist", enabled: true, progressFile: "TASK.md",
151
+ untilChecklistComplete: true, verifyCommand: "npm test" }
152
+ const verifyState = { jobs: [verifyJob] }
153
+ assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true,
154
+ "unchecked host verification cannot imply completion")
155
+ verifyJob.lastVerifyCode = 1
156
+ verifyJob.lastVerifyAt = clock
157
+ assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true,
158
+ "failed host verification cannot imply completion")
159
+ verifyJob.lastVerifyCode = 0
160
+ assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true,
161
+ "successful verify without recorded checklist fingerprint is not authoritative")
162
+ verifyJob.lastVerifyChecklistDigest = (await inspectTaskChecklist(project, "TASK.md")).digest
163
+ await writeFile(path.join(project, "TASK.md"), "- [x] code\n- [x] plan\n- [x] later task\n")
164
+ assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).admitted, true,
165
+ "verification for older checklist bytes cannot certify newly changed work")
166
+ verifyJob.lastVerifyChecklistDigest = (await inspectTaskChecklist(project, "TASK.md")).digest
167
+ assert.equal((await runtime.admitJob(project, {}, "session-verify", verifyState, verifyJob)).reason, "checklist_complete")
168
+
169
+ const missingJob = { id: "missing-checklist", enabled: true, progressFile: "MISSING.md", untilChecklistComplete: true }
170
+ const missingState = { jobs: [missingJob] }
171
+ const missing = await runtime.admitJob(project, {}, "session-missing", missingState, missingJob)
172
+ assert.deepEqual(missing, { admitted: false, reason: "checklist_unavailable" })
173
+ assert.equal(missingJob.paused, true)
174
+ assert.equal(missingJob.lastChecklistError, "file-missing")
175
+ assert.equal(missingState.jobs.length, 1, "preserve job for explicit recovery")
176
+ } finally {
177
+ await rm(project, { force: true, recursive: true })
178
+ }
179
+
133
180
  console.log("run admission runtime tests passed")
@@ -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
+ }