@bybrawe/opencode-loop 0.5.34 → 0.5.36
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 +20 -0
- package/README.md +1 -1
- package/package.json +3 -3
- package/scripts/empty-turn-recovery-test.mjs +150 -0
- package/scripts/host-adapter-contract-test.mjs +2 -2
- package/scripts/host-goal-coexistence-canary.mjs +419 -0
- package/scripts/network-recovery-test.mjs +218 -0
- package/scripts/session-status-test.mjs +18 -4
- package/src/index.js +422 -33
- package/src/source/core/continuation.js +53 -2
- package/src/source/opencode/host.js +23 -2
- package/src/source/runtime/empty-turn.js +46 -0
- package/src/source/runtime/executor.js +173 -16
- package/src/source/runtime/network-recovery.js +40 -0
- package/src/source/runtime/run-finalization.js +28 -0
- package/src/source/runtime/session-status.js +37 -32
- package/src/source/runtime/terminal-guard.js +56 -0
|
@@ -0,0 +1,419 @@
|
|
|
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, readdir, 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 GOAL_OBJECTIVE = "prove dedicated Goal owns continuation over Loop"
|
|
14
|
+
const GOAL_PROMPT_MARKER = "Continue working toward the active OpenCode goal."
|
|
15
|
+
const LOOP_PROMPT_MARKER = "AUTONOMOUS OPENCODE LOOP ITERATION"
|
|
16
|
+
|
|
17
|
+
function resolveOpenCodeBinary() {
|
|
18
|
+
if (!isWindows) return path.join(repoRoot, "node_modules", ".bin", "opencode")
|
|
19
|
+
const candidates = [
|
|
20
|
+
path.join(repoRoot, "node_modules", "opencode-windows-x64", "bin", "opencode.exe"),
|
|
21
|
+
path.join(repoRoot, "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe"),
|
|
22
|
+
path.join(repoRoot, "node_modules", "opencode-windows-arm64", "bin", "opencode.exe"),
|
|
23
|
+
]
|
|
24
|
+
const found = candidates.find((candidate) => existsSync(candidate))
|
|
25
|
+
if (!found) throw new Error(`OpenCode native Windows binary was not installed. Checked: ${candidates.join(", ")}`)
|
|
26
|
+
return found
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const opencodeBin = resolveOpenCodeBinary()
|
|
30
|
+
|
|
31
|
+
function appendLog(current, chunk, limit = 80_000) {
|
|
32
|
+
return (current + String(chunk)).slice(-limit)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function seedConfigDependencies(dir) {
|
|
36
|
+
await mkdir(path.join(dir, "node_modules"), { recursive: true })
|
|
37
|
+
const dependencies = { "@opencode-ai/plugin": "*" }
|
|
38
|
+
await writeFile(path.join(dir, "package.json"), `${JSON.stringify({ private: true, dependencies }, null, 2)}\n`)
|
|
39
|
+
await writeFile(
|
|
40
|
+
path.join(dir, "package-lock.json"),
|
|
41
|
+
`${JSON.stringify({
|
|
42
|
+
name: "opencode-loop-goal-coexistence-config",
|
|
43
|
+
lockfileVersion: 3,
|
|
44
|
+
requires: true,
|
|
45
|
+
packages: { "": { dependencies } },
|
|
46
|
+
}, null, 2)}\n`,
|
|
47
|
+
)
|
|
48
|
+
await writeFile(path.join(dir, ".gitignore"), "node_modules\npackage.json\npackage-lock.json\nbun.lock\n.gitignore\n")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function reservePort() {
|
|
52
|
+
return await new Promise((resolve, reject) => {
|
|
53
|
+
const server = net.createServer()
|
|
54
|
+
server.once("error", reject)
|
|
55
|
+
server.listen(0, "127.0.0.1", () => {
|
|
56
|
+
const address = server.address()
|
|
57
|
+
if (!address || typeof address === "string") return reject(new Error("failed to reserve TCP port"))
|
|
58
|
+
server.close((error) => error ? reject(error) : resolve(address.port))
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function waitForTcp(port, child, logs, timeoutMs = 30_000) {
|
|
64
|
+
const deadline = Date.now() + timeoutMs
|
|
65
|
+
while (Date.now() < deadline) {
|
|
66
|
+
if (child.exitCode !== null) throw new Error(`OpenCode server exited before ready.\n${logs()}`)
|
|
67
|
+
const connected = await new Promise((resolve) => {
|
|
68
|
+
const socket = net.createConnection({ host: "127.0.0.1", port })
|
|
69
|
+
socket.once("connect", () => { socket.destroy(); resolve(true) })
|
|
70
|
+
socket.once("error", () => resolve(false))
|
|
71
|
+
socket.setTimeout(500, () => { socket.destroy(); resolve(false) })
|
|
72
|
+
})
|
|
73
|
+
if (connected) return
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`timed out waiting for OpenCode server on ${port}\n${logs()}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function stopProcess(child, timeoutMs = 2_000) {
|
|
80
|
+
if (!child || child.exitCode !== null) return
|
|
81
|
+
child.kill()
|
|
82
|
+
await new Promise((resolve) => {
|
|
83
|
+
if (child.exitCode !== null) return resolve()
|
|
84
|
+
const timer = setTimeout(resolve, timeoutMs)
|
|
85
|
+
child.once("close", () => { clearTimeout(timer); resolve() })
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function spawnOpenCode(args, options = {}) {
|
|
90
|
+
return spawn(opencodeBin, args, { ...options, windowsHide: true })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function runOpenCode(args, { cwd, env, timeoutMs = 60_000 }) {
|
|
94
|
+
return await new Promise((resolve, reject) => {
|
|
95
|
+
const child = spawnOpenCode(args, { cwd, env })
|
|
96
|
+
let stdout = ""
|
|
97
|
+
let stderr = ""
|
|
98
|
+
let settled = false
|
|
99
|
+
child.stdout?.on("data", (chunk) => { stdout = appendLog(stdout, chunk) })
|
|
100
|
+
child.stderr?.on("data", (chunk) => { stderr = appendLog(stderr, chunk) })
|
|
101
|
+
const finish = (fn, value) => {
|
|
102
|
+
if (settled) return
|
|
103
|
+
settled = true
|
|
104
|
+
clearTimeout(timer)
|
|
105
|
+
fn(value)
|
|
106
|
+
}
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
void stopProcess(child)
|
|
109
|
+
finish(reject, new Error(`OpenCode command timed out: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
110
|
+
}, timeoutMs)
|
|
111
|
+
child.once("error", (error) => finish(reject, error))
|
|
112
|
+
child.once("close", (code) => {
|
|
113
|
+
if (code !== 0) return finish(reject, new Error(`OpenCode command exited ${code}: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
114
|
+
finish(resolve, { stdout, stderr })
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function contentText(content) {
|
|
120
|
+
if (typeof content === "string") return content
|
|
121
|
+
if (!Array.isArray(content)) return ""
|
|
122
|
+
return content.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").join("\n")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function lastUserMessageText(body) {
|
|
126
|
+
const messages = Array.isArray(body?.messages) ? body.messages : []
|
|
127
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
128
|
+
if (messages[index]?.role === "user") return contentText(messages[index]?.content)
|
|
129
|
+
}
|
|
130
|
+
return contentText(messages.at(-1)?.content)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function streamText(res, content, sequence) {
|
|
134
|
+
const id = `chatcmpl-loop-goal-coexistence-${sequence}`
|
|
135
|
+
const created = Math.floor(Date.now() / 1000)
|
|
136
|
+
res.writeHead(200, {
|
|
137
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
138
|
+
"cache-control": "no-cache",
|
|
139
|
+
connection: "keep-alive",
|
|
140
|
+
})
|
|
141
|
+
const send = (value) => res.write(`data: ${JSON.stringify(value)}\n\n`)
|
|
142
|
+
send({
|
|
143
|
+
id,
|
|
144
|
+
object: "chat.completion.chunk",
|
|
145
|
+
created,
|
|
146
|
+
model: "canary",
|
|
147
|
+
choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }],
|
|
148
|
+
})
|
|
149
|
+
send({
|
|
150
|
+
id,
|
|
151
|
+
object: "chat.completion.chunk",
|
|
152
|
+
created,
|
|
153
|
+
model: "canary",
|
|
154
|
+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
155
|
+
usage: { prompt_tokens: 40, completion_tokens: 4, total_tokens: 44 },
|
|
156
|
+
})
|
|
157
|
+
res.end("data: [DONE]\n\n")
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function startProvider() {
|
|
161
|
+
let releaseGoal
|
|
162
|
+
const goalRelease = new Promise((resolve) => { releaseGoal = resolve })
|
|
163
|
+
const stats = { chatRequests: 0, goalRequests: 0, loopRequests: 0, otherRequests: 0, paths: [] }
|
|
164
|
+
const server = createServer(async (req, res) => {
|
|
165
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1")
|
|
166
|
+
stats.paths.push(`${req.method} ${url.pathname}`)
|
|
167
|
+
if (req.method === "GET" && url.pathname.endsWith("/models")) {
|
|
168
|
+
res.writeHead(200, { "content-type": "application/json" })
|
|
169
|
+
res.end(JSON.stringify({ object: "list", data: [{ id: "canary", object: "model", owned_by: "canary" }] }))
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
if (req.method !== "POST" || !url.pathname.endsWith("/chat/completions")) {
|
|
173
|
+
res.writeHead(404, { "content-type": "application/json" })
|
|
174
|
+
res.end(JSON.stringify({ error: { message: `unexpected endpoint: ${req.method} ${url.pathname}` } }))
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let raw = ""
|
|
179
|
+
for await (const chunk of req) raw += String(chunk)
|
|
180
|
+
const body = raw ? JSON.parse(raw) : {}
|
|
181
|
+
const text = lastUserMessageText(body)
|
|
182
|
+
stats.chatRequests += 1
|
|
183
|
+
const sequence = stats.chatRequests
|
|
184
|
+
|
|
185
|
+
if (text.includes(GOAL_PROMPT_MARKER) && text.includes(GOAL_OBJECTIVE)) {
|
|
186
|
+
stats.goalRequests += 1
|
|
187
|
+
if (stats.goalRequests === 1) await goalRelease
|
|
188
|
+
streamText(res, `GOAL_TURN_${stats.goalRequests}`, sequence)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
if (text.includes(LOOP_PROMPT_MARKER)) {
|
|
192
|
+
stats.loopRequests += 1
|
|
193
|
+
streamText(res, `UNEXPECTED_LOOP_TURN_${stats.loopRequests}`, sequence)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
stats.otherRequests += 1
|
|
198
|
+
streamText(res, `OTHER_TURN_${stats.otherRequests}`, sequence)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
stats,
|
|
203
|
+
releaseGoal() { releaseGoal() },
|
|
204
|
+
async listen() {
|
|
205
|
+
await new Promise((resolve, reject) => {
|
|
206
|
+
server.once("error", reject)
|
|
207
|
+
server.listen(0, "127.0.0.1", resolve)
|
|
208
|
+
})
|
|
209
|
+
const address = server.address()
|
|
210
|
+
if (!address || typeof address === "string") throw new Error("failed to start deterministic coexistence provider")
|
|
211
|
+
return address.port
|
|
212
|
+
},
|
|
213
|
+
async close() {
|
|
214
|
+
releaseGoal()
|
|
215
|
+
await new Promise((resolve) => server.close(() => resolve()))
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function waitFor(predicate, description, diagnostics, timeoutMs = 45_000) {
|
|
221
|
+
const deadline = Date.now() + timeoutMs
|
|
222
|
+
while (Date.now() < deadline) {
|
|
223
|
+
if (await predicate()) return
|
|
224
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
225
|
+
}
|
|
226
|
+
const detail = typeof diagnostics === "function" ? await diagnostics() : diagnostics
|
|
227
|
+
throw new Error(`timed out waiting for ${description}\n${detail}`)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function readActiveGoal(workspace, sessionID) {
|
|
231
|
+
const dir = path.join(workspace, ".opencode", "goals")
|
|
232
|
+
try {
|
|
233
|
+
for (const name of await readdir(dir)) {
|
|
234
|
+
if (!name.endsWith(".json")) continue
|
|
235
|
+
try {
|
|
236
|
+
const value = JSON.parse(await readFile(path.join(dir, name), "utf8"))
|
|
237
|
+
if (value?.sessionID === sessionID) return value
|
|
238
|
+
} catch {}
|
|
239
|
+
}
|
|
240
|
+
} catch {}
|
|
241
|
+
return undefined
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function main() {
|
|
245
|
+
const goalPackageRoot = path.join(repoRoot, "node_modules", "@bybrawe", "opencode-goal")
|
|
246
|
+
const goalPackageJson = path.join(goalPackageRoot, "package.json")
|
|
247
|
+
assert.ok(existsSync(goalPackageJson), "@bybrawe/opencode-goal must be installed before the coexistence canary")
|
|
248
|
+
const goalPackage = JSON.parse(await readFile(goalPackageJson, "utf8"))
|
|
249
|
+
|
|
250
|
+
const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-goal-coexistence-"))
|
|
251
|
+
const home = path.join(workspace, ".home")
|
|
252
|
+
const projectConfig = path.join(workspace, ".opencode")
|
|
253
|
+
const globalConfig = path.join(home, ".config", "opencode")
|
|
254
|
+
const pluginDir = path.join(projectConfig, "plugins")
|
|
255
|
+
const commandDir = path.join(projectConfig, "commands")
|
|
256
|
+
const agentDir = path.join(projectConfig, "agents")
|
|
257
|
+
const provider = startProvider()
|
|
258
|
+
const providerPort = await provider.listen()
|
|
259
|
+
let server
|
|
260
|
+
let serverLog = ""
|
|
261
|
+
let goalCommandError = null
|
|
262
|
+
let loopCommandError = null
|
|
263
|
+
|
|
264
|
+
await mkdir(pluginDir, { recursive: true })
|
|
265
|
+
await mkdir(commandDir, { recursive: true })
|
|
266
|
+
await mkdir(agentDir, { recursive: true })
|
|
267
|
+
await seedConfigDependencies(projectConfig)
|
|
268
|
+
await seedConfigDependencies(globalConfig)
|
|
269
|
+
|
|
270
|
+
const loopPluginEntry = pathToFileURL(path.join(repoRoot, "src", "index.js")).href
|
|
271
|
+
const goalPackageSpec = pathToFileURL(goalPackageRoot).href
|
|
272
|
+
await writeFile(path.join(pluginDir, "opencode-loop.js"), `export { default as OpenCodeLoopPlugin } from ${JSON.stringify(loopPluginEntry)}\n`)
|
|
273
|
+
await writeFile(path.join(commandDir, "loop.md"), `---\ndescription: Start a coexistence Loop\nagent: opencode-loop-local\n---\n\nOpenCode Loop local command handled. Reply exactly: OK.\n`)
|
|
274
|
+
await writeFile(path.join(agentDir, "opencode-loop-local.md"), `---\ndescription: Local Loop coexistence acknowledgement\nmode: primary\npermission:\n "*": deny\n---\n\nReply exactly: OK\n`)
|
|
275
|
+
await writeFile(path.join(workspace, "opencode.json"), `${JSON.stringify({
|
|
276
|
+
$schema: "https://opencode.ai/config.json",
|
|
277
|
+
plugin: [goalPackageSpec],
|
|
278
|
+
model: "canary/canary",
|
|
279
|
+
small_model: "canary/canary",
|
|
280
|
+
provider: {
|
|
281
|
+
canary: {
|
|
282
|
+
npm: "@ai-sdk/openai-compatible",
|
|
283
|
+
name: "Deterministic Loop Goal Coexistence Canary",
|
|
284
|
+
options: { baseURL: `http://127.0.0.1:${providerPort}/v1`, apiKey: "canary-key" },
|
|
285
|
+
models: { canary: { name: "Deterministic Loop Goal Coexistence Canary", limit: { context: 100000, output: 4096 } } },
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
}, null, 2)}\n`)
|
|
289
|
+
|
|
290
|
+
const env = {
|
|
291
|
+
...process.env,
|
|
292
|
+
HOME: home,
|
|
293
|
+
USERPROFILE: home,
|
|
294
|
+
XDG_CONFIG_HOME: path.join(home, ".config"),
|
|
295
|
+
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
|
296
|
+
XDG_CACHE_HOME: path.join(home, ".cache"),
|
|
297
|
+
OPENCODE_DISABLE_AUTOUPDATE: "true",
|
|
298
|
+
OPENCODE_DB: ":memory:",
|
|
299
|
+
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
|
300
|
+
CI: "true",
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const prewarm = await runOpenCode(["debug", "config"], { cwd: workspace, env, timeoutMs: 60_000 })
|
|
305
|
+
assert.match(prewarm.stdout, /\{[\s\S]*\}/, `OpenCode config prewarm returned no JSON\n${prewarm.stdout}\n${prewarm.stderr}`)
|
|
306
|
+
|
|
307
|
+
const port = await reservePort()
|
|
308
|
+
server = spawnOpenCode(["serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: workspace, env })
|
|
309
|
+
server.stdout?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
|
|
310
|
+
server.stderr?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
|
|
311
|
+
await waitForTcp(port, server, () => serverLog)
|
|
312
|
+
|
|
313
|
+
const baseURL = `http://127.0.0.1:${port}`
|
|
314
|
+
const directoryQuery = `directory=${encodeURIComponent(workspace)}`
|
|
315
|
+
const api = async (pathname, init = {}) => {
|
|
316
|
+
const separator = pathname.includes("?") ? "&" : "?"
|
|
317
|
+
const response = await fetch(`${baseURL}${pathname}${separator}${directoryQuery}`, {
|
|
318
|
+
...init,
|
|
319
|
+
headers: { "content-type": "application/json", ...(init.headers ?? {}) },
|
|
320
|
+
signal: init.signal ?? AbortSignal.timeout(30_000),
|
|
321
|
+
})
|
|
322
|
+
const text = await response.text()
|
|
323
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)
|
|
324
|
+
if (!text) return null
|
|
325
|
+
try { return JSON.parse(text) } catch { return text }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const sessionsPayload = await api("/session", { method: "GET" })
|
|
329
|
+
assert.ok(Array.isArray(sessionsPayload?.data ?? sessionsPayload), "GET /session bootstrap did not return an array")
|
|
330
|
+
const createdPayload = await api("/session", { method: "POST", body: JSON.stringify({ title: "Loop + Goal ownership canary" }) })
|
|
331
|
+
const session = createdPayload?.data ?? createdPayload
|
|
332
|
+
const sessionID = String(session?.id ?? "")
|
|
333
|
+
assert.ok(sessionID, `OpenCode did not create a session: ${JSON.stringify(createdPayload)}`)
|
|
334
|
+
|
|
335
|
+
const commandPath = `/session/${encodeURIComponent(sessionID)}/command`
|
|
336
|
+
const sendCommand = async (name, argumentsText, signal) => await api(commandPath, {
|
|
337
|
+
method: "POST",
|
|
338
|
+
body: JSON.stringify({ agent: "build", model: "canary/canary", command: name, arguments: argumentsText }),
|
|
339
|
+
signal: signal ?? AbortSignal.timeout(90_000),
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
const goalController = new AbortController()
|
|
343
|
+
const goalCommand = sendCommand("goal", `${GOAL_OBJECTIVE} --max-turns 5`, goalController.signal).catch((error) => {
|
|
344
|
+
goalCommandError = error
|
|
345
|
+
return null
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
const loopStateFile = path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`)
|
|
349
|
+
const loopLogFile = path.join(workspace, ".opencode", "opencode-loop", "loop.log")
|
|
350
|
+
const diagnostics = async () => {
|
|
351
|
+
let goal = "missing"
|
|
352
|
+
let loopState = "missing"
|
|
353
|
+
let loopLog = "missing"
|
|
354
|
+
try { goal = JSON.stringify(await readActiveGoal(workspace, sessionID), null, 2) } catch {}
|
|
355
|
+
try { loopState = await readFile(loopStateFile, "utf8") } catch {}
|
|
356
|
+
try { loopLog = await readFile(loopLogFile, "utf8") } catch {}
|
|
357
|
+
return `goal package=${goalPackage.version}\nprovider=${JSON.stringify(provider.stats)}\ngoalCommandError=${String(goalCommandError ?? "none")}\nloopCommandError=${String(loopCommandError ?? "none")}\ngoal=${goal}\nloopState=${loopState}\nloopLog=${loopLog}\nserver log:\n${serverLog}`
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
await waitFor(() => provider.stats.goalRequests === 1, "the first dedicated Goal provider turn to be held open", diagnostics)
|
|
361
|
+
await waitFor(async () => {
|
|
362
|
+
const goal = await readActiveGoal(workspace, sessionID)
|
|
363
|
+
return goal?.status === "active" && goal?.objective === GOAL_OBJECTIVE
|
|
364
|
+
}, "released Goal package to persist an active same-session Goal", diagnostics)
|
|
365
|
+
|
|
366
|
+
const loopController = new AbortController()
|
|
367
|
+
const loopCommand = sendCommand("loop", "devam et", loopController.signal).catch((error) => {
|
|
368
|
+
loopCommandError = error
|
|
369
|
+
return null
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
await waitFor(async () => {
|
|
373
|
+
try {
|
|
374
|
+
return (await readFile(loopLogFile, "utf8")).includes("goal-overlap-blocked")
|
|
375
|
+
} catch {
|
|
376
|
+
return false
|
|
377
|
+
}
|
|
378
|
+
}, "Loop to reject prompt ownership while the dedicated Goal is active", diagnostics, 20_000)
|
|
379
|
+
|
|
380
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
381
|
+
assert.equal(provider.stats.goalRequests, 1, `Goal provider turn should remain the only autonomous request while overlap is rejected\n${await diagnostics()}`)
|
|
382
|
+
assert.equal(provider.stats.loopRequests, 0, `Loop must not dispatch while dedicated Goal owns continuation\n${await diagnostics()}`)
|
|
383
|
+
assert.equal(provider.stats.otherRequests, 0, `command bridge must not leak to the model during ownership rejection\n${await diagnostics()}`)
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const loopState = JSON.parse(await readFile(loopStateFile, "utf8"))
|
|
387
|
+
assert.equal(Array.isArray(loopState.jobs) ? loopState.jobs.length : 0, 0, `blocked prompt Loop must not persist a job: ${JSON.stringify(loopState)}`)
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if (error?.code !== "ENOENT") throw error
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const activeGoal = await readActiveGoal(workspace, sessionID)
|
|
393
|
+
assert.equal(activeGoal?.status, "active", `Goal must remain active after Loop overlap rejection\n${await diagnostics()}`)
|
|
394
|
+
|
|
395
|
+
loopController.abort()
|
|
396
|
+
goalController.abort()
|
|
397
|
+
await Promise.allSettled([loopCommand, goalCommand])
|
|
398
|
+
|
|
399
|
+
console.log(JSON.stringify({
|
|
400
|
+
ok: true,
|
|
401
|
+
platform: process.platform,
|
|
402
|
+
sessionID,
|
|
403
|
+
goalPackageVersion: goalPackage.version,
|
|
404
|
+
provider: provider.stats,
|
|
405
|
+
dedicatedGoal: { id: activeGoal?.id, status: activeGoal?.status },
|
|
406
|
+
loop: { blocked: true, persistedJobs: 0 },
|
|
407
|
+
}, null, 2))
|
|
408
|
+
} finally {
|
|
409
|
+
provider.releaseGoal()
|
|
410
|
+
await stopProcess(server)
|
|
411
|
+
await provider.close().catch(() => undefined)
|
|
412
|
+
await rm(workspace, { recursive: true, force: true }).catch(() => undefined)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
main().catch((error) => {
|
|
417
|
+
console.error(error?.stack || error)
|
|
418
|
+
process.exitCode = 1
|
|
419
|
+
})
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { isCompletionBoundedContinuation, isTerminalNoWorkReply } from "../src/source/core/continuation.js"
|
|
3
|
+
import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "../src/source/runtime/network-recovery.js"
|
|
4
|
+
import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js"
|
|
5
|
+
import { applyTerminalContinuationGuard } from "../src/source/runtime/terminal-guard.js"
|
|
6
|
+
import { createLoopExecutor } from "../src/source/runtime/executor.js"
|
|
7
|
+
import { clearSessionActivity, sessionStatuses, sessionStatusSeenAt } from "../src/source/runtime/session-activity.js"
|
|
8
|
+
|
|
9
|
+
function clone(value) { return JSON.parse(JSON.stringify(value)) }
|
|
10
|
+
|
|
11
|
+
for (const value of [
|
|
12
|
+
"fetch failed",
|
|
13
|
+
"network connection lost",
|
|
14
|
+
"ECONNRESET",
|
|
15
|
+
"EAI_AGAIN",
|
|
16
|
+
"ETIMEDOUT",
|
|
17
|
+
"socket hang up",
|
|
18
|
+
"HTTP 503 service unavailable",
|
|
19
|
+
]) assert.equal(isTransientNetworkError(value), true, value)
|
|
20
|
+
assert.equal(isTransientNetworkError("HTTP 401 invalid API key"), false)
|
|
21
|
+
assert.equal(networkRetryDelayMs(1, 5_000, 60_000), 5_000)
|
|
22
|
+
assert.equal(networkRetryDelayMs(2, 5_000, 60_000), 10_000)
|
|
23
|
+
assert.equal(networkRetryDelayMs(9, 5_000, 60_000), 60_000)
|
|
24
|
+
|
|
25
|
+
const refunded = {
|
|
26
|
+
id: "limited",
|
|
27
|
+
runCount: 3,
|
|
28
|
+
maxRuns: 3,
|
|
29
|
+
enabled: false,
|
|
30
|
+
lastRunAt: 500,
|
|
31
|
+
failureCount: 2,
|
|
32
|
+
}
|
|
33
|
+
refundInfrastructureRun(refunded, {
|
|
34
|
+
runCount: 3,
|
|
35
|
+
previousLastRunAt: 250,
|
|
36
|
+
disabledByMaxRuns: true,
|
|
37
|
+
}, {
|
|
38
|
+
reason: "dispatch_failed_transient",
|
|
39
|
+
error: new Error("fetch failed: ECONNRESET"),
|
|
40
|
+
now: 1_000,
|
|
41
|
+
})
|
|
42
|
+
assert.equal(refunded.runCount, 2, "transient infrastructure attempt must not consume max-runs")
|
|
43
|
+
assert.equal(refunded.enabled, true, "max-runs disable caused by a transient attempt must be rolled back")
|
|
44
|
+
assert.equal(refunded.lastRunAt, 250)
|
|
45
|
+
assert.equal(refunded.failureCount, 2, "transient outage must not consume ordinary failure budget")
|
|
46
|
+
assert.equal(refunded.infrastructureFailureCount, 1)
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
let clock = 100_000
|
|
50
|
+
const activeRuns = new Map([["retry-session", { startedAt: 1, jobId: "j", job: { id: "j" } }]])
|
|
51
|
+
const logs = []
|
|
52
|
+
const runtime = createSessionStatusRuntime({
|
|
53
|
+
activeRuns,
|
|
54
|
+
now: () => clock,
|
|
55
|
+
staleActiveRecoveryMs: 1_000,
|
|
56
|
+
sessionStatusCacheMs: 0,
|
|
57
|
+
activeRunCompletionFromMessages: async () => "unknown",
|
|
58
|
+
appendLoopLog: async (...args) => logs.push(args),
|
|
59
|
+
})
|
|
60
|
+
const retryClient = { session: { status: async () => ({ data: { "retry-session": { type: "retry" } } }) } }
|
|
61
|
+
assert.equal(await runtime.sessionStatusType(retryClient, "retry-session", "/repo"), "retry")
|
|
62
|
+
clock += 120_000
|
|
63
|
+
assert.equal(await runtime.sessionStatusType(retryClient, "retry-session", "/repo"), "retry", "stale retry must never be converted to idle by age alone")
|
|
64
|
+
assert.equal(logs.some((entry) => entry[1] === "status-stale-recovery"), false)
|
|
65
|
+
clearSessionActivity("retry-session")
|
|
66
|
+
sessionStatuses.delete("retry-session")
|
|
67
|
+
sessionStatusSeenAt.delete("retry-session")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
let clock = 100_000
|
|
72
|
+
const activeRuns = new Map([["offline-session", { startedAt: 1, jobId: "j", job: { id: "j" } }]])
|
|
73
|
+
const runtime = createSessionStatusRuntime({
|
|
74
|
+
activeRuns,
|
|
75
|
+
now: () => clock,
|
|
76
|
+
staleActiveRecoveryMs: 1_000,
|
|
77
|
+
sessionStatusCacheMs: 0,
|
|
78
|
+
activeRunCompletionFromMessages: async () => "unknown",
|
|
79
|
+
appendLoopLog: async () => {},
|
|
80
|
+
})
|
|
81
|
+
const offlineClient = { session: { status: async () => { throw new Error("network connection lost") } } }
|
|
82
|
+
clock += 120_000
|
|
83
|
+
assert.equal(await runtime.sessionStatusType(offlineClient, "offline-session", "/repo"), "busy", "failed status reads must fail closed, not become idle")
|
|
84
|
+
clearSessionActivity("offline-session")
|
|
85
|
+
sessionStatuses.delete("offline-session")
|
|
86
|
+
sessionStatusSeenAt.delete("offline-session")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
assert.equal(isCompletionBoundedContinuation("devam et"), false)
|
|
90
|
+
assert.equal(isCompletionBoundedContinuation("devam et bitene kadar devam tamamen projeyi bitir"), true)
|
|
91
|
+
assert.equal(isTerminalNoWorkReply("Proje tamamlandı — 28/28 test yeşil. Yapılacak iş yok. Sıfır bilinen hata."), true)
|
|
92
|
+
assert.equal(isTerminalNoWorkReply("Proje tamamlandı ama sıradaki iş build doğrulaması."), false)
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
const terminalMessages = [{
|
|
96
|
+
info: { role: "assistant", time: { created: 2_000, completed: 2_100 } },
|
|
97
|
+
parts: [{ type: "text", text: "Proje tamamlandı — 28/28 test yeşil. Yapılacak iş yok. Sıfır bilinen hata." }],
|
|
98
|
+
}]
|
|
99
|
+
const client = { session: { messages: async () => ({ data: terminalMessages }) } }
|
|
100
|
+
const bounded = {
|
|
101
|
+
id: "bounded",
|
|
102
|
+
action: "devam et bitene kadar devam tamamen projeyi bitir",
|
|
103
|
+
scheduleMode: "idle",
|
|
104
|
+
lastRunAt: 1_500,
|
|
105
|
+
paused: false,
|
|
106
|
+
}
|
|
107
|
+
let result = await applyTerminalContinuationGuard("/repo", client, "s", bounded)
|
|
108
|
+
assert.equal(result.terminal, true)
|
|
109
|
+
assert.equal(result.pausedNow, false)
|
|
110
|
+
assert.equal(result.job.terminalNoWorkCount, 1)
|
|
111
|
+
result = await applyTerminalContinuationGuard("/repo", client, "s", result.job)
|
|
112
|
+
assert.equal(result.pausedNow, true, "two current terminal replies should stop an explicit until-done loop")
|
|
113
|
+
assert.equal(result.job.paused, true)
|
|
114
|
+
|
|
115
|
+
const infinite = {
|
|
116
|
+
id: "infinite",
|
|
117
|
+
action: "devam et",
|
|
118
|
+
scheduleMode: "idle",
|
|
119
|
+
lastRunAt: 1_500,
|
|
120
|
+
paused: false,
|
|
121
|
+
}
|
|
122
|
+
const untouched = await applyTerminalContinuationGuard("/repo", client, "s", infinite)
|
|
123
|
+
assert.equal(untouched.terminal, false)
|
|
124
|
+
assert.equal(untouched.job.paused, false, "plain /loop devam et must remain intentionally infinite")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
{
|
|
128
|
+
let clock = 1_000_000
|
|
129
|
+
let liveStatus = "idle"
|
|
130
|
+
const states = new Map()
|
|
131
|
+
const schedules = []
|
|
132
|
+
const fireCalls = []
|
|
133
|
+
const directory = "/repo"
|
|
134
|
+
const sessionID = "watchdog-session"
|
|
135
|
+
const stateKey = `${directory}:${sessionID}`
|
|
136
|
+
states.set(stateKey, { jobs: [{
|
|
137
|
+
id: "job",
|
|
138
|
+
name: "job",
|
|
139
|
+
action: "devam et",
|
|
140
|
+
scheduleMode: "idle",
|
|
141
|
+
enabled: true,
|
|
142
|
+
paused: false,
|
|
143
|
+
intervalMs: 0,
|
|
144
|
+
runCount: 0,
|
|
145
|
+
maxRuns: 1,
|
|
146
|
+
maxRuntimeMs: 0,
|
|
147
|
+
timeoutMs: 0,
|
|
148
|
+
noOverlap: true,
|
|
149
|
+
}] })
|
|
150
|
+
|
|
151
|
+
const executor = createLoopExecutor({
|
|
152
|
+
workspace: {
|
|
153
|
+
buildPrompt: async (_directory, job) => job.action,
|
|
154
|
+
ensureBranch: async (_directory, job) => job,
|
|
155
|
+
watchChanged: async () => false,
|
|
156
|
+
untilReached: async () => false,
|
|
157
|
+
createCheckpoint: async () => {},
|
|
158
|
+
},
|
|
159
|
+
goalPolicy: {
|
|
160
|
+
runGoalChecks: async (_directory, _sessionID, job) => job,
|
|
161
|
+
applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job,
|
|
162
|
+
},
|
|
163
|
+
scheduler: {
|
|
164
|
+
rememberSession: () => {},
|
|
165
|
+
scheduleDueWork: async (...args) => schedules.push(args),
|
|
166
|
+
},
|
|
167
|
+
now: () => clock,
|
|
168
|
+
readState: async () => clone(states.get(stateKey)),
|
|
169
|
+
writeState: async (_directory, _sessionID, state) => states.set(stateKey, clone(state)),
|
|
170
|
+
appendLoopLog: async () => {},
|
|
171
|
+
notifyJob: async () => {},
|
|
172
|
+
toast: async () => {},
|
|
173
|
+
runShellCommand: async () => ({ code: 0, stdout: "", stderr: "" }),
|
|
174
|
+
fireSdk: (...args) => { fireCalls.push(args); return Promise.resolve({ data: {} }) },
|
|
175
|
+
sdkCall: async () => ({}),
|
|
176
|
+
normalizedModelRef: () => undefined,
|
|
177
|
+
compactTuiCommandName: () => undefined,
|
|
178
|
+
guardLoopOwnedUserMessage: () => {},
|
|
179
|
+
dangerousShell: () => false,
|
|
180
|
+
activeRunCompletionFromMessages: async () => "unknown",
|
|
181
|
+
busyRetryMs: 5,
|
|
182
|
+
providerRetryWatchdogMs: 20,
|
|
183
|
+
networkRetryMaxMs: 100,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const client = {
|
|
187
|
+
session: {
|
|
188
|
+
status: async () => ({ data: liveStatus === "idle" ? {} : { [sessionID]: { type: liveStatus } } }),
|
|
189
|
+
prompt: async () => ({ data: {} }),
|
|
190
|
+
abort: async () => ({ data: {} }),
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
195
|
+
assert.ok(executor.getActiveRun(sessionID), "first logical run should be active")
|
|
196
|
+
assert.equal(states.get(stateKey).jobs[0].runCount, 1)
|
|
197
|
+
assert.equal(states.get(stateKey).jobs[0].enabled, false, "max-runs can temporarily disable while the real turn is in flight")
|
|
198
|
+
|
|
199
|
+
liveStatus = "retry"
|
|
200
|
+
clock += 5
|
|
201
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
202
|
+
assert.ok(executor.getActiveRun(sessionID), "first retry observation should remain host-owned")
|
|
203
|
+
clock += 25
|
|
204
|
+
await executor.maybeRunDueJobs(directory, client, sessionID)
|
|
205
|
+
|
|
206
|
+
assert.equal(executor.getActiveRun(sessionID), undefined, "stuck Loop-owned retry should be released after watchdog")
|
|
207
|
+
const job = states.get(stateKey).jobs[0]
|
|
208
|
+
assert.equal(job.runCount, 0, "network watchdog recovery must refund the logical run")
|
|
209
|
+
assert.equal(job.enabled, true, "refunded max-runs job should be runnable again")
|
|
210
|
+
assert.equal(job.infrastructureFailureCount, 1)
|
|
211
|
+
assert.ok(fireCalls.some((call) => String(call[1]).includes("provider retry watchdog")), "watchdog must abort only the Loop-owned stuck turn")
|
|
212
|
+
assert.ok(schedules.some((call) => call[3] >= 5), "recovery must reschedule with backoff")
|
|
213
|
+
clearSessionActivity(sessionID)
|
|
214
|
+
sessionStatuses.delete(sessionID)
|
|
215
|
+
sessionStatusSeenAt.delete(sessionID)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log("network recovery + terminal continuation guard: ok")
|
|
@@ -158,17 +158,31 @@ try {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
{
|
|
161
|
-
const sessionID = "
|
|
161
|
+
const sessionID = "retry-remains-owned"
|
|
162
162
|
track(sessionID)
|
|
163
163
|
runtime.clearSessionStatus(sessionID)
|
|
164
164
|
activeRuns.set(sessionID, { jobId: "job-2", job: { id: "job-2" }, startedAt: 1_000 })
|
|
165
165
|
completion = "unknown"
|
|
166
|
+
const beforeLogs = logs.length
|
|
166
167
|
const client = { session: { status: async () => ({ data: { [sessionID]: { type: "retry" } } }) } }
|
|
167
|
-
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "
|
|
168
|
-
assert.
|
|
168
|
+
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "retry")
|
|
169
|
+
assert.equal(logs.length, beforeLogs, "unknown retry must not use stale-busy recovery")
|
|
169
170
|
activeRuns.delete(sessionID)
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
{
|
|
174
|
+
const sessionID = "retry-message-complete"
|
|
175
|
+
track(sessionID)
|
|
176
|
+
runtime.clearSessionStatus(sessionID)
|
|
177
|
+
activeRuns.set(sessionID, { jobId: "job-retry-complete", job: { id: "job-retry-complete", name: "retry-complete" }, startedAt: 1_000 })
|
|
178
|
+
completion = "completed"
|
|
179
|
+
const client = { session: { status: async () => ({ data: { [sessionID]: { type: "retry" } } }) } }
|
|
180
|
+
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "idle", "a genuinely completed current assistant turn may release stale retry")
|
|
181
|
+
assert.deepEqual(logs.at(-1), ["/repo", "status-message-complete-recovery", { sessionID, job: "retry-complete", startedAt: 1_000 }])
|
|
182
|
+
activeRuns.delete(sessionID)
|
|
183
|
+
completion = "unknown"
|
|
184
|
+
}
|
|
185
|
+
|
|
172
186
|
{
|
|
173
187
|
const sessionID = "fallback-fresh"
|
|
174
188
|
track(sessionID)
|
|
@@ -178,7 +192,7 @@ try {
|
|
|
178
192
|
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "busy")
|
|
179
193
|
activeRuns.set(sessionID, { jobId: "stale", job: {}, startedAt: clock - 60_000 })
|
|
180
194
|
runtime.clearSessionStatus(sessionID)
|
|
181
|
-
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "
|
|
195
|
+
assert.equal(await runtime.sessionStatusType(client, sessionID, "/repo"), "busy", "status transport failure must fail closed even for an old active run")
|
|
182
196
|
activeRuns.delete(sessionID)
|
|
183
197
|
}
|
|
184
198
|
|