@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/CHANGELOG.md +17 -0
- package/README.md +21 -9
- package/examples/OPENCODE2-0.6.8.md +18 -0
- package/examples/OPENCODE2-0.6.9.md +34 -0
- package/package.json +3 -3
- package/scripts/checklist-completion-test.mjs +104 -0
- package/scripts/comprehensive-test.mjs +9 -1
- package/scripts/desktop-command-diagnostics-test.mjs +97 -2
- package/scripts/desktop-command-diagnostics.mjs +70 -9
- package/scripts/loopd-test.mjs +1 -1
- package/scripts/loopd.mjs +4 -2
- package/scripts/native-cli-io-test.mjs +6 -3
- package/scripts/native-entry-test.mjs +16 -6
- package/scripts/native-release.mjs +3 -3
- package/scripts/native-v2-test.mjs +61 -0
- package/scripts/opencode2-loop-canary.mjs +2 -1
- package/scripts/package-native-smoke.mjs +41 -7
- package/scripts/publish-workflow-test.mjs +3 -2
- package/scripts/run-admission-test.mjs +47 -0
- package/scripts/state-exfat-lock-test.mjs +99 -0
- package/src/index.js +187 -2
- package/src/native.js +208 -15
- package/src/server.js +212 -19
- package/src/source/core/args.js +3 -0
- package/src/source/core/jobs.js +1 -0
- package/src/source/core/state.js +33 -0
- package/src/source/opencode2/native-plugin.js +3 -0
- package/src/source/opencode2/native-policy.js +21 -0
- package/src/source/opencode2/native-runtime.js +1 -0
- package/src/source/opencode2/status.js +5 -1
- package/src/source/runtime/checklist-completion.js +113 -0
- package/src/source/runtime/run-admission.js +25 -0
- package/src/source/runtime/run-finalization.js +7 -0
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,
|
|
52
|
-
assert.equal(call.cwd,
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
15
|
-
assert.equal(request.goal?.version, "1.3.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
+
}
|