@bybrawe/opencode-loop 0.6.6 → 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.
@@ -83,6 +83,17 @@ function printCompanionHelp() {
83
83
  }
84
84
 
85
85
  async function main() {
86
+ if (rawArgs[0] === "--desktop-diagnostics") {
87
+ // Diagnostic mode must not run either installer or modify any configuration.
88
+ const { runDesktopDiagnosticCli } = await import("./desktop-command-diagnostics.mjs")
89
+ process.exitCode = await runDesktopDiagnosticCli(rawArgs.slice(1))
90
+ return
91
+ }
92
+ if (rawArgs.includes("--desktop-diagnostics")) {
93
+ console.error("--desktop-diagnostics must be the first argument.")
94
+ process.exitCode = 2
95
+ return
96
+ }
86
97
  if (withGoals && loopOnly) {
87
98
  console.error("Use either --with-goals or --loop-only, not both.")
88
99
  process.exitCode = 2
@@ -5,8 +5,10 @@ import os from "node:os"
5
5
  import path from "node:path"
6
6
  import plugin from "../src/source/opencode2/native-plugin.js"
7
7
 
8
- function fixture(directory, { failCleanup = false } = {}) {
8
+ function fixture(directory, { failCleanup = false, captureCommands = false } = {}) {
9
9
  const released = []
10
+ const commands = new Map()
11
+ const prompts = []
10
12
  let releaseRead, signal
11
13
  let streamClosed = false
12
14
  const end = () => releaseRead?.()
@@ -18,10 +20,17 @@ function fixture(directory, { failCleanup = false } = {}) {
18
20
  location: { directory }, options: {}, app: { version: "2.0.18" },
19
21
  session: {
20
22
  get: async ({ sessionID }) => ({ id: sessionID, location: { directory } }),
21
- prompt: async () => { throw new Error("cleanup must not dispatch a prompt") },
23
+ prompt: async (request) => {
24
+ if (!captureCommands) throw new Error("cleanup must not dispatch a prompt")
25
+ prompts.push(request)
26
+ return { id: "test-prompt" }
27
+ },
22
28
  hook: async (name) => registration(name),
23
29
  },
24
- command: { transform: async () => registration("commands") },
30
+ command: { transform: async (register) => {
31
+ if (captureCommands) await register({ add: ({ name, execute }) => commands.set(name, execute) })
32
+ return registration("commands")
33
+ } },
25
34
  event: { subscribe(options = {}) {
26
35
  signal = options.signal
27
36
  return (async function* () {
@@ -35,7 +44,7 @@ function fixture(directory, { failCleanup = false } = {}) {
35
44
  })()
36
45
  } },
37
46
  }
38
- return { ctx, end, released, signal: () => signal, streamClosed: () => streamClosed }
47
+ return { ctx, end, released, commands, prompts, signal: () => signal, streamClosed: () => streamClosed }
39
48
  }
40
49
 
41
50
  async function bounded(promise) {
@@ -100,19 +109,22 @@ test("native Loop cleanup is shared and drains all registrations after an error"
100
109
  }
101
110
  })
102
111
 
103
- test("native Loop never probes undocumented SessionDomain shell compact or inbox fields", async () => {
112
+ test("native Loop never probes undocumented SessionDomain shell or inbox fields", async () => {
104
113
  const directory = await mkdtemp(path.join(os.tmpdir(), "loop-v2-public-session-"))
105
114
  const host = fixture(directory)
106
115
  let dispose
107
116
  try {
108
- for (const field of ["shell", "compact", "inbox"]) {
117
+ for (const field of ["shell", "inbox"]) {
109
118
  Object.defineProperty(host.ctx.session, field, {
110
119
  configurable: true,
111
120
  get() { throw new Error(`undocumented session.${field} was accessed`) },
112
121
  })
113
122
  }
123
+ const calls = []
124
+ host.ctx.session.compact = async (input) => { calls.push(input); return { id: input.id } }
114
125
  dispose = await plugin.setup(host.ctx)
115
126
  assert.equal(typeof dispose, "function")
127
+ assert.deepEqual(calls, [], "public compact is invoked only for scheduled work")
116
128
  } finally {
117
129
  host.end()
118
130
  await dispose?.().catch(() => {})
@@ -123,3 +135,24 @@ test("native Loop never probes undocumented SessionDomain shell compact or inbox
123
135
  test("native Loop uses the same public plugin identity as the package entry", () => {
124
136
  assert.equal(plugin.id, "@bybrawe/opencode-loop", "native entry must share canonical plugin identity")
125
137
  })
138
+
139
+ test("native /loop-help reflects public V2 compaction rather than obsolete unsupported guidance", async () => {
140
+ const directory = await mkdtemp(path.join(os.tmpdir(), "loop-v2-help-compaction-"))
141
+ const host = fixture(directory, { captureCommands: true })
142
+ host.ctx.session.compact = async () => ({ id: "unused-compaction" })
143
+ let dispose
144
+ try {
145
+ dispose = await plugin.setup(host.ctx)
146
+ const help = host.commands.get("loop-help")
147
+ assert.equal(typeof help, "function")
148
+ await help({ sessionID: "ses-help" })
149
+ assert.equal(host.prompts.length, 1)
150
+ assert.match(host.prompts[0].text, /public session\.compact/)
151
+ assert.match(host.prompts[0].text, /inputID/)
152
+ assert.doesNotMatch(host.prompts[0].text, /does not expose manual session compaction/)
153
+ } finally {
154
+ host.end()
155
+ await dispose?.().catch(() => {})
156
+ await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
157
+ }
158
+ })
@@ -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.6", "release version is not authorized")
10
+ assert.equal(request?.version, "0.6.7", "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.5")
15
- assert.equal(request.goal?.version, "1.3.47")
14
+ assert.equal(request.predecessor, "0.6.6")
15
+ assert.equal(request.goal?.version, "1.3.48")
16
16
  assert.match(request.goal?.sha ?? "", /^[a-f0-9]{40}$/)
17
17
  return request
18
18
  }
@@ -215,13 +215,75 @@ await test("compaction cadence does not charge a logical run", async (f) => {
215
215
  await f.complete()
216
216
  assert.equal(f.compacts.length, 1)
217
217
  assert.equal((await f.state()).jobs[0].runCount, 1)
218
- await f.event("compaction", "started")
219
- await f.event("compaction", "ended")
218
+ await f.event("compaction", "started", { reason: "auto" })
219
+ await f.event("compaction", "ended", { reason: "auto" })
220
+ await f.event("session", "idle")
221
+ assert.equal(f.prompts.length, 1, "an unrelated automatic compaction cannot settle a Loop request")
222
+ await f.event("compaction", "started", { reason: "manual", inputID: f.compacts[0].id })
223
+ await f.event("compaction", "ended", { reason: "manual" })
220
224
  await f.event("session", "idle")
221
225
  assert.equal(f.prompts.length, 2)
222
226
  assert.equal((await f.state()).jobs[0].runCount, 2)
223
227
  })
224
228
 
229
+ await test("manual compaction needs delivered inbox and completed host boundary", async (f) => {
230
+ await f.control("loop-compact", "0s --now --max-runs 1")
231
+ await f.wake()
232
+ assert.equal(f.compacts.length, 1)
233
+ assert.equal(f.compacts[0].delivery, "queue")
234
+ assert.ok(f.compacts[0].id)
235
+ await f.event("compaction", "started", { reason: "manual", inputID: "a-different-compaction" })
236
+ await f.event("compaction", "ended", { reason: "manual" })
237
+ await f.event("session", "idle")
238
+ assert.ok((await f.state()).jobs[0].v2Run, "a different compaction must not finish the scheduled request")
239
+ await f.event("compaction", "started", { reason: "manual", inputID: f.compacts[0].id })
240
+ await f.event("compaction", "ended", { reason: "manual" })
241
+ await f.event("session", "idle")
242
+ assert.equal((await f.state()).jobs[0].v2Run, undefined)
243
+ assert.equal((await f.state()).jobs[0].runCount, 1)
244
+ })
245
+
246
+ await test("foreign host compaction failure cannot pause or consume a pending Loop compact", async (f) => {
247
+ await f.control("loop-compact", "0s --now --max-runs 1")
248
+ await f.wake()
249
+ const jobID = f.compacts[0].id
250
+ await f.event("compaction", "started", { reason: "manual", inputID: "another-user-input" })
251
+ await f.event("compaction", "failed", {
252
+ reason: "manual", inputID: "another-user-input",
253
+ error: "Not our compaction",
254
+ })
255
+ await f.event("session", "idle")
256
+ const waiting = (await f.state()).jobs[0]
257
+ assert.equal(waiting.paused, false, "foreign compaction failure must not pause our pending operation")
258
+ assert.ok(waiting.v2Run, "foreign event must not erase the pending compact request")
259
+ assert.equal(waiting.runCount, 1, "a confirmed host admission is charged once; foreign failure cannot change its accounting")
260
+
261
+ await f.event("compaction", "started", { reason: "manual", inputID: jobID })
262
+ await f.event("compaction", "failed", {
263
+ reason: "manual", inputID: jobID,
264
+ error: "Summary did not match the template\nwith details",
265
+ })
266
+ const failed = (await f.state()).jobs[0]
267
+ assert.equal(failed.paused, true)
268
+ assert.match(failed.pauseReason, /^compaction-failed: Summary did not match the template/)
269
+ assert.equal(failed.pauseReason.includes("\n"), false, "provider messages cannot inject log newlines")
270
+ assert.equal(failed.v2Run, undefined, "confirmed owned host failure must release pending work")
271
+ assert.equal(failed.runCount, 1, "a failed host compact preserves the one already-admitted logical run")
272
+ await f.event("session", "idle")
273
+ assert.equal((await f.state()).jobs[0].paused, true)
274
+ })
275
+
276
+ await test("unowned automatic host compaction failure leaves unrelated Loop jobs enabled", async (f) => {
277
+ await f.add("every 1h delayed task --max-runs 1")
278
+ await f.event("compaction", "started", { reason: "auto" })
279
+ await f.event("compaction", "failed", { reason: "auto", error: "host model unavailable" })
280
+ await f.event("session", "idle")
281
+ const job = (await f.state()).jobs[0]
282
+ assert.equal(job.paused, false)
283
+ assert.equal(job.runCount, 0)
284
+ assert.equal(job.v2Run, undefined)
285
+ })
286
+
225
287
  await test("a failed delivered execution is paused and can be explicitly resumed", async (f) => {
226
288
  await f.add("0s task --max-runs 3")
227
289
  await f.wake()
@@ -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, { cwd, encoding: "utf8", timeout: 360_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true })
13
- if (result.error) throw result.error
14
- assert.equal(result.status, 0, [result.stdout, result.stderr].filter(Boolean).join("\n"))
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
- run(process.execPath, [npmCLI, "install", "--ignore-scripts", "--omit=dev", "--omit=peer", "--no-audit", "--no-fund", path.join(temp, packed[0].filename)], consumer)
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.6", predecessor: "0.6.5", goal: { version: "1.3.47", sha: "b".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) } }
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
57
  assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.5" }, env))
54
- assert.equal(releaseDecision(404, null, request, sha, "0.6.5"), true)
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
+ }