@bybrawe/opencode-loop 0.5.26 → 0.5.27
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 +10 -0
- package/README.md +9 -3
- package/package.json +4 -3
- package/scripts/comprehensive-test.mjs +31 -0
- package/scripts/host-loop-canary.mjs +343 -0
- package/scripts/loopd-test.mjs +45 -18
- package/scripts/loopd.mjs +92 -22
- package/src/index.js +82 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.27
|
|
4
|
+
|
|
5
|
+
- Rebase stale Loop state snapshots against the latest persisted job state before writing, preventing scheduler bookkeeping from erasing unrelated user pause/resume or other state changes.
|
|
6
|
+
- Add a deterministic concurrency regression that forces overlapping state reads/writes and proves a concurrent user pause survives a stale scheduler update.
|
|
7
|
+
- Harden `opencode-loopd` by pinning one exact OpenCode session across iterations instead of resolving `--continue` on every run; a fresh daemon session is created and then pinned when no existing session is available.
|
|
8
|
+
- Add per-run daemon timeouts (30 minutes by default, configurable with `--timeout`; `0s` disables) and timeout exit code 124, including descendant-process termination.
|
|
9
|
+
- Add daemon regressions for session pinning, timeout termination, and scheduled-task timeout propagation.
|
|
10
|
+
- Add a real OpenCode host canary that proves a three-run `/loop` performs exactly three autonomous turns on Ubuntu and Windows.
|
|
11
|
+
- Narrow the OpenCode plugin peer range to `>=1.4.0 <2` so a future breaking 2.x release is not treated as automatically compatible.
|
|
12
|
+
|
|
3
13
|
## 0.5.26
|
|
4
14
|
|
|
5
15
|
- Added an explicit npm-based OpenCode installation path: `npm install -g @bybrawe/opencode-loop@latest` followed by `opencode-loop`.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, safe long-running continuation helpers, and the `opencode-loopd` background daemon.
|
|
6
6
|
|
|
7
|
-
> **Current release: `0.5.
|
|
7
|
+
> **Current release: `0.5.27`.** Loop also contains an older experimental `/loop-goal` mode, but for strong persistent Goal contracts and host-verified completion, use the separate **OpenCode Goals** plugin described below.
|
|
8
8
|
|
|
9
9
|
## Install or update
|
|
10
10
|
|
|
@@ -304,7 +304,7 @@ Do not use a normal prompt loop when you mean to run `/compact`. Prefer:
|
|
|
304
304
|
|
|
305
305
|
## Background daemon
|
|
306
306
|
|
|
307
|
-
The normal `/loop` plugin is session-bound. If OpenCode closes, that TUI/session loop cannot keep running in the background.
|
|
307
|
+
The normal `/loop` plugin is session-bound. If OpenCode closes, that TUI/session loop cannot keep running in the background. `opencode-loopd` resolves the session once at startup and pins that exact session for later iterations, so a newer unrelated session cannot steal the daemon. If no session exists, the daemon creates one and pins it before the second iteration. Each daemon run is bounded by `--timeout` (30 minutes by default; use `--timeout 0s` to disable).
|
|
308
308
|
|
|
309
309
|
For long-running background jobs use:
|
|
310
310
|
|
|
@@ -321,7 +321,13 @@ opencode-loopd --project . --every 0s --prompt "continue from progress.md and im
|
|
|
321
321
|
Select a model and agent:
|
|
322
322
|
|
|
323
323
|
```bash
|
|
324
|
-
opencode-loopd --project . --every 0s --max-runs 1 --model provider/model --agent build --prompt-file loop-prompt.md
|
|
324
|
+
opencode-loopd --project . --every 0s --max-runs 1 --timeout 30m --model provider/model --agent build --prompt-file loop-prompt.md
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Pin a specific existing session when needed:
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
opencode-loopd --project . --session ses_xxx --every 5m --prompt-file loop-prompt.md
|
|
325
331
|
```
|
|
326
332
|
|
|
327
333
|
Limit total runs:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.27",
|
|
4
4
|
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
"CHANGELOG.md"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"check": "node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/comprehensive-test.mjs",
|
|
21
|
+
"check": "node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs",
|
|
22
22
|
"test": "node scripts/install-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/comprehensive-test.mjs",
|
|
23
|
+
"canary:host": "node scripts/host-loop-canary.mjs",
|
|
23
24
|
"install:global": "node scripts/install-node.mjs",
|
|
24
25
|
"pack:zip": "node scripts/make-zip.mjs"
|
|
25
26
|
},
|
|
@@ -54,7 +55,7 @@
|
|
|
54
55
|
"url": "https://github.com/ByBrawe/opencode-loop/issues"
|
|
55
56
|
},
|
|
56
57
|
"peerDependencies": {
|
|
57
|
-
"@opencode-ai/plugin": ">=1.4.0"
|
|
58
|
+
"@opencode-ai/plugin": ">=1.4.0 <2"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@opencode-ai/plugin": "^1.18.15"
|
|
@@ -705,6 +705,36 @@ async function testStateReadRetriesTransientPartialJson() {
|
|
|
705
705
|
}
|
|
706
706
|
}
|
|
707
707
|
|
|
708
|
+
async function testStateRebasePreservesConcurrentPause() {
|
|
709
|
+
const h = await createHarness()
|
|
710
|
+
const originalRename = fs.rename
|
|
711
|
+
try {
|
|
712
|
+
await h.command("loop", "10m --no-now --name race continue safely")
|
|
713
|
+
h.statuses.set(h.sessionID, "busy")
|
|
714
|
+
let delayed = false
|
|
715
|
+
fs.rename = async (source, target, ...rest) => {
|
|
716
|
+
if (!delayed && path.resolve(target) === path.resolve(h.stateFile)) {
|
|
717
|
+
delayed = true
|
|
718
|
+
await delay(120)
|
|
719
|
+
}
|
|
720
|
+
return await originalRename.call(fs, source, target, ...rest)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const pause = h.command("loop-pause", "race")
|
|
724
|
+
await delay(20)
|
|
725
|
+
const runNow = h.command("loop-now", "race")
|
|
726
|
+
await Promise.all([pause, runNow])
|
|
727
|
+
|
|
728
|
+
const state = await h.readState()
|
|
729
|
+
const job = state.jobs.find((item) => item.name === "race")
|
|
730
|
+
assert.ok(job, "the concurrent state update must keep the loop job")
|
|
731
|
+
assert.equal(job.paused, true, "a stale scheduler snapshot must not erase a concurrent user pause")
|
|
732
|
+
} finally {
|
|
733
|
+
fs.rename = originalRename
|
|
734
|
+
await h.cleanup()
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
708
738
|
await testParserAndPresets()
|
|
709
739
|
await testLifecycleAndCommandDedupe()
|
|
710
740
|
await testWatchScheduling()
|
|
@@ -718,5 +748,6 @@ await testInitializationDoesNotWaitForLocalApi()
|
|
|
718
748
|
await testWindowsSafeStatePersistence()
|
|
719
749
|
await testWindowsStateRenameRetriesBeforeFallback()
|
|
720
750
|
await testStateReadRetriesTransientPartialJson()
|
|
751
|
+
await testStateRebasePreservesConcurrentPause()
|
|
721
752
|
|
|
722
753
|
console.log("OpenCode Loop comprehensive test passed")
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { existsSync } from "node:fs"
|
|
3
|
+
import { createServer } from "node:http"
|
|
4
|
+
import net from "node:net"
|
|
5
|
+
import { spawn } from "node:child_process"
|
|
6
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
|
7
|
+
import os from "node:os"
|
|
8
|
+
import path from "node:path"
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
10
|
+
|
|
11
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
12
|
+
const isWindows = process.platform === "win32"
|
|
13
|
+
const LOOP_OBJECTIVE = "real host loop canary"
|
|
14
|
+
|
|
15
|
+
function resolveOpenCodeBinary() {
|
|
16
|
+
if (!isWindows) return path.join(repoRoot, "node_modules", ".bin", "opencode")
|
|
17
|
+
const candidates = [
|
|
18
|
+
path.join(repoRoot, "node_modules", "opencode-windows-x64", "bin", "opencode.exe"),
|
|
19
|
+
path.join(repoRoot, "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe"),
|
|
20
|
+
path.join(repoRoot, "node_modules", "opencode-windows-arm64", "bin", "opencode.exe"),
|
|
21
|
+
]
|
|
22
|
+
const found = candidates.find((candidate) => existsSync(candidate))
|
|
23
|
+
if (!found) throw new Error(`OpenCode native Windows binary was not installed. Checked: ${candidates.join(", ")}`)
|
|
24
|
+
return found
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const opencodeBin = resolveOpenCodeBinary()
|
|
28
|
+
|
|
29
|
+
function appendLog(current, chunk, limit = 80_000) {
|
|
30
|
+
return (current + String(chunk)).slice(-limit)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function seedConfigDependencies(dir) {
|
|
34
|
+
await mkdir(path.join(dir, "node_modules"), { recursive: true })
|
|
35
|
+
const dependencies = { "@opencode-ai/plugin": "*" }
|
|
36
|
+
await writeFile(path.join(dir, "package.json"), `${JSON.stringify({ private: true, dependencies }, null, 2)}\n`)
|
|
37
|
+
await writeFile(
|
|
38
|
+
path.join(dir, "package-lock.json"),
|
|
39
|
+
`${JSON.stringify({
|
|
40
|
+
name: "opencode-loop-host-canary-config",
|
|
41
|
+
lockfileVersion: 3,
|
|
42
|
+
requires: true,
|
|
43
|
+
packages: { "": { dependencies } },
|
|
44
|
+
}, null, 2)}\n`,
|
|
45
|
+
)
|
|
46
|
+
await writeFile(path.join(dir, ".gitignore"), "node_modules\npackage.json\npackage-lock.json\nbun.lock\n.gitignore\n")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function reservePort() {
|
|
50
|
+
return await new Promise((resolve, reject) => {
|
|
51
|
+
const server = net.createServer()
|
|
52
|
+
server.once("error", reject)
|
|
53
|
+
server.listen(0, "127.0.0.1", () => {
|
|
54
|
+
const address = server.address()
|
|
55
|
+
if (!address || typeof address === "string") return reject(new Error("failed to reserve TCP port"))
|
|
56
|
+
server.close((error) => error ? reject(error) : resolve(address.port))
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function waitForTcp(port, child, logs, timeoutMs = 30_000) {
|
|
62
|
+
const deadline = Date.now() + timeoutMs
|
|
63
|
+
while (Date.now() < deadline) {
|
|
64
|
+
if (child.exitCode !== null) throw new Error(`OpenCode server exited before ready.\n${logs()}`)
|
|
65
|
+
const connected = await new Promise((resolve) => {
|
|
66
|
+
const socket = net.createConnection({ host: "127.0.0.1", port })
|
|
67
|
+
socket.once("connect", () => { socket.destroy(); resolve(true) })
|
|
68
|
+
socket.once("error", () => resolve(false))
|
|
69
|
+
socket.setTimeout(500, () => { socket.destroy(); resolve(false) })
|
|
70
|
+
})
|
|
71
|
+
if (connected) return
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`timed out waiting for OpenCode server on ${port}\n${logs()}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function stopProcess(child, timeoutMs = 2_000) {
|
|
78
|
+
if (!child || child.exitCode !== null) return
|
|
79
|
+
child.kill()
|
|
80
|
+
await new Promise((resolve) => {
|
|
81
|
+
if (child.exitCode !== null) return resolve()
|
|
82
|
+
const timer = setTimeout(resolve, timeoutMs)
|
|
83
|
+
child.once("close", () => { clearTimeout(timer); resolve() })
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function spawnOpenCode(args, options = {}) {
|
|
88
|
+
return spawn(opencodeBin, args, { ...options, windowsHide: true })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function runOpenCode(args, { cwd, env, timeoutMs = 60_000 }) {
|
|
92
|
+
return await new Promise((resolve, reject) => {
|
|
93
|
+
const child = spawnOpenCode(args, { cwd, env })
|
|
94
|
+
let stdout = ""
|
|
95
|
+
let stderr = ""
|
|
96
|
+
let settled = false
|
|
97
|
+
child.stdout?.on("data", (chunk) => { stdout = appendLog(stdout, chunk) })
|
|
98
|
+
child.stderr?.on("data", (chunk) => { stderr = appendLog(stderr, chunk) })
|
|
99
|
+
const finish = (fn, value) => {
|
|
100
|
+
if (settled) return
|
|
101
|
+
settled = true
|
|
102
|
+
clearTimeout(timer)
|
|
103
|
+
fn(value)
|
|
104
|
+
}
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
void stopProcess(child)
|
|
107
|
+
finish(reject, new Error(`OpenCode command timed out: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
108
|
+
}, timeoutMs)
|
|
109
|
+
child.once("error", (error) => finish(reject, error))
|
|
110
|
+
child.once("close", (code) => {
|
|
111
|
+
if (code !== 0) return finish(reject, new Error(`OpenCode command exited ${code}: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
112
|
+
finish(resolve, { stdout, stderr })
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function contentText(content) {
|
|
118
|
+
if (typeof content === "string") return content
|
|
119
|
+
if (!Array.isArray(content)) return ""
|
|
120
|
+
return content.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").join("\n")
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function allMessageText(body) {
|
|
124
|
+
return (body.messages ?? []).map((message) => contentText(message?.content)).join("\n")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function streamHeaders(res) {
|
|
128
|
+
res.writeHead(200, {
|
|
129
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
130
|
+
"cache-control": "no-cache",
|
|
131
|
+
connection: "keep-alive",
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function writeSse(res, value) {
|
|
136
|
+
res.write(`data: ${JSON.stringify(value)}\n\n`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function streamText(res, content, sequence) {
|
|
140
|
+
const id = `chatcmpl-loop-${sequence}`
|
|
141
|
+
const created = Math.floor(Date.now() / 1000)
|
|
142
|
+
streamHeaders(res)
|
|
143
|
+
writeSse(res, {
|
|
144
|
+
id,
|
|
145
|
+
object: "chat.completion.chunk",
|
|
146
|
+
created,
|
|
147
|
+
model: "canary",
|
|
148
|
+
choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }],
|
|
149
|
+
})
|
|
150
|
+
writeSse(res, {
|
|
151
|
+
id,
|
|
152
|
+
object: "chat.completion.chunk",
|
|
153
|
+
created,
|
|
154
|
+
model: "canary",
|
|
155
|
+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
156
|
+
usage: { prompt_tokens: 30, completion_tokens: 4, total_tokens: 34 },
|
|
157
|
+
})
|
|
158
|
+
res.end("data: [DONE]\n\n")
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function startProvider() {
|
|
162
|
+
const stats = { chatRequests: 0, loopRequests: 0, paths: [] }
|
|
163
|
+
const server = createServer(async (req, res) => {
|
|
164
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1")
|
|
165
|
+
stats.paths.push(`${req.method} ${url.pathname}`)
|
|
166
|
+
if (req.method === "GET" && url.pathname.endsWith("/models")) {
|
|
167
|
+
res.writeHead(200, { "content-type": "application/json" })
|
|
168
|
+
res.end(JSON.stringify({ object: "list", data: [{ id: "canary", object: "model", owned_by: "canary" }] }))
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
if (req.method !== "POST" || !url.pathname.endsWith("/chat/completions")) {
|
|
172
|
+
res.writeHead(404, { "content-type": "application/json" })
|
|
173
|
+
res.end(JSON.stringify({ error: { message: `unexpected endpoint: ${req.method} ${url.pathname}` } }))
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let raw = ""
|
|
178
|
+
for await (const chunk of req) raw += String(chunk)
|
|
179
|
+
const body = raw ? JSON.parse(raw) : {}
|
|
180
|
+
stats.chatRequests += 1
|
|
181
|
+
const text = allMessageText(body)
|
|
182
|
+
if (text.includes("AUTONOMOUS OPENCODE LOOP ITERATION") && text.includes(LOOP_OBJECTIVE)) {
|
|
183
|
+
stats.loopRequests += 1
|
|
184
|
+
streamText(res, `LOOP_TURN_${stats.loopRequests}`, stats.chatRequests)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
streamText(res, "OK", stats.chatRequests)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
stats,
|
|
192
|
+
async listen() {
|
|
193
|
+
await new Promise((resolve, reject) => {
|
|
194
|
+
server.once("error", reject)
|
|
195
|
+
server.listen(0, "127.0.0.1", resolve)
|
|
196
|
+
})
|
|
197
|
+
const address = server.address()
|
|
198
|
+
if (!address || typeof address === "string") throw new Error("failed to start deterministic loop provider")
|
|
199
|
+
return address.port
|
|
200
|
+
},
|
|
201
|
+
async close() {
|
|
202
|
+
await new Promise((resolve) => server.close(() => resolve()))
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function waitFor(predicate, description, diagnostics, timeoutMs = 45_000) {
|
|
208
|
+
const deadline = Date.now() + timeoutMs
|
|
209
|
+
while (Date.now() < deadline) {
|
|
210
|
+
if (await predicate()) return
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
212
|
+
}
|
|
213
|
+
throw new Error(`timed out waiting for ${description}\n${diagnostics()}`)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function main() {
|
|
217
|
+
const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-host-canary-"))
|
|
218
|
+
const home = path.join(workspace, ".home")
|
|
219
|
+
const projectConfig = path.join(workspace, ".opencode")
|
|
220
|
+
const globalConfig = path.join(home, ".config", "opencode")
|
|
221
|
+
const pluginDir = path.join(projectConfig, "plugins")
|
|
222
|
+
const commandDir = path.join(projectConfig, "commands")
|
|
223
|
+
const agentDir = path.join(projectConfig, "agents")
|
|
224
|
+
const provider = startProvider()
|
|
225
|
+
const providerPort = await provider.listen()
|
|
226
|
+
let server
|
|
227
|
+
let serverLog = ""
|
|
228
|
+
let commandError = null
|
|
229
|
+
|
|
230
|
+
await mkdir(pluginDir, { recursive: true })
|
|
231
|
+
await mkdir(commandDir, { recursive: true })
|
|
232
|
+
await mkdir(agentDir, { recursive: true })
|
|
233
|
+
await seedConfigDependencies(projectConfig)
|
|
234
|
+
await seedConfigDependencies(globalConfig)
|
|
235
|
+
const pluginEntry = pathToFileURL(path.join(repoRoot, "src", "index.js")).href
|
|
236
|
+
await writeFile(path.join(pluginDir, "opencode-loop.js"), `export { default as OpenCodeLoopPlugin } from ${JSON.stringify(pluginEntry)}\n`)
|
|
237
|
+
await writeFile(path.join(commandDir, "loop.md"), `---\ndescription: Start a host canary loop\nagent: opencode-loop-local\n---\n\nOpenCode Loop local command handled. Reply exactly: OK.\n`)
|
|
238
|
+
await writeFile(path.join(agentDir, "opencode-loop-local.md"), `---\ndescription: Local Loop command acknowledgement\nmode: primary\npermission:\n "*": deny\n---\n\nReply exactly: OK\n`)
|
|
239
|
+
await writeFile(path.join(workspace, "opencode.json"), `${JSON.stringify({
|
|
240
|
+
$schema: "https://opencode.ai/config.json",
|
|
241
|
+
model: "canary/canary",
|
|
242
|
+
small_model: "canary/canary",
|
|
243
|
+
provider: {
|
|
244
|
+
canary: {
|
|
245
|
+
npm: "@ai-sdk/openai-compatible",
|
|
246
|
+
name: "Deterministic Loop Canary",
|
|
247
|
+
options: { baseURL: `http://127.0.0.1:${providerPort}/v1`, apiKey: "canary-key" },
|
|
248
|
+
models: { canary: { name: "Deterministic Loop Canary", limit: { context: 100000, output: 4096 } } },
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
}, null, 2)}\n`)
|
|
252
|
+
|
|
253
|
+
const env = {
|
|
254
|
+
...process.env,
|
|
255
|
+
HOME: home,
|
|
256
|
+
USERPROFILE: home,
|
|
257
|
+
XDG_CONFIG_HOME: path.join(home, ".config"),
|
|
258
|
+
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
|
259
|
+
XDG_CACHE_HOME: path.join(home, ".cache"),
|
|
260
|
+
OPENCODE_DISABLE_AUTOUPDATE: "true",
|
|
261
|
+
OPENCODE_DB: ":memory:",
|
|
262
|
+
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
|
263
|
+
CI: "true",
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await runOpenCode(["debug", "config"], { cwd: workspace, env, timeoutMs: 60_000 })
|
|
268
|
+
const port = await reservePort()
|
|
269
|
+
server = spawnOpenCode(["serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: workspace, env })
|
|
270
|
+
server.stdout?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
|
|
271
|
+
server.stderr?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
|
|
272
|
+
await waitForTcp(port, server, () => serverLog)
|
|
273
|
+
|
|
274
|
+
const baseURL = `http://127.0.0.1:${port}`
|
|
275
|
+
const directoryQuery = `directory=${encodeURIComponent(workspace)}`
|
|
276
|
+
const api = async (pathname, init = {}) => {
|
|
277
|
+
const separator = pathname.includes("?") ? "&" : "?"
|
|
278
|
+
const response = await fetch(`${baseURL}${pathname}${separator}${directoryQuery}`, {
|
|
279
|
+
...init,
|
|
280
|
+
headers: { "content-type": "application/json", ...(init.headers ?? {}) },
|
|
281
|
+
signal: init.signal ?? AbortSignal.timeout(30_000),
|
|
282
|
+
})
|
|
283
|
+
const text = await response.text()
|
|
284
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)
|
|
285
|
+
if (!text) return null
|
|
286
|
+
try { return JSON.parse(text) } catch { return text }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const sessionsBefore = await api("/session", { method: "GET", signal: AbortSignal.timeout(45_000) })
|
|
290
|
+
assert.ok(Array.isArray(sessionsBefore?.data ?? sessionsBefore), "GET /session bootstrap did not return an array")
|
|
291
|
+
const createdPayload = await api("/session", { method: "POST", body: JSON.stringify({ title: "opencode-loop host canary" }) })
|
|
292
|
+
const session = createdPayload?.data ?? createdPayload
|
|
293
|
+
const sessionID = String(session?.id ?? "")
|
|
294
|
+
assert.ok(sessionID, `OpenCode did not create a session: ${JSON.stringify(createdPayload)}`)
|
|
295
|
+
|
|
296
|
+
const command = api(`/session/${encodeURIComponent(sessionID)}/command`, {
|
|
297
|
+
method: "POST",
|
|
298
|
+
body: JSON.stringify({ agent: "build", model: "canary/canary", command: "loop", arguments: `0s --max-runs 3 ${LOOP_OBJECTIVE}` }),
|
|
299
|
+
signal: AbortSignal.timeout(90_000),
|
|
300
|
+
}).catch((error) => {
|
|
301
|
+
commandError = error
|
|
302
|
+
return null
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const stateFile = path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`)
|
|
306
|
+
const diagnostics = async () => {
|
|
307
|
+
let state = "missing"
|
|
308
|
+
try { state = await readFile(stateFile, "utf8") } catch {}
|
|
309
|
+
return `provider=${JSON.stringify(provider.stats)}\ncommandError=${String(commandError ?? "none")}\nstate=${state}\nserver log:\n${serverLog}`
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await waitFor(() => provider.stats.loopRequests >= 3, "three real autonomous Loop turns", () => `provider=${JSON.stringify(provider.stats)}\nserver log:\n${serverLog}`)
|
|
313
|
+
await new Promise((resolve) => setTimeout(resolve, 1_500))
|
|
314
|
+
assert.equal(provider.stats.loopRequests, 3, `Loop must stop at --max-runs 3; got extra real-host turn(s)\n${await diagnostics()}`)
|
|
315
|
+
assert.equal(server.exitCode, null, `OpenCode server exited during canary\n${await diagnostics()}`)
|
|
316
|
+
|
|
317
|
+
let persisted = null
|
|
318
|
+
try { persisted = JSON.parse(await readFile(stateFile, "utf8")) } catch {}
|
|
319
|
+
if (persisted?.jobs?.length) {
|
|
320
|
+
const loop = persisted.jobs.find((item) => item.name === "default" || item.action === LOOP_OBJECTIVE)
|
|
321
|
+
if (loop) assert.ok((loop.runCount || 0) >= 3, `persisted Loop run count was lower than provider turn count: ${JSON.stringify(loop)}`)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
console.log(JSON.stringify({
|
|
325
|
+
ok: true,
|
|
326
|
+
platform: process.platform,
|
|
327
|
+
sessionID,
|
|
328
|
+
loopRequests: provider.stats.loopRequests,
|
|
329
|
+
chatRequests: provider.stats.chatRequests,
|
|
330
|
+
commandError: commandError ? String(commandError) : null,
|
|
331
|
+
}, null, 2))
|
|
332
|
+
void command
|
|
333
|
+
} finally {
|
|
334
|
+
await stopProcess(server)
|
|
335
|
+
await provider.close().catch(() => undefined)
|
|
336
|
+
await rm(workspace, { recursive: true, force: true }).catch(() => undefined)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
main().catch((error) => {
|
|
341
|
+
console.error(error?.stack || error)
|
|
342
|
+
process.exitCode = 1
|
|
343
|
+
})
|
package/scripts/loopd-test.mjs
CHANGED
|
@@ -32,16 +32,7 @@ function runCli(cliArgs, env = {}) {
|
|
|
32
32
|
async function makeFakeCommand(name) {
|
|
33
33
|
const script = path.join(temporaryRoot, `${name}.mjs`)
|
|
34
34
|
const log = path.join(temporaryRoot, `${name}.jsonl`)
|
|
35
|
-
await fs.writeFile(script,
|
|
36
|
-
import fs from "node:fs"
|
|
37
|
-
const log = process.env.FAKE_COMMAND_LOG
|
|
38
|
-
let previous = []
|
|
39
|
-
try { previous = fs.readFileSync(log, "utf8").trim().split(/\\r?\\n/).filter(Boolean) } catch {}
|
|
40
|
-
fs.appendFileSync(log, JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd() }) + "\\n")
|
|
41
|
-
const codes = String(process.env.FAKE_EXIT_CODES || "0").split(",").map((value) => Number(value.trim()))
|
|
42
|
-
const code = codes[Math.min(previous.length, codes.length - 1)]
|
|
43
|
-
process.exit(Number.isInteger(code) ? code : 0)
|
|
44
|
-
`, "utf8")
|
|
35
|
+
await fs.writeFile(script, "\nimport fs from \"node:fs\"\nconst log = process.env.FAKE_COMMAND_LOG\nconst argv = process.argv.slice(2)\nlet previous = []\ntry { previous = fs.readFileSync(log, \"utf8\").trim().split(/\\r?\\n/).filter(Boolean).map((line) => JSON.parse(line)) } catch {}\nfs.appendFileSync(log, JSON.stringify({ args: argv, cwd: process.cwd() }) + \"\\n\")\nif (argv[0] === \"session\" && argv[1] === \"list\") {\n console.log(process.env.FAKE_SESSION_LIST_JSON || \"[]\")\n process.exit(0)\n}\nconst sleepMs = Number(process.env.FAKE_SLEEP_MS || 0)\nif (sleepMs > 0) await new Promise((resolve) => setTimeout(resolve, sleepMs))\nconst previousRuns = previous.filter((entry) => entry?.args?.[0] === \"run\").length\nconst codes = String(process.env.FAKE_EXIT_CODES || \"0\").split(\",\").map((value) => Number(value.trim()))\nconst code = codes[Math.min(previousRuns, codes.length - 1)]\nprocess.exit(Number.isInteger(code) ? code : 0)\n", "utf8")
|
|
45
36
|
|
|
46
37
|
if (process.platform === "win32") {
|
|
47
38
|
const command = path.join(temporaryRoot, `${name}.cmd`)
|
|
@@ -83,14 +74,16 @@ try {
|
|
|
83
74
|
})
|
|
84
75
|
assert.equal(result.code, 0, result.stderr)
|
|
85
76
|
let calls = await readLog(fakeOpenCode.log)
|
|
86
|
-
|
|
87
|
-
assert.equal(
|
|
88
|
-
assert.
|
|
89
|
-
|
|
77
|
+
let runCalls = calls.filter((item) => item.args[0] === "run")
|
|
78
|
+
assert.equal(runCalls.length, 1)
|
|
79
|
+
assert.equal(path.resolve(runCalls[0].cwd), path.resolve(project))
|
|
80
|
+
assert.deepEqual(runCalls[0].args, [
|
|
81
|
+
"run", "--title", runCalls[0].args[2],
|
|
90
82
|
"--model", "opencode/nemotron-3-ultra-free",
|
|
91
83
|
"--agent", "build",
|
|
92
84
|
inlinePrompt,
|
|
93
85
|
], "loopd must preserve model, agent, quotes, and shell metacharacters as literal arguments")
|
|
86
|
+
assert.match(runCalls[0].args[2], /^OpenCode Loop daemon /)
|
|
94
87
|
|
|
95
88
|
const promptFile = path.join(project, "goal prompt.md")
|
|
96
89
|
await fs.writeFile(promptFile, "\uFEFFPrompt loaded from a BOM file.", "utf8")
|
|
@@ -105,7 +98,39 @@ try {
|
|
|
105
98
|
})
|
|
106
99
|
assert.equal(result.code, 0, result.stderr)
|
|
107
100
|
calls = await readLog(fakeOpenCode.log)
|
|
108
|
-
|
|
101
|
+
runCalls = calls.filter((item) => item.args[0] === "run")
|
|
102
|
+
assert.equal(runCalls.at(-1).args.at(-1), "Prompt loaded from a BOM file.")
|
|
103
|
+
|
|
104
|
+
const pinnedLog = path.join(temporaryRoot, "pinned.jsonl")
|
|
105
|
+
result = await runCli([
|
|
106
|
+
"--project", project,
|
|
107
|
+
"--every", "0s",
|
|
108
|
+
"--max-runs", "2",
|
|
109
|
+
"--prompt", "pinned session work",
|
|
110
|
+
], {
|
|
111
|
+
OPENCODE_BIN: fakeOpenCode.command,
|
|
112
|
+
FAKE_COMMAND_LOG: pinnedLog,
|
|
113
|
+
FAKE_SESSION_LIST_JSON: JSON.stringify([{ id: "ses_pinned", title: "Pinned" }]),
|
|
114
|
+
})
|
|
115
|
+
assert.equal(result.code, 0, result.stderr)
|
|
116
|
+
const pinnedRuns = (await readLog(pinnedLog)).filter((item) => item.args[0] === "run")
|
|
117
|
+
assert.equal(pinnedRuns.length, 2)
|
|
118
|
+
assert.ok(pinnedRuns.every((item) => item.args[1] === "--session" && item.args[2] === "ses_pinned"), "every daemon iteration must stay pinned to the same session")
|
|
119
|
+
|
|
120
|
+
const timeoutLog = path.join(temporaryRoot, "timeout.jsonl")
|
|
121
|
+
result = await runCli([
|
|
122
|
+
"--project", project,
|
|
123
|
+
"--every", "0s",
|
|
124
|
+
"--max-runs", "1",
|
|
125
|
+
"--timeout", "50ms",
|
|
126
|
+
"--prompt", "hang",
|
|
127
|
+
], {
|
|
128
|
+
OPENCODE_BIN: fakeOpenCode.command,
|
|
129
|
+
FAKE_COMMAND_LOG: timeoutLog,
|
|
130
|
+
FAKE_SLEEP_MS: "5000",
|
|
131
|
+
})
|
|
132
|
+
assert.equal(result.code, 124, "a timed-out daemon run must return exit code 124")
|
|
133
|
+
assert.match(result.stdout, /timed out after 50ms/)
|
|
109
134
|
|
|
110
135
|
const failureLog = path.join(temporaryRoot, "failure.jsonl")
|
|
111
136
|
result = await runCli([
|
|
@@ -169,6 +194,7 @@ try {
|
|
|
169
194
|
assert.equal(taskConfig.prompt, "scheduled task prompt")
|
|
170
195
|
assert.equal(taskConfig.model, "opencode/nemotron-3-ultra-free")
|
|
171
196
|
assert.equal(taskConfig.agent, "build")
|
|
197
|
+
assert.equal(taskConfig.timeout, "30m")
|
|
172
198
|
assert.equal(taskConfig.opencodeBin, fakeOpenCode.command)
|
|
173
199
|
|
|
174
200
|
const taskRunLog = path.join(temporaryRoot, "task-run.jsonl")
|
|
@@ -176,9 +202,10 @@ try {
|
|
|
176
202
|
FAKE_COMMAND_LOG: taskRunLog,
|
|
177
203
|
})
|
|
178
204
|
assert.equal(result.code, 0, result.stderr)
|
|
179
|
-
const
|
|
180
|
-
assert.
|
|
181
|
-
|
|
205
|
+
const taskRunCalls = (await readLog(taskRunLog)).filter((item) => item.args[0] === "run")
|
|
206
|
+
assert.equal(taskRunCalls.length, 1)
|
|
207
|
+
assert.deepEqual(taskRunCalls[0].args, [
|
|
208
|
+
"run", "--title", taskRunCalls[0].args[2],
|
|
182
209
|
"--model", "opencode/nemotron-3-ultra-free",
|
|
183
210
|
"--agent", "build",
|
|
184
211
|
"scheduled task prompt",
|
package/scripts/loopd.mjs
CHANGED
|
@@ -61,13 +61,46 @@ function sleep(ms) {
|
|
|
61
61
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
function
|
|
64
|
+
function terminateChild(child) {
|
|
65
|
+
if (!child || child.exitCode !== null) return
|
|
66
|
+
try {
|
|
67
|
+
if (process.platform === "win32" && child.pid) {
|
|
68
|
+
spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true })
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (child.pid) process.kill(-child.pid, "SIGTERM")
|
|
72
|
+
else child.kill("SIGTERM")
|
|
73
|
+
const force = setTimeout(() => {
|
|
74
|
+
try {
|
|
75
|
+
if (child.exitCode !== null) return
|
|
76
|
+
if (child.pid) process.kill(-child.pid, "SIGKILL")
|
|
77
|
+
else child.kill("SIGKILL")
|
|
78
|
+
} catch {}
|
|
79
|
+
}, 1_000)
|
|
80
|
+
force.unref?.()
|
|
81
|
+
} catch {
|
|
82
|
+
try { child.kill("SIGTERM") } catch {}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function spawnOnce(command, commandArgs, cwd, options = {}) {
|
|
65
87
|
return new Promise((resolve) => {
|
|
66
88
|
let settled = false
|
|
89
|
+
let timedOut = false
|
|
90
|
+
const capture = options.capture === true
|
|
91
|
+
const stdout = []
|
|
92
|
+
const stderr = []
|
|
93
|
+
let timer
|
|
67
94
|
const done = (result) => {
|
|
68
95
|
if (settled) return
|
|
69
96
|
settled = true
|
|
70
|
-
|
|
97
|
+
if (timer) clearTimeout(timer)
|
|
98
|
+
resolve({
|
|
99
|
+
...result,
|
|
100
|
+
timedOut,
|
|
101
|
+
stdout: capture ? Buffer.concat(stdout).toString("utf8") : "",
|
|
102
|
+
stderr: capture ? Buffer.concat(stderr).toString("utf8") : "",
|
|
103
|
+
})
|
|
71
104
|
}
|
|
72
105
|
|
|
73
106
|
let child
|
|
@@ -75,17 +108,27 @@ function spawnOnce(command, commandArgs, cwd) {
|
|
|
75
108
|
child = spawn(command, commandArgs, {
|
|
76
109
|
cwd,
|
|
77
110
|
shell: false,
|
|
78
|
-
stdio: "inherit",
|
|
111
|
+
stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
79
112
|
env: process.env,
|
|
80
113
|
windowsHide: true,
|
|
114
|
+
detached: process.platform !== "win32",
|
|
81
115
|
})
|
|
82
116
|
} catch (error) {
|
|
83
117
|
done({ code: -1, error })
|
|
84
118
|
return
|
|
85
119
|
}
|
|
86
120
|
|
|
121
|
+
child.stdout?.on("data", (chunk) => stdout.push(Buffer.from(chunk)))
|
|
122
|
+
child.stderr?.on("data", (chunk) => stderr.push(Buffer.from(chunk)))
|
|
123
|
+
if (Number(options.timeoutMs) > 0) {
|
|
124
|
+
timer = setTimeout(() => {
|
|
125
|
+
timedOut = true
|
|
126
|
+
terminateChild(child)
|
|
127
|
+
}, Number(options.timeoutMs))
|
|
128
|
+
timer.unref?.()
|
|
129
|
+
}
|
|
87
130
|
child.on("error", (error) => done({ code: -1, error }))
|
|
88
|
-
child.on("exit", (code, signal) => done({ code: code ?? (signal ? 1 : 0), signal }))
|
|
131
|
+
child.on("exit", (code, signal) => done({ code: timedOut ? 124 : (code ?? (signal ? 1 : 0)), signal }))
|
|
89
132
|
})
|
|
90
133
|
}
|
|
91
134
|
|
|
@@ -98,15 +141,25 @@ function stripBom(text) {
|
|
|
98
141
|
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
|
99
142
|
}
|
|
100
143
|
|
|
101
|
-
async function run(command, commandArgs, cwd) {
|
|
102
|
-
const direct = await spawnOnce(command, commandArgs, cwd)
|
|
103
|
-
if (direct.code !== -1 || process.platform !== "win32" || !["ENOENT", "EINVAL"].includes(direct.error?.code)) return direct
|
|
144
|
+
async function run(command, commandArgs, cwd, options = {}) {
|
|
145
|
+
const direct = await spawnOnce(command, commandArgs, cwd, options)
|
|
146
|
+
if (direct.code !== -1 || process.platform !== "win32" || !["ENOENT", "EINVAL"].includes(direct.error?.code)) return direct
|
|
104
147
|
|
|
105
|
-
const fallback = await spawnOnce("cmd.exe", ["/d", "/s", "/c", command, ...commandArgs], cwd)
|
|
148
|
+
const fallback = await spawnOnce("cmd.exe", ["/d", "/s", "/c", command, ...commandArgs], cwd, options)
|
|
106
149
|
if (fallback.code === -1) {
|
|
107
150
|
console.error(`[opencode-loopd] failed to start ${command}: ${fallback.error?.message || direct.error?.message || "unknown error"}`)
|
|
108
151
|
}
|
|
109
|
-
return fallback
|
|
152
|
+
return fallback
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function resolveLatestSessionID(opencodeBin, project, preferredTitle) {
|
|
156
|
+
const result = await run(opencodeBin, ["session", "list", "--format", "json", "-n", "20"], project, { capture: true, timeoutMs: 15_000 })
|
|
157
|
+
if (result.code !== 0 || result.timedOut) return undefined
|
|
158
|
+
let parsed
|
|
159
|
+
try { parsed = JSON.parse(result.stdout || "[]") } catch { return undefined }
|
|
160
|
+
const sessions = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.data) ? parsed.data : []
|
|
161
|
+
const match = preferredTitle ? sessions.find((item) => item?.title === preferredTitle) : sessions[0]
|
|
162
|
+
return typeof match?.id === "string" && match.id ? match.id : undefined
|
|
110
163
|
}
|
|
111
164
|
|
|
112
165
|
function runSync(command, commandArgs) {
|
|
@@ -145,50 +198,63 @@ async function daemon(options = {}) {
|
|
|
145
198
|
const every = options.every ?? arg("--every", "0s")
|
|
146
199
|
const delay = parseMs(every)
|
|
147
200
|
const maxRuns = parseMaxRuns(options.maxRuns ?? arg("--max-runs", "0"))
|
|
201
|
+
const timeoutText = options.timeout ?? arg("--timeout", "30m")
|
|
202
|
+
const timeoutMs = parseMs(timeoutText)
|
|
148
203
|
const sleepFirst = options.sleepFirst ?? has("--sleep-first")
|
|
149
204
|
const prompt = readPrompt(project, options)
|
|
150
205
|
const model = options.model ?? arg("--model")
|
|
151
206
|
const agent = options.agent ?? arg("--agent")
|
|
152
207
|
const opencodeBin = options.opencodeBin || OPENCODE_BIN
|
|
208
|
+
const explicitSessionID = options.session ?? arg("--session")
|
|
209
|
+
let sessionID = explicitSessionID || await resolveLatestSessionID(opencodeBin, project)
|
|
210
|
+
const sessionTitle = `OpenCode Loop daemon ${process.pid}-${Date.now()}`
|
|
153
211
|
|
|
154
212
|
console.log("OpenCode Loop daemon")
|
|
155
213
|
console.log(`project: ${project}`)
|
|
156
214
|
console.log(`every: ${every}`)
|
|
157
215
|
console.log(`maxRuns: ${maxRuns || "unlimited"}`)
|
|
216
|
+
console.log(`timeout: ${timeoutText}`)
|
|
217
|
+
if (sessionID) console.log(`session: ${sessionID} (pinned)`)
|
|
158
218
|
|
|
159
219
|
let count = 0
|
|
160
220
|
|
|
161
|
-
if (sleepFirst && delay > 0)
|
|
162
|
-
await sleep(delay)
|
|
163
|
-
}
|
|
221
|
+
if (sleepFirst && delay > 0) await sleep(delay)
|
|
164
222
|
|
|
165
223
|
while (true) {
|
|
166
224
|
count += 1
|
|
167
|
-
|
|
168
225
|
console.log("")
|
|
169
226
|
console.log(`[opencode-loopd] run #${count} ${new Date().toISOString()}`)
|
|
170
227
|
|
|
171
|
-
const runArgs = ["run"
|
|
228
|
+
const runArgs = ["run"]
|
|
229
|
+
if (sessionID) runArgs.push("--session", sessionID)
|
|
230
|
+
else runArgs.push("--title", sessionTitle)
|
|
172
231
|
if (model) runArgs.push("--model", model)
|
|
173
232
|
if (agent) runArgs.push("--agent", agent)
|
|
174
233
|
runArgs.push(prompt)
|
|
175
|
-
const code = await run(opencodeBin, runArgs, project)
|
|
176
234
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
235
|
+
const result = await run(opencodeBin, runArgs, project, { timeoutMs })
|
|
236
|
+
const code = result.timedOut ? 124 : result.code
|
|
237
|
+
const moreRunsRemain = maxRuns === 0 || count < maxRuns
|
|
238
|
+
|
|
239
|
+
if (!sessionID && code === 0 && moreRunsRemain) {
|
|
240
|
+
sessionID = await resolveLatestSessionID(opencodeBin, project, sessionTitle)
|
|
241
|
+
if (!sessionID) {
|
|
242
|
+
console.error("[opencode-loopd] could not resolve the newly created session; refusing to continue unpinned")
|
|
243
|
+
return 1
|
|
181
244
|
}
|
|
245
|
+
console.log(`[opencode-loopd] pinned session ${sessionID}`)
|
|
182
246
|
}
|
|
183
247
|
|
|
248
|
+
if (result.timedOut) console.log(`[opencode-loopd] opencode run timed out after ${timeoutText}`)
|
|
249
|
+
else if (code !== 0) console.log(`[opencode-loopd] opencode exited with code ${code}`)
|
|
250
|
+
if (code !== 0 && delay === 0 && moreRunsRemain) await sleep(FAILED_RUN_RETRY_MS)
|
|
251
|
+
|
|
184
252
|
if (maxRuns > 0 && count >= maxRuns) {
|
|
185
253
|
console.log("[opencode-loopd] max runs reached")
|
|
186
254
|
return Number.isInteger(code) && code > 0 ? code : code < 0 ? 1 : 0
|
|
187
255
|
}
|
|
188
256
|
|
|
189
|
-
if (delay > 0)
|
|
190
|
-
await sleep(delay)
|
|
191
|
-
}
|
|
257
|
+
if (delay > 0) await sleep(delay)
|
|
192
258
|
}
|
|
193
259
|
}
|
|
194
260
|
|
|
@@ -227,6 +293,7 @@ function installTask() {
|
|
|
227
293
|
const promptArg = arg("--prompt")
|
|
228
294
|
const model = arg("--model")
|
|
229
295
|
const agent = arg("--agent")
|
|
296
|
+
const timeout = arg("--timeout", "30m")
|
|
230
297
|
const node = process.execPath
|
|
231
298
|
const script = fileURLToPath(import.meta.url)
|
|
232
299
|
const artifacts = taskArtifacts(name)
|
|
@@ -239,6 +306,7 @@ function installTask() {
|
|
|
239
306
|
prompt: promptArg || undefined,
|
|
240
307
|
model: model || undefined,
|
|
241
308
|
agent: agent || undefined,
|
|
309
|
+
timeout,
|
|
242
310
|
opencodeBin: OPENCODE_BIN,
|
|
243
311
|
}
|
|
244
312
|
fs.writeFileSync(artifacts.config, JSON.stringify(taskConfig, null, 2) + "\n", "utf8")
|
|
@@ -296,6 +364,8 @@ Options:
|
|
|
296
364
|
--prompt-file <file> Read prompt from file relative to the project
|
|
297
365
|
--model <provider/id> OpenCode model used for each run
|
|
298
366
|
--agent <name> OpenCode agent used for each run
|
|
367
|
+
--session <id> Pin an existing OpenCode session (auto-pinned otherwise)
|
|
368
|
+
--timeout <duration> Max time per OpenCode run (default: 30m, 0s disables)
|
|
299
369
|
--max-runs <n> Stop after n runs
|
|
300
370
|
--sleep-first Wait before first run
|
|
301
371
|
`)
|
package/src/index.js
CHANGED
|
@@ -23,6 +23,7 @@ const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000
|
|
|
23
23
|
const LOOP_OWNED_USER_MESSAGE_GUARD_MS = 10_000
|
|
24
24
|
const LOOP_OWNED_USER_MESSAGE_RETENTION_MS = 10 * 60_000
|
|
25
25
|
const LOCAL_COMMAND_AGENT = "opencode-loop-local"
|
|
26
|
+
const STATE_BASELINE = Symbol("opencode-loop-state-baseline")
|
|
26
27
|
|
|
27
28
|
const activeRuns = new Map()
|
|
28
29
|
const handledCommands = new Map()
|
|
@@ -373,7 +374,7 @@ async function withStateWriteLock(directory, sessionID, fn) {
|
|
|
373
374
|
}
|
|
374
375
|
}
|
|
375
376
|
|
|
376
|
-
async function
|
|
377
|
+
async function readStateFile(directory, sessionID) {
|
|
377
378
|
const target = statePath(directory, sessionID)
|
|
378
379
|
const attempts = 5
|
|
379
380
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
@@ -394,6 +395,17 @@ async function readState(directory, sessionID) {
|
|
|
394
395
|
return { version: 4, jobs: [] }
|
|
395
396
|
}
|
|
396
397
|
|
|
398
|
+
async function readState(directory, sessionID) {
|
|
399
|
+
const state = await readStateFile(directory, sessionID)
|
|
400
|
+
Object.defineProperty(state, STATE_BASELINE, {
|
|
401
|
+
value: structuredClone(state.jobs || []),
|
|
402
|
+
enumerable: false,
|
|
403
|
+
configurable: false,
|
|
404
|
+
writable: true,
|
|
405
|
+
})
|
|
406
|
+
return state
|
|
407
|
+
}
|
|
408
|
+
|
|
397
409
|
function isRetriableStateWriteError(error) {
|
|
398
410
|
const code = error?.code
|
|
399
411
|
return code === "EPERM" || code === "EACCES" || code === "EBUSY" || code === "EEXIST" || code === "EAGAIN"
|
|
@@ -457,11 +469,79 @@ async function writeFileAtomically(target, contents, options = {}) {
|
|
|
457
469
|
}
|
|
458
470
|
}
|
|
459
471
|
|
|
472
|
+
function stateValuesEqual(left, right) {
|
|
473
|
+
if (Object.is(left, right)) return true
|
|
474
|
+
try { return JSON.stringify(left) === JSON.stringify(right) } catch { return false }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function mergeStateJob(baseJob, intendedJob, currentJob) {
|
|
478
|
+
const merged = structuredClone(currentJob || {})
|
|
479
|
+
const keys = new Set([
|
|
480
|
+
...Object.keys(baseJob || {}),
|
|
481
|
+
...Object.keys(intendedJob || {}),
|
|
482
|
+
])
|
|
483
|
+
for (const key of keys) {
|
|
484
|
+
const baseHas = Object.prototype.hasOwnProperty.call(baseJob || {}, key)
|
|
485
|
+
const intendedHas = Object.prototype.hasOwnProperty.call(intendedJob || {}, key)
|
|
486
|
+
const currentHas = Object.prototype.hasOwnProperty.call(currentJob || {}, key)
|
|
487
|
+
const intendedChanged = baseHas !== intendedHas || !stateValuesEqual(baseJob?.[key], intendedJob?.[key])
|
|
488
|
+
if (!intendedChanged) continue
|
|
489
|
+
const currentChanged = baseHas !== currentHas || !stateValuesEqual(baseJob?.[key], currentJob?.[key])
|
|
490
|
+
const sameResult = intendedHas === currentHas && stateValuesEqual(intendedJob?.[key], currentJob?.[key])
|
|
491
|
+
// First committed writer wins a true same-field conflict. A stale snapshot
|
|
492
|
+
// can still apply changes to unrelated fields without erasing newer state.
|
|
493
|
+
if (currentChanged && !sameResult) continue
|
|
494
|
+
if (intendedHas) merged[key] = structuredClone(intendedJob[key])
|
|
495
|
+
else delete merged[key]
|
|
496
|
+
}
|
|
497
|
+
return merged
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function mergeStateJobs(baseJobs, intendedJobs, currentJobs) {
|
|
501
|
+
const byID = (jobs) => new Map((jobs || []).filter((job) => job?.id).map((job) => [job.id, job]))
|
|
502
|
+
const base = byID(baseJobs)
|
|
503
|
+
const intended = byID(intendedJobs)
|
|
504
|
+
const current = byID(currentJobs)
|
|
505
|
+
const merged = []
|
|
506
|
+
|
|
507
|
+
for (const currentJob of currentJobs || []) {
|
|
508
|
+
const id = currentJob?.id
|
|
509
|
+
if (!id || !base.has(id)) {
|
|
510
|
+
merged.push(structuredClone(currentJob))
|
|
511
|
+
continue
|
|
512
|
+
}
|
|
513
|
+
const baseJob = base.get(id)
|
|
514
|
+
const intendedJob = intended.get(id)
|
|
515
|
+
if (!intendedJob) {
|
|
516
|
+
// A deletion based on an old snapshot must not erase a job that changed
|
|
517
|
+
// after that snapshot was read.
|
|
518
|
+
if (!stateValuesEqual(baseJob, currentJob)) merged.push(structuredClone(currentJob))
|
|
519
|
+
continue
|
|
520
|
+
}
|
|
521
|
+
merged.push(mergeStateJob(baseJob, intendedJob, currentJob))
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
for (const intendedJob of intendedJobs || []) {
|
|
525
|
+
const id = intendedJob?.id
|
|
526
|
+
if (!id || base.has(id) || current.has(id)) continue
|
|
527
|
+
merged.push(structuredClone(intendedJob))
|
|
528
|
+
}
|
|
529
|
+
return merged
|
|
530
|
+
}
|
|
531
|
+
|
|
460
532
|
async function writeState(directory, sessionID, state) {
|
|
461
533
|
await withStateWriteLock(directory, sessionID, async () => {
|
|
462
534
|
await ensureDir(stateDir(directory))
|
|
463
535
|
const target = statePath(directory, sessionID)
|
|
464
|
-
const
|
|
536
|
+
const baseline = state?.[STATE_BASELINE]
|
|
537
|
+
let jobs = structuredClone(state.jobs || [])
|
|
538
|
+
if (Array.isArray(baseline)) {
|
|
539
|
+
const current = await readStateFile(directory, sessionID)
|
|
540
|
+
jobs = mergeStateJobs(baseline, jobs, current.jobs || [])
|
|
541
|
+
state.jobs = structuredClone(jobs)
|
|
542
|
+
state[STATE_BASELINE] = structuredClone(jobs)
|
|
543
|
+
}
|
|
544
|
+
const payload = JSON.stringify({ version: 4, jobs }, null, 2)
|
|
465
545
|
await writeFileAtomically(target, payload)
|
|
466
546
|
})
|
|
467
547
|
}
|