@bybrawe/opencode-loop 0.6.5 → 0.6.7
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 +19 -0
- package/README.md +32 -24
- package/examples/OPENCODE2-0.6.6.md +48 -0
- package/examples/OPENCODE2-0.6.7.md +26 -0
- package/package.json +4 -4
- package/scripts/comprehensive-test.mjs +3 -3
- package/scripts/desktop-command-diagnostics-test.mjs +92 -0
- package/scripts/desktop-command-diagnostics.mjs +237 -0
- package/scripts/goal-companion-test.mjs +23 -15
- package/scripts/host-goal-bootstrap-test.mjs +46 -0
- package/scripts/host-goal-bootstrap.mjs +71 -0
- package/scripts/host-goal-coexistence-canary.mjs +11 -2
- package/scripts/install-with-goals.mjs +28 -118
- package/scripts/native-plugin-lifetime-test.mjs +39 -6
- package/scripts/native-release.mjs +3 -3
- package/scripts/native-v2-test.mjs +64 -2
- package/scripts/opencode2-loop-canary.mjs +55 -1
- package/scripts/package-native-smoke.mjs +40 -5
- package/scripts/publish-workflow-test.mjs +7 -3
- package/scripts/state-integrity-test.mjs +47 -0
- package/scripts/state-process-test.mjs +172 -0
- package/src/index.js +217 -11
- package/src/native.js +236 -19
- package/src/server.js +236 -19
- package/src/source/core/state.js +203 -9
- package/src/source/opencode2/events.js +1 -1
- package/src/source/opencode2/native-plugin.js +4 -6
- package/src/source/opencode2/native-runtime.js +30 -4
|
@@ -118,7 +118,7 @@ function streamText(res, content, sequence) {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
function startProvider() {
|
|
121
|
-
const stats = { chatRequests: 0, loopRequests: 0, paths: [] }
|
|
121
|
+
const stats = { chatRequests: 0, loopRequests: 0, compactionRequests: 0, paths: [] }
|
|
122
122
|
const server = createServer(async (req, res) => {
|
|
123
123
|
const url = new URL(req.url ?? "/", "http://127.0.0.1")
|
|
124
124
|
stats.paths.push(`${req.method} ${url.pathname}`)
|
|
@@ -137,6 +137,18 @@ function startProvider() {
|
|
|
137
137
|
for await (const chunk of req) raw += String(chunk)
|
|
138
138
|
const body = raw ? JSON.parse(raw) : {}
|
|
139
139
|
stats.chatRequests += 1
|
|
140
|
+
// The real host requires a structured checkpoint. A plain "OK" response
|
|
141
|
+
// triggers its retried summary validation and fails for test reasons,
|
|
142
|
+
// unrelated to Loop's durable compaction lifecycle.
|
|
143
|
+
const compactionPrompt = JSON.stringify(body.messages ?? [])
|
|
144
|
+
if (
|
|
145
|
+
compactionPrompt.includes("## Objective")
|
|
146
|
+
&& compactionPrompt.includes("Do not continue the task or call tools.")
|
|
147
|
+
) {
|
|
148
|
+
stats.compactionRequests += 1
|
|
149
|
+
streamText(res, "## Objective\n- Preserve the verified native Loop job and provider canary context.\n\n## Requirements\n- Continue only the configured user work.", stats.chatRequests)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
140
152
|
const text = lastUserText(body)
|
|
141
153
|
if (text.includes("AUTONOMOUS OPENCODE LOOP ITERATION")) {
|
|
142
154
|
stats.loopRequests += 1
|
|
@@ -478,6 +490,48 @@ async function main() {
|
|
|
478
490
|
}, "native shell terminal correlation", diagnostics, 20_000)
|
|
479
491
|
assert.equal(await readFile(path.join(workspace, "native-preflight.txt"), "utf8"), "PP")
|
|
480
492
|
await sendControl("loop-clear")
|
|
493
|
+
|
|
494
|
+
// OpenCode 2.0.22+ exposes public session.compact. Unlike prompt items,
|
|
495
|
+
// a host compaction control does not produce session.inbox.delivered.
|
|
496
|
+
// Exercise a REAL manual compaction and correlate its started.inputID
|
|
497
|
+
// with the request admitted by this Loop job before accepting completion.
|
|
498
|
+
if (process.env.OPENCODE2_NATIVE_COMPACT_CANARY === "1") {
|
|
499
|
+
await sendControl("loop-compact", "0s --now --max-runs 1 --name native-compact")
|
|
500
|
+
await waitFor(async () => {
|
|
501
|
+
const job = await jobNamed("native-compact")
|
|
502
|
+
return Boolean(
|
|
503
|
+
job?.runCount === 1
|
|
504
|
+
&& !job.v2Run
|
|
505
|
+
&& job.enabled === false
|
|
506
|
+
&& typeof job.lastCompactAt === "number"
|
|
507
|
+
&& job.lastCompactAt > 0
|
|
508
|
+
)
|
|
509
|
+
}, "real host-owned manual compaction completion", diagnostics, 90_000)
|
|
510
|
+
|
|
511
|
+
assert.ok(provider.stats.compactionRequests >= 1,
|
|
512
|
+
`host compaction never called the structured-summary model\n${await diagnostics()}`)
|
|
513
|
+
const recorded = (await readFile(eventTrace, "utf8"))
|
|
514
|
+
.split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
515
|
+
.filter((event) => event?.data?.sessionID === sessionID)
|
|
516
|
+
const manualStart = recorded.find((event) =>
|
|
517
|
+
event.type === "session.compaction.started"
|
|
518
|
+
&& event.data?.reason === "manual"
|
|
519
|
+
&& typeof event.data?.inputID === "string"
|
|
520
|
+
&& event.data.inputID.length > 0
|
|
521
|
+
)
|
|
522
|
+
const manualEnd = recorded.find((event) =>
|
|
523
|
+
event.type === "session.compaction.ended"
|
|
524
|
+
&& event.data?.reason === "manual"
|
|
525
|
+
&& manualStart
|
|
526
|
+
&& event.id !== undefined
|
|
527
|
+
) ?? recorded.find((event) =>
|
|
528
|
+
event.type === "session.compaction.ended" && event.data?.reason === "manual"
|
|
529
|
+
)
|
|
530
|
+
assert.ok(manualStart, `real V2 host never started the manual inbox compaction\n${await diagnostics()}`)
|
|
531
|
+
assert.ok(manualEnd, `real V2 host never completed the manual inbox compaction\n${await diagnostics()}`)
|
|
532
|
+
assert.equal(server.exitCode, null)
|
|
533
|
+
await sendControl("loop-clear")
|
|
534
|
+
}
|
|
481
535
|
const beforePresets = provider.stats.loopRequests
|
|
482
536
|
await sendControl("loop-init")
|
|
483
537
|
assert.match(await readFile(path.join(workspace, "progress.md"), "utf8"), /^# Progress\n/)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict"
|
|
2
2
|
import { spawnSync } from "node:child_process"
|
|
3
|
-
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"
|
|
4
4
|
import { tmpdir } from "node:os"
|
|
5
5
|
import path from "node:path"
|
|
6
6
|
import { fileURLToPath } from "node:url"
|
|
@@ -9,9 +9,14 @@ 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
11
|
function run(command, args, cwd) {
|
|
12
|
-
const result = spawnSync(command, args, {
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const result = spawnSync(command, args, {
|
|
13
|
+
cwd, encoding: "utf8", timeout: 180_000, maxBuffer: 8 * 1024 * 1024,
|
|
14
|
+
windowsHide: true,
|
|
15
|
+
env: { ...process.env, npm_config_fetch_retries: "1", npm_config_fetch_timeout: "90000" },
|
|
16
|
+
})
|
|
17
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join("\n")
|
|
18
|
+
if (result.error) throw new Error(`npm smoke subprocess failed: ${result.error.message}\n${output}`, { cause: result.error })
|
|
19
|
+
assert.equal(result.status, 0, output)
|
|
15
20
|
return result.stdout
|
|
16
21
|
}
|
|
17
22
|
const temp = await mkdtemp(path.join(tmpdir(), "loop native package "))
|
|
@@ -23,7 +28,37 @@ try {
|
|
|
23
28
|
const consumer = path.join(temp, "consumer with spaces")
|
|
24
29
|
await mkdir(consumer)
|
|
25
30
|
await writeFile(path.join(consumer, "package.json"), JSON.stringify({ private: true, type: "module" }))
|
|
26
|
-
|
|
31
|
+
const packedTarball = path.join(temp, packed[0].filename)
|
|
32
|
+
const installArgs = ["install", "--ignore-scripts", "--omit=dev", "--omit=peer", "--no-audit", "--no-fund"]
|
|
33
|
+
try {
|
|
34
|
+
// npm ci populated this job's npm cache from the lockfile. A cache-only
|
|
35
|
+
// install remains an exact, clean npm consumer when all metadata is present.
|
|
36
|
+
run(process.execPath, [npmCLI, ...installArgs, "--offline", packedTarball], consumer)
|
|
37
|
+
console.log("Clean tarball npm install from local cache PASS")
|
|
38
|
+
} 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") {
|
|
41
|
+
console.warn("Offline cache incomplete: performing strict bounded online npm consumer install")
|
|
42
|
+
run(process.execPath, [npmCLI, ...installArgs, "--prefer-offline", packedTarball], consumer)
|
|
43
|
+
} else {
|
|
44
|
+
// GitHub Windows runners sporadically hang for minutes while npm fetches
|
|
45
|
+
// transient dependency packuments. We still check the EXACT production
|
|
46
|
+
// archive and isolated V2 imports on Windows, using the already-verified
|
|
47
|
+
// npm-ci dependency tree. Linux continues to gate a real clean npm install.
|
|
48
|
+
// This is intentionally NOT labelled a successful Windows npm install.
|
|
49
|
+
const packageRoot = path.join(consumer, "node_modules", "@bybrawe", "opencode-loop")
|
|
50
|
+
await mkdir(packageRoot, { recursive: true })
|
|
51
|
+
run("tar", ["-xzf", packedTarball, "-C", packageRoot, "--strip-components=1"], temp)
|
|
52
|
+
const scope = path.join(consumer, "node_modules", "@opencode")
|
|
53
|
+
await mkdir(scope, { recursive: true })
|
|
54
|
+
await symlink(
|
|
55
|
+
path.join(root, "node_modules", "@opencode", "plugin"),
|
|
56
|
+
path.join(scope, "plugin"),
|
|
57
|
+
"junction",
|
|
58
|
+
)
|
|
59
|
+
console.warn("Windows npm cache lacks package metadata; testing packed archive with preinstalled dependency tree (not a clean npm install)")
|
|
60
|
+
}
|
|
61
|
+
}
|
|
27
62
|
console.log(run(process.execPath, [path.join(root, "scripts/native-entry-test.mjs"), consumer], root).trim())
|
|
28
63
|
console.log("Production tarball native entry isolation and lazy legacy delegation PASS")
|
|
29
64
|
} finally {
|
|
@@ -43,15 +43,19 @@ for (const block of workflow.split(/- uses: actions\/checkout@/).slice(1)) asser
|
|
|
43
43
|
const publisher = workflow.slice(workflow.indexOf(" publish:\n"), workflow.indexOf(" github-release:\n"))
|
|
44
44
|
assert.doesNotMatch(publisher, /contents:\s*write/, "OIDC publisher must not gain repository mutation privileges")
|
|
45
45
|
|
|
46
|
+
assert.match(workflow, /for attempt in \{1\.\.12\}/, "clean published npm consumer must retry version propagation")
|
|
47
|
+
assert.ok(workflow.includes('--prefer-online --cache "$VERIFY_DIR/npm-cache-$attempt"'), "consumer retries must not reuse a stale npm cache")
|
|
48
|
+
assert.ok(workflow.includes("Published Loop+Goal clean-install validation did not converge"), "clean consumer must remain a hard release gate")
|
|
49
|
+
|
|
46
50
|
const sha = "a".repeat(40)
|
|
47
|
-
const request = { name: "@bybrawe/opencode-loop", version: "0.6.
|
|
51
|
+
const request = { name: "@bybrawe/opencode-loop", version: "0.6.7", predecessor: "0.6.6", goal: { version: "1.3.48", sha: "b".repeat(40) } }
|
|
48
52
|
const pkg = { name: request.name, version: request.version }
|
|
49
53
|
const env = { GITHUB_REPOSITORY: "ByBrawe/opencode-loop", GITHUB_REF: "refs/heads/main", GITHUB_SHA: sha }
|
|
50
54
|
assert.strictEqual(validateRelease(request, pkg, env), request)
|
|
51
55
|
for (const change of [{ GITHUB_REPOSITORY: "someone/fork" }, { GITHUB_REF: "refs/heads/release" }, { GITHUB_SHA: "main" }]) assert.throws(() => validateRelease(request, pkg, { ...env, ...change }))
|
|
52
56
|
assert.throws(() => validateRelease({ ...request, version: "9.0.0" }, pkg, env))
|
|
53
|
-
assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.
|
|
54
|
-
assert.equal(releaseDecision(404, null, request, sha, "0.6.
|
|
57
|
+
assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.5" }, env))
|
|
58
|
+
assert.equal(releaseDecision(404, null, request, sha, "0.6.6"), true)
|
|
55
59
|
for (const status of [401, 403, 429, 500, 503]) assert.throws(() => releaseDecision(status, null, request, sha, "0.6.0"))
|
|
56
60
|
assert.throws(() => releaseDecision(404, null, request, sha, "0.7.0"))
|
|
57
61
|
const manifest = { ...pkg, gitHead: sha }
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { readState, writeState, stateDir } from "../src/source/core/state.js"
|
|
6
|
+
|
|
7
|
+
// An unreadable existing session is not a new/empty session. The old fallback
|
|
8
|
+
// silently returned zero jobs and let the next ordinary write replace all work.
|
|
9
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-state-integrity-"))
|
|
10
|
+
const sessionID = "persisted-session"
|
|
11
|
+
const file = path.join(stateDir(root), `${sessionID}.json`)
|
|
12
|
+
try {
|
|
13
|
+
const missing = await readState(root, sessionID)
|
|
14
|
+
assert.deepEqual(missing.jobs, [], "only ENOENT is a new empty session")
|
|
15
|
+
|
|
16
|
+
const started = await readState(root, sessionID)
|
|
17
|
+
started.jobs = [{ id: "keep-this-job", enabled: true, runCount: 7 }]
|
|
18
|
+
await writeState(root, sessionID, started)
|
|
19
|
+
const oldSnapshot = await readState(root, sessionID)
|
|
20
|
+
assert.equal(oldSnapshot.jobs.length, 1)
|
|
21
|
+
assert.equal(oldSnapshot.jobs[0].runCount, 7)
|
|
22
|
+
|
|
23
|
+
const corrupt = '{"version":4,"jobs":['
|
|
24
|
+
await mkdir(stateDir(root), { recursive: true })
|
|
25
|
+
await writeFile(file, corrupt)
|
|
26
|
+
await assert.rejects(readState(root, sessionID), /could not be read; refusing to replace persisted jobs/)
|
|
27
|
+
assert.equal(await readFile(file, "utf8"), corrupt, "read must not reset the original state")
|
|
28
|
+
const backups = (await readdir(stateDir(root))).filter((name) => name.startsWith(`${sessionID}.json.corrupt-`))
|
|
29
|
+
assert.ok(backups.length >= 1, "corrupted bytes should be backed up for investigation")
|
|
30
|
+
assert.equal(await readFile(path.join(stateDir(root), backups[0]), "utf8"), corrupt)
|
|
31
|
+
|
|
32
|
+
await writeFile(file, JSON.stringify({ version: 4, jobs: { invalid: "shape" } }))
|
|
33
|
+
await assert.rejects(readState(root, sessionID), /could not be read; refusing to replace persisted jobs/)
|
|
34
|
+
assert.equal(JSON.parse(await readFile(file, "utf8")).jobs.invalid, "shape")
|
|
35
|
+
await writeFile(file, corrupt)
|
|
36
|
+
|
|
37
|
+
oldSnapshot.jobs[0].runCount = 8
|
|
38
|
+
await assert.rejects(
|
|
39
|
+
writeState(root, sessionID, oldSnapshot),
|
|
40
|
+
/could not be read; refusing to replace persisted jobs/,
|
|
41
|
+
"a stale snapshot must not replace a corrupt persisted session",
|
|
42
|
+
)
|
|
43
|
+
assert.equal(await readFile(file, "utf8"), corrupt)
|
|
44
|
+
console.log("Loop corrupt-state fail-closed and no-lost-jobs regression passed")
|
|
45
|
+
} finally {
|
|
46
|
+
await rm(root, { recursive: true, force: true })
|
|
47
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
3
|
+
import { randomUUID } from "node:crypto"
|
|
4
|
+
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile, link, stat } from "node:fs/promises"
|
|
5
|
+
import os from "node:os"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import { fileURLToPath } from "node:url"
|
|
8
|
+
import { readState, writeState, removeState, stateDir } from "../src/source/core/state.js"
|
|
9
|
+
|
|
10
|
+
const filename = fileURLToPath(import.meta.url)
|
|
11
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
12
|
+
async function exists(file) { try { await stat(file); return true } catch (error) { if (error?.code === "ENOENT") return false; throw error } }
|
|
13
|
+
async function until(fn, duration = 15_000) {
|
|
14
|
+
const started = Date.now()
|
|
15
|
+
while (Date.now() - started < duration) {
|
|
16
|
+
if (await fn()) return
|
|
17
|
+
await pause(20)
|
|
18
|
+
}
|
|
19
|
+
throw new Error("state worker sync timeout")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (process.argv[2] === "--worker") {
|
|
23
|
+
const [, , , root, sessionID, marker, gate] = process.argv
|
|
24
|
+
const snapshot = await readState(root, sessionID)
|
|
25
|
+
await writeFile(`${gate}.${marker}.ready`, "ready")
|
|
26
|
+
await until(() => exists(gate))
|
|
27
|
+
if (marker === "delete") await removeState(root, sessionID)
|
|
28
|
+
else {
|
|
29
|
+
snapshot.jobs[0][marker] = Number(marker.slice(1))
|
|
30
|
+
await writeState(root, sessionID, snapshot)
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-process-lock-"))
|
|
34
|
+
const sessionID = "multi-process"
|
|
35
|
+
const lockFile = path.join(stateDir(root), `${sessionID}.json.lock`)
|
|
36
|
+
try {
|
|
37
|
+
for (let round = 0; round < 6; round++) {
|
|
38
|
+
const initial = await readState(root, sessionID)
|
|
39
|
+
initial.jobs = [{ id: "shared", round }]
|
|
40
|
+
await writeState(root, sessionID, initial)
|
|
41
|
+
const gate = path.join(root, `round-${round}.go`)
|
|
42
|
+
const workers = []
|
|
43
|
+
for (let n = 0; n < 4; n++) {
|
|
44
|
+
const marker = `f${n}`
|
|
45
|
+
const child = spawn(process.execPath, [filename, "--worker", root, sessionID, marker, gate], { stdio: ["ignore", "ignore", "pipe"] })
|
|
46
|
+
let stderr = ""
|
|
47
|
+
child.stderr.on("data", (part) => { stderr += part.toString() })
|
|
48
|
+
const closed = new Promise((resolve, reject) => {
|
|
49
|
+
child.once("error", reject)
|
|
50
|
+
child.once("close", (code) => code === 0 ? resolve() : reject(new Error(`worker ${marker} exited ${code}: ${stderr}`)))
|
|
51
|
+
})
|
|
52
|
+
workers.push({ marker, closed })
|
|
53
|
+
}
|
|
54
|
+
await until(async () => (await Promise.all(workers.map((w) => exists(`${gate}.${w.marker}.ready`)))).every(Boolean))
|
|
55
|
+
await writeFile(gate, "go")
|
|
56
|
+
await Promise.all(workers.map((w) => w.closed))
|
|
57
|
+
const saved = (await readState(root, sessionID)).jobs[0]
|
|
58
|
+
assert.equal(saved.round, round)
|
|
59
|
+
for (let n = 0; n < 4; n++) assert.equal(saved[`f${n}`], n, "each independent writer must survive")
|
|
60
|
+
assert.equal(await exists(lockFile), false, "all writers must release the lock")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A stale writer must not resurrect a job removed by another process.
|
|
64
|
+
// Race both operation orders: the lock serializes remove and write, while
|
|
65
|
+
// baseline-aware merge must preserve the deletion if write runs second.
|
|
66
|
+
for (let round = 0; round < 8; round++) {
|
|
67
|
+
const seed = await readState(root, sessionID)
|
|
68
|
+
seed.jobs = [{ id: "shared", round: `delete-${round}` }]
|
|
69
|
+
await writeState(root, sessionID, seed)
|
|
70
|
+
const gate = path.join(root, `delete-${round}.go`)
|
|
71
|
+
const workers = []
|
|
72
|
+
for (const marker of ["delete", "f0"]) {
|
|
73
|
+
const child = spawn(process.execPath, [filename, "--worker", root, sessionID, marker, gate], {
|
|
74
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
75
|
+
})
|
|
76
|
+
let stderr = ""
|
|
77
|
+
child.stderr.on("data", (part) => { stderr += part.toString() })
|
|
78
|
+
workers.push({
|
|
79
|
+
marker,
|
|
80
|
+
closed: new Promise((resolve, reject) => {
|
|
81
|
+
child.once("error", reject)
|
|
82
|
+
child.once("close", (code) => code === 0 ? resolve() : reject(new Error(`worker ${marker} exited ${code}: ${stderr}`)))
|
|
83
|
+
}),
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
await until(async () => (await Promise.all(workers.map((w) =>
|
|
87
|
+
exists(`${gate}.${w.marker}.ready`)))).every(Boolean))
|
|
88
|
+
await writeFile(gate, "go")
|
|
89
|
+
await Promise.all(workers.map((w) => w.closed))
|
|
90
|
+
assert.deepEqual((await readState(root, sessionID)).jobs, [],
|
|
91
|
+
"remove-vs-stale-writer must not resurrect deleted work")
|
|
92
|
+
assert.equal(await exists(lockFile), false, "both processes must release lock after deletion race")
|
|
93
|
+
}
|
|
94
|
+
const fresh = await readState(root, sessionID)
|
|
95
|
+
fresh.jobs = [{ id: "shared", round: "reseeded" }]
|
|
96
|
+
await writeState(root, sessionID, fresh)
|
|
97
|
+
|
|
98
|
+
// A malformed lock is never stolen or silently removed.
|
|
99
|
+
const corrupt = await readState(root, sessionID)
|
|
100
|
+
await writeFile(lockFile, "corrupt owner")
|
|
101
|
+
corrupt.jobs[0].newField = "must-not-commit"
|
|
102
|
+
await assert.rejects(writeState(root, sessionID, corrupt), /state lock invalid_owner/)
|
|
103
|
+
assert.equal((await readState(root, sessionID)).jobs[0].newField, undefined)
|
|
104
|
+
await rm(lockFile)
|
|
105
|
+
|
|
106
|
+
// A dead owner is reclaimed with an election lock, never a blind unlink.
|
|
107
|
+
const dead = spawnSync(process.execPath, ["-e", "process.exit(0)"], { encoding: "utf8" })
|
|
108
|
+
assert.equal(dead.status, 0)
|
|
109
|
+
assert.ok(dead.pid > 0)
|
|
110
|
+
const token = randomUUID()
|
|
111
|
+
const candidate = path.join(stateDir(root), `.loop-lock-${dead.pid}-${token}.json`)
|
|
112
|
+
const owner = { schemaVersion: 1, pid: dead.pid, token, acquiredAt: Date.now(), candidateName: path.basename(candidate) }
|
|
113
|
+
await writeFile(candidate, JSON.stringify(owner))
|
|
114
|
+
await link(candidate, lockFile)
|
|
115
|
+
// Simulate a crash while an earlier process was cleaning the dead owner.
|
|
116
|
+
// A stray, dead `.lock.cleanup` must not permanently block recovery.
|
|
117
|
+
const cleanupToken = randomUUID()
|
|
118
|
+
const cleanupCandidate = path.join(stateDir(root), `.loop-lock-${dead.pid}-${cleanupToken}.json`)
|
|
119
|
+
const cleanupOwner = {
|
|
120
|
+
schemaVersion: 1, pid: dead.pid, token: cleanupToken,
|
|
121
|
+
acquiredAt: Date.now(), candidateName: path.basename(cleanupCandidate),
|
|
122
|
+
}
|
|
123
|
+
await writeFile(cleanupCandidate, JSON.stringify(cleanupOwner))
|
|
124
|
+
await link(cleanupCandidate, `${lockFile}.cleanup`)
|
|
125
|
+
const revived = await readState(root, sessionID)
|
|
126
|
+
revived.jobs[0].recovered = true
|
|
127
|
+
await writeState(root, sessionID, revived)
|
|
128
|
+
assert.equal((await readState(root, sessionID)).jobs[0].recovered, true)
|
|
129
|
+
assert.equal(await exists(lockFile), false)
|
|
130
|
+
assert.equal(await exists(candidate), false)
|
|
131
|
+
assert.equal(await exists(cleanupCandidate), false, "orphaned election candidate must be cleaned")
|
|
132
|
+
assert.equal(await exists(`${lockFile}.cleanup`), false, "orphaned cleanup lock must be removed")
|
|
133
|
+
|
|
134
|
+
// A malformed election must fail closed and not delete another owner's files.
|
|
135
|
+
const badToken = randomUUID()
|
|
136
|
+
const badCandidate = path.join(stateDir(root), `.loop-lock-${dead.pid}-${badToken}.json`)
|
|
137
|
+
const badOwner = {
|
|
138
|
+
schemaVersion: 1, pid: dead.pid, token: badToken,
|
|
139
|
+
acquiredAt: Date.now(), candidateName: path.basename(badCandidate),
|
|
140
|
+
}
|
|
141
|
+
await writeFile(badCandidate, JSON.stringify(badOwner))
|
|
142
|
+
await link(badCandidate, lockFile)
|
|
143
|
+
await writeFile(`${lockFile}.cleanup`, "not valid owner metadata")
|
|
144
|
+
const blocked = await readState(root, sessionID)
|
|
145
|
+
blocked.jobs[0].mustNotWrite = true
|
|
146
|
+
await assert.rejects(writeState(root, sessionID, blocked), /state lock invalid_owner/)
|
|
147
|
+
assert.equal((await readState(root, sessionID)).jobs[0].mustNotWrite, undefined)
|
|
148
|
+
assert.equal(await exists(lockFile), true, "invalid election must not erase the dead canonical owner")
|
|
149
|
+
await rm(lockFile)
|
|
150
|
+
await rm(`${lockFile}.cleanup`)
|
|
151
|
+
await rm(badCandidate)
|
|
152
|
+
|
|
153
|
+
// A path supplied by another tool cannot symlink Loop state outside the project.
|
|
154
|
+
const other = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-escape-"))
|
|
155
|
+
const unsafe = path.join(root, "unsafe")
|
|
156
|
+
await mkdir(unsafe)
|
|
157
|
+
try {
|
|
158
|
+
const linkTarget = path.join(unsafe, ".opencode")
|
|
159
|
+
try {
|
|
160
|
+
const { symlink } = await import("node:fs/promises")
|
|
161
|
+
await symlink(other, linkTarget, process.platform === "win32" ? "junction" : "dir")
|
|
162
|
+
await assert.rejects(readState(unsafe, sessionID), /state lock unsafe_path/)
|
|
163
|
+
await assert.rejects(writeState(unsafe, sessionID, { version: 4, jobs: [] }), /state lock unsafe_path/)
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES"].includes(error?.code)) throw error
|
|
166
|
+
}
|
|
167
|
+
} finally { await rm(other, { recursive: true, force: true }) }
|
|
168
|
+
console.log("Loop multi-process write/delete races, stale-owner recovery, and unsafe-path regressions passed")
|
|
169
|
+
} finally {
|
|
170
|
+
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
|
|
171
|
+
}
|
|
172
|
+
}
|
package/src/index.js
CHANGED
|
@@ -381,6 +381,7 @@ import { spawn } from "child_process";
|
|
|
381
381
|
|
|
382
382
|
// src/source/core/state.js
|
|
383
383
|
import { promises as fs } from "fs";
|
|
384
|
+
import { randomUUID as stateLockRandomUUID } from "crypto";
|
|
384
385
|
import os from "os";
|
|
385
386
|
import path from "path";
|
|
386
387
|
var STATE_DIR = ".opencode/opencode-loop";
|
|
@@ -403,6 +404,194 @@ async function pathExists(filePath) {
|
|
|
403
404
|
return false;
|
|
404
405
|
}
|
|
405
406
|
}
|
|
407
|
+
var STATE_PROCESS_LOCK_TIMEOUT_MS = 5000;
|
|
408
|
+
var STATE_PROCESS_LOCK_POLL_MS = 20;
|
|
409
|
+
function stateLockError(kind, detail) {
|
|
410
|
+
const error = new Error(`OpenCode Loop state lock ${kind}: ${detail}`);
|
|
411
|
+
error.code = "OPENCODE_LOOP_STATE_LOCK";
|
|
412
|
+
return error;
|
|
413
|
+
}
|
|
414
|
+
async function assertStatePathSafe(directory, target) {
|
|
415
|
+
const root = path.resolve(directory);
|
|
416
|
+
const relative = path.relative(root, path.resolve(target));
|
|
417
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
418
|
+
throw stateLockError("unsafe_path", `path escapes project: ${target}`);
|
|
419
|
+
}
|
|
420
|
+
let current = root;
|
|
421
|
+
for (const part of relative.split(path.sep).filter(Boolean)) {
|
|
422
|
+
current = path.join(current, part);
|
|
423
|
+
let info;
|
|
424
|
+
try {
|
|
425
|
+
info = await fs.lstat(current);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (error?.code === "ENOENT")
|
|
428
|
+
return;
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
if (info.isSymbolicLink())
|
|
432
|
+
throw stateLockError("unsafe_path", `symbolic link or junction: ${current}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function stateProcessLockOwner() {
|
|
436
|
+
const token = stateLockRandomUUID();
|
|
437
|
+
return {
|
|
438
|
+
schemaVersion: 1,
|
|
439
|
+
pid: process.pid,
|
|
440
|
+
token,
|
|
441
|
+
acquiredAt: Date.now(),
|
|
442
|
+
candidateName: `.loop-lock-${process.pid}-${token}.json`
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
function stateProcessLockOwnerValid(owner) {
|
|
446
|
+
return owner?.schemaVersion === 1 && Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.token === "string" && /^[0-9a-f-]{36}$/i.test(owner.token) && Number.isFinite(owner.acquiredAt) && owner.acquiredAt > 0 && owner.candidateName === `.loop-lock-${owner.pid}-${owner.token}.json` && path.basename(owner.candidateName) === owner.candidateName;
|
|
447
|
+
}
|
|
448
|
+
async function readStateProcessOwner(file) {
|
|
449
|
+
let raw;
|
|
450
|
+
for (let attempt = 0;; attempt++) {
|
|
451
|
+
try {
|
|
452
|
+
raw = await fs.readFile(file, "utf8");
|
|
453
|
+
break;
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (error?.code === "ENOENT")
|
|
456
|
+
return null;
|
|
457
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
458
|
+
throw error;
|
|
459
|
+
await delay(10 * (attempt + 1));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
let owner;
|
|
463
|
+
try {
|
|
464
|
+
owner = JSON.parse(raw);
|
|
465
|
+
} catch {
|
|
466
|
+
throw stateLockError("invalid_owner", `corrupt lock metadata at ${file}`);
|
|
467
|
+
}
|
|
468
|
+
if (!stateProcessLockOwnerValid(owner))
|
|
469
|
+
throw stateLockError("invalid_owner", `invalid lock metadata at ${file}`);
|
|
470
|
+
return owner;
|
|
471
|
+
}
|
|
472
|
+
function stateProcessAlive(pid) {
|
|
473
|
+
if (pid === process.pid)
|
|
474
|
+
return true;
|
|
475
|
+
try {
|
|
476
|
+
process.kill(pid, 0);
|
|
477
|
+
return true;
|
|
478
|
+
} catch (error) {
|
|
479
|
+
return error?.code !== "ESRCH";
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async function removeStateProcessFile(file) {
|
|
483
|
+
for (let attempt = 0;; attempt++) {
|
|
484
|
+
try {
|
|
485
|
+
await fs.rm(file, { force: true });
|
|
486
|
+
return;
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
489
|
+
throw error;
|
|
490
|
+
await delay(10 * (attempt + 1));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async function claimStateProcessLock(candidate, canonical) {
|
|
495
|
+
for (let attempt = 0;; attempt++) {
|
|
496
|
+
try {
|
|
497
|
+
await fs.link(candidate, canonical);
|
|
498
|
+
return true;
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if (error?.code === "EEXIST")
|
|
501
|
+
return false;
|
|
502
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
503
|
+
throw error;
|
|
504
|
+
await delay(10 * (attempt + 1));
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
async function createStateLockCandidate(directory, owner) {
|
|
509
|
+
const candidate = path.join(stateDir(directory), owner.candidateName);
|
|
510
|
+
await assertStatePathSafe(directory, candidate);
|
|
511
|
+
await fs.writeFile(candidate, `${JSON.stringify(owner)}
|
|
512
|
+
`, { flag: "wx", mode: 384 });
|
|
513
|
+
return candidate;
|
|
514
|
+
}
|
|
515
|
+
async function recoverDeadStateProcessLock(directory, canonical, stale, depth = 0) {
|
|
516
|
+
const cleanupFile = `${canonical}.cleanup`;
|
|
517
|
+
const cleanupOwner = stateProcessLockOwner();
|
|
518
|
+
const cleanupCandidate = await createStateLockCandidate(directory, cleanupOwner);
|
|
519
|
+
let elected = false;
|
|
520
|
+
try {
|
|
521
|
+
await assertStatePathSafe(directory, cleanupFile);
|
|
522
|
+
elected = await claimStateProcessLock(cleanupCandidate, cleanupFile);
|
|
523
|
+
if (!elected) {
|
|
524
|
+
const existing = await readStateProcessOwner(cleanupFile);
|
|
525
|
+
if (existing && !stateProcessAlive(existing.pid)) {
|
|
526
|
+
if (depth >= 6)
|
|
527
|
+
throw stateLockError("recovery_depth", `too many orphaned cleanup elections for ${canonical}`);
|
|
528
|
+
await recoverDeadStateProcessLock(directory, cleanupFile, existing, depth + 1);
|
|
529
|
+
}
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
const current = await readStateProcessOwner(canonical);
|
|
533
|
+
if (!current || current.token !== stale.token || current.pid !== stale.pid || stateProcessAlive(current.pid))
|
|
534
|
+
return false;
|
|
535
|
+
await assertStatePathSafe(directory, canonical);
|
|
536
|
+
await removeStateProcessFile(canonical);
|
|
537
|
+
const staleCandidate = path.join(stateDir(directory), current.candidateName);
|
|
538
|
+
await assertStatePathSafe(directory, staleCandidate);
|
|
539
|
+
await removeStateProcessFile(staleCandidate).catch(() => {});
|
|
540
|
+
return true;
|
|
541
|
+
} finally {
|
|
542
|
+
if (elected) {
|
|
543
|
+
const current = await readStateProcessOwner(cleanupFile).catch(() => null);
|
|
544
|
+
if (current?.token === cleanupOwner.token)
|
|
545
|
+
await removeStateProcessFile(cleanupFile);
|
|
546
|
+
}
|
|
547
|
+
await removeStateProcessFile(cleanupCandidate).catch(() => {});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
async function acquireStateProcessLock(directory, sessionID) {
|
|
551
|
+
const targetDirectory = stateDir(directory);
|
|
552
|
+
await assertStatePathSafe(directory, targetDirectory);
|
|
553
|
+
await ensureDir(targetDirectory);
|
|
554
|
+
await assertStatePathSafe(directory, targetDirectory);
|
|
555
|
+
const target = statePath(directory, sessionID);
|
|
556
|
+
const canonical = `${target}.lock`;
|
|
557
|
+
await assertStatePathSafe(directory, target);
|
|
558
|
+
await assertStatePathSafe(directory, canonical);
|
|
559
|
+
const owner = stateProcessLockOwner();
|
|
560
|
+
const candidate = await createStateLockCandidate(directory, owner);
|
|
561
|
+
const started = Date.now();
|
|
562
|
+
let acquired = false;
|
|
563
|
+
try {
|
|
564
|
+
while (true) {
|
|
565
|
+
await assertStatePathSafe(directory, canonical);
|
|
566
|
+
if (await claimStateProcessLock(candidate, canonical)) {
|
|
567
|
+
acquired = true;
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
const current = await readStateProcessOwner(canonical);
|
|
571
|
+
if (current && !stateProcessAlive(current.pid))
|
|
572
|
+
await recoverDeadStateProcessLock(directory, canonical, current);
|
|
573
|
+
if (Date.now() - started >= STATE_PROCESS_LOCK_TIMEOUT_MS) {
|
|
574
|
+
throw stateLockError("timeout", `session ${sessionID} remained locked for ${STATE_PROCESS_LOCK_TIMEOUT_MS}ms`);
|
|
575
|
+
}
|
|
576
|
+
await delay(STATE_PROCESS_LOCK_POLL_MS);
|
|
577
|
+
}
|
|
578
|
+
return async () => {
|
|
579
|
+
const current = await readStateProcessOwner(canonical);
|
|
580
|
+
if (current?.token !== owner.token || current.pid !== owner.pid) {
|
|
581
|
+
throw stateLockError("lost", `session ${sessionID} lock ownership changed`);
|
|
582
|
+
}
|
|
583
|
+
await assertStatePathSafe(directory, canonical);
|
|
584
|
+
await removeStateProcessFile(canonical);
|
|
585
|
+
await removeStateProcessFile(candidate);
|
|
586
|
+
};
|
|
587
|
+
} catch (error) {
|
|
588
|
+
if (acquired && (await readStateProcessOwner(canonical).catch(() => null))?.token === owner.token) {
|
|
589
|
+
await removeStateProcessFile(canonical).catch(() => {});
|
|
590
|
+
}
|
|
591
|
+
await removeStateProcessFile(candidate).catch(() => {});
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
406
595
|
function stateLockKey(directory, sessionID) {
|
|
407
596
|
return `${path.resolve(directory)}:${safeID(sessionID)}`;
|
|
408
597
|
}
|
|
@@ -416,21 +605,32 @@ async function withStateWriteLock(directory, sessionID, fn) {
|
|
|
416
605
|
const next = previous.catch(() => {}).then(() => current);
|
|
417
606
|
stateWriteLocks.set(key, next);
|
|
418
607
|
await previous.catch(() => {});
|
|
608
|
+
let unlock;
|
|
419
609
|
try {
|
|
610
|
+
unlock = await acquireStateProcessLock(directory, sessionID);
|
|
420
611
|
return await fn();
|
|
421
612
|
} finally {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
613
|
+
try {
|
|
614
|
+
if (unlock)
|
|
615
|
+
await unlock();
|
|
616
|
+
} finally {
|
|
617
|
+
release();
|
|
618
|
+
if (stateWriteLocks.get(key) === next)
|
|
619
|
+
stateWriteLocks.delete(key);
|
|
620
|
+
}
|
|
425
621
|
}
|
|
426
622
|
}
|
|
427
623
|
async function readStateFile(directory, sessionID) {
|
|
428
624
|
const target = statePath(directory, sessionID);
|
|
625
|
+
await assertStatePathSafe(directory, target);
|
|
429
626
|
const attempts = 5;
|
|
430
627
|
for (let attempt = 0;attempt < attempts; attempt++) {
|
|
431
628
|
try {
|
|
432
629
|
const parsed = JSON.parse(await fs.readFile(target, "utf8"));
|
|
433
|
-
|
|
630
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.jobs)) {
|
|
631
|
+
throw new SyntaxError("invalid Loop state schema: jobs must be an array");
|
|
632
|
+
}
|
|
633
|
+
return { version: 4, jobs: parsed.jobs };
|
|
434
634
|
} catch (error) {
|
|
435
635
|
if (error?.code === "ENOENT")
|
|
436
636
|
return { version: 4, jobs: [] };
|
|
@@ -444,7 +644,7 @@ async function readStateFile(directory, sessionID) {
|
|
|
444
644
|
await ensureDir(stateDir(directory));
|
|
445
645
|
await fs.copyFile(target, `${target}.corrupt-${Date.now()}`);
|
|
446
646
|
} catch {}
|
|
447
|
-
|
|
647
|
+
throw new Error(`OpenCode Loop state at ${target} could not be read; refusing to replace persisted jobs.`);
|
|
448
648
|
}
|
|
449
649
|
async function readState(directory, sessionID) {
|
|
450
650
|
const state = await readStateFile(directory, sessionID);
|
|
@@ -574,25 +774,31 @@ function mergeStateJobs(baseJobs, intendedJobs, currentJobs) {
|
|
|
574
774
|
}
|
|
575
775
|
async function writeState(directory, sessionID, state) {
|
|
576
776
|
await withStateWriteLock(directory, sessionID, async () => {
|
|
577
|
-
await ensureDir(stateDir(directory));
|
|
578
777
|
const target = statePath(directory, sessionID);
|
|
778
|
+
await assertStatePathSafe(directory, target);
|
|
779
|
+
const current = await readStateFile(directory, sessionID);
|
|
579
780
|
const baseline = state?.[STATE_BASELINE];
|
|
580
781
|
let jobs = structuredClone(state.jobs || []);
|
|
581
782
|
if (Array.isArray(baseline)) {
|
|
582
|
-
const current = await readStateFile(directory, sessionID);
|
|
583
783
|
jobs = mergeStateJobs(baseline, jobs, current.jobs || []);
|
|
584
|
-
state.jobs = structuredClone(jobs);
|
|
585
|
-
state[STATE_BASELINE] = structuredClone(jobs);
|
|
586
784
|
}
|
|
587
785
|
const payload = JSON.stringify({ version: 4, jobs }, null, 2);
|
|
588
786
|
await writeFileAtomically(target, payload);
|
|
787
|
+
state.jobs = structuredClone(jobs);
|
|
788
|
+
if (Array.isArray(baseline))
|
|
789
|
+
state[STATE_BASELINE] = structuredClone(jobs);
|
|
589
790
|
});
|
|
590
791
|
}
|
|
591
792
|
async function removeState(directory, sessionID) {
|
|
592
793
|
await withStateWriteLock(directory, sessionID, async () => {
|
|
794
|
+
const target = statePath(directory, sessionID);
|
|
795
|
+
await assertStatePathSafe(directory, target);
|
|
593
796
|
try {
|
|
594
|
-
await fs.unlink(
|
|
595
|
-
} catch {
|
|
797
|
+
await fs.unlink(target);
|
|
798
|
+
} catch (error) {
|
|
799
|
+
if (error?.code !== "ENOENT")
|
|
800
|
+
throw error;
|
|
801
|
+
}
|
|
596
802
|
});
|
|
597
803
|
}
|
|
598
804
|
|