opencode-harness-kit 0.6.4 → 0.7.0
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 +249 -1
- package/README.md +222 -5
- package/bin/oc-harness.mjs +85 -7
- package/dist/budget.d.ts +247 -0
- package/dist/config.d.ts +34 -1
- package/dist/effort.d.ts +12 -1
- package/dist/guard.d.ts +5 -0
- package/dist/health.d.ts +12 -1
- package/dist/index.js +556 -149
- package/dist/profile.d.ts +2 -1
- package/dist/prune.d.ts +22 -0
- package/dist/rules.d.ts +6 -2
- package/docs/EXIT-CODES.md +18 -4
- package/docs/REVIEW-VERDICT.md +73 -0
- package/eval/RESULTS.md +130 -7
- package/eval/ab.mjs +591 -68
- package/eval/compare.mjs +267 -0
- package/eval/verify.mjs +9 -0
- package/lanes.json +18 -9
- package/package.json +6 -2
- package/prompts/reviewer.md +24 -0
- package/src/budget.js +418 -0
- package/src/config.js +172 -6
- package/src/driver/run.mjs +780 -42
- package/src/effort.js +17 -3
- package/src/guard.ts +304 -1
- package/src/health.js +13 -1
- package/src/index.ts +23 -12
- package/src/profile.js +1 -1
- package/src/prune.ts +21 -1
- package/src/rules.js +145 -0
- package/src/tasks.js +31 -0
package/src/driver/run.mjs
CHANGED
|
@@ -1,24 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// `oc-harness run` -- drive the
|
|
3
|
-
//
|
|
2
|
+
// `oc-harness run` -- drive the plan's tasks through build and verify without a
|
|
3
|
+
// human at the keyboard.
|
|
4
4
|
//
|
|
5
|
-
// The
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// The skills already know how to do their own job. What was missing was the
|
|
6
|
+
// thing that decides *which* task runs next and *when to stop*. That is here,
|
|
7
|
+
// and it is deliberately small: it reads `.space/tasks.md`, hands one task at a
|
|
8
|
+
// time to `opencode run --agent <role>`, records the real verify output, and
|
|
9
|
+
// stops. Review and finish remain explicit human steps: this loop will not
|
|
10
|
+
// claim a task is done because an agent said so, and it will not publish.
|
|
10
11
|
//
|
|
11
12
|
// What it will not do, ever:
|
|
12
13
|
// - push, open a PR, or cut a release. Publishing is a human decision, and an
|
|
13
14
|
// unattended loop that can do it is an unattended loop that will.
|
|
14
15
|
// - retry a task it already blocked, or start a task with no verify command.
|
|
15
16
|
// A task it cannot check is a task it cannot claim is done.
|
|
17
|
+
// - run two writers in one project. `.space/run.lock` makes the second run
|
|
18
|
+
// refuse rather than race the first to the task file.
|
|
16
19
|
// - loop forever. Every stop condition is explicit and every one of them is
|
|
17
20
|
// reported, so a run that ends is never a mystery.
|
|
18
21
|
|
|
19
22
|
import { spawnSync } from "node:child_process"
|
|
20
|
-
import {
|
|
21
|
-
|
|
23
|
+
import {
|
|
24
|
+
existsSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
renameSync,
|
|
29
|
+
unlinkSync,
|
|
30
|
+
realpathSync,
|
|
31
|
+
statSync,
|
|
32
|
+
} from "node:fs"
|
|
33
|
+
import { join, relative, resolve, sep } from "node:path"
|
|
22
34
|
|
|
23
35
|
import {
|
|
24
36
|
parseTasks,
|
|
@@ -27,15 +39,114 @@ import {
|
|
|
27
39
|
taskPrompt,
|
|
28
40
|
summarize,
|
|
29
41
|
isBlocked,
|
|
42
|
+
isDone,
|
|
43
|
+
isVacuousVerify,
|
|
30
44
|
} from "../tasks.js"
|
|
31
45
|
|
|
32
46
|
const TASKS_PATH = join(".space", "tasks.md")
|
|
47
|
+
const LOCK_PATH = join(".space", "run.lock")
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether a process is still there.
|
|
51
|
+
*
|
|
52
|
+
* `process.kill(pid, 0)` sends no signal; it only asks the kernel whether the
|
|
53
|
+
* pid exists. EPERM means it exists and belongs to someone else, which is
|
|
54
|
+
* still a live holder -- the answer is yes in both cases.
|
|
55
|
+
*/
|
|
56
|
+
/** @param {unknown} err @returns {string | undefined} */
|
|
57
|
+
function errorCode(err) {
|
|
58
|
+
return err && typeof err === "object" && "code" in err
|
|
59
|
+
? /** @type {{ code?: string }} */ (err).code
|
|
60
|
+
: undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @param {unknown} err @returns {string} */
|
|
64
|
+
function errorText(err) {
|
|
65
|
+
return err instanceof Error ? err.message : String(err)
|
|
66
|
+
}
|
|
33
67
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
68
|
+
function pidAlive(pid) {
|
|
69
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pid, 0)
|
|
72
|
+
return true
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return errorCode(err) === "EPERM"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Take `.space/run.lock`, or report who holds it.
|
|
80
|
+
*
|
|
81
|
+
* Two `oc-harness run` processes in one project would both read the task file,
|
|
82
|
+
* both run the same task, and both write their result back -- the second write
|
|
83
|
+
* winning and the first task's work being reported twice. The lock is a file
|
|
84
|
+
* created with O_EXCL, so the kernel decides the race rather than the order in
|
|
85
|
+
* which the two processes happened to read the directory.
|
|
86
|
+
*
|
|
87
|
+
* A lock whose pid is gone is stale and is cleared. A lock that cannot be
|
|
88
|
+
* parsed is treated the same way: a crashed run must not need a manual `rm`
|
|
89
|
+
* before the next one can start.
|
|
90
|
+
*
|
|
91
|
+
* A live pid is never cleared automatically, because the lock may belong to a
|
|
92
|
+
* run that is simply slow. `force` is the explicit override for the case where
|
|
93
|
+
* the pid was reused by an unrelated process and waiting would be forever.
|
|
94
|
+
*/
|
|
95
|
+
export function acquireLock(cwd, { pid = process.pid, now = Date.now(), force = false } = {}) {
|
|
96
|
+
const path = join(cwd, LOCK_PATH)
|
|
97
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
98
|
+
try {
|
|
99
|
+
writeFileSync(path, JSON.stringify({ pid, at: new Date(now).toISOString() }), {
|
|
100
|
+
flag: "wx",
|
|
101
|
+
})
|
|
102
|
+
return {
|
|
103
|
+
path,
|
|
104
|
+
release() {
|
|
105
|
+
try {
|
|
106
|
+
unlinkSync(path)
|
|
107
|
+
} catch {
|
|
108
|
+
// Already gone is the state we wanted.
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (errorCode(err) !== "EEXIST") {
|
|
114
|
+
return { error: `cannot create ${LOCK_PATH}: ${errorText(err)}` }
|
|
115
|
+
}
|
|
116
|
+
let holder = null
|
|
117
|
+
try {
|
|
118
|
+
holder = JSON.parse(readFileSync(path, "utf8"))
|
|
119
|
+
} catch {
|
|
120
|
+
// A crashed run can leave half a line. Treat it as stale.
|
|
121
|
+
}
|
|
122
|
+
if (holder && pidAlive(holder.pid)) {
|
|
123
|
+
// `force` breaks a live lock once. If another run takes it between the
|
|
124
|
+
// unlink and the next create, that run wins and this one reports busy;
|
|
125
|
+
// forcing again would let two runs in.
|
|
126
|
+
if (!force || attempt > 0) return { busy: holder }
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
unlinkSync(path)
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return { error: `cannot clear stale ${LOCK_PATH}: ${errorText(err)}` }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { error: `could not take ${LOCK_PATH}` }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
// Colour is for a terminal. A CI job or a redirected log gets plain text:
|
|
140
|
+
// escape codes in a file are noise a reader pays for on every line, and
|
|
141
|
+
// `NO_COLOR` is the conventional way to ask for none.
|
|
142
|
+
const COLOUR = !("NO_COLOR" in process.env) && process.stdout.isTTY && process.env.TERM !== "dumb"
|
|
143
|
+
const paint = (code, s) => (COLOUR ? `\x1b[${code}m${s}\x1b[0m` : String(s))
|
|
144
|
+
|
|
145
|
+
const bold = (s) => paint("1", s)
|
|
146
|
+
const stamp = (s) => paint("2", s)
|
|
147
|
+
const green = (s) => paint("32", s)
|
|
148
|
+
const yellow = (s) => paint("33", s)
|
|
149
|
+
const red = (s) => paint("31", s)
|
|
39
150
|
|
|
40
151
|
/**
|
|
41
152
|
* Run one command to completion and report what happened.
|
|
@@ -75,8 +186,326 @@ export function tail(text, n = 12) {
|
|
|
75
186
|
}
|
|
76
187
|
|
|
77
188
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
189
|
+
* The working tree, keyed by path, with the two-letter status and the file's
|
|
190
|
+
* mtime so an edit to a file that was ALREADY dirty is still visible.
|
|
191
|
+
*
|
|
192
|
+
* Returns null when `cwd` is not inside a git work tree. A task's `files:`
|
|
193
|
+
* scope cannot be checked there, and guessing from a directory listing would
|
|
194
|
+
* be worse than saying nothing.
|
|
195
|
+
*/
|
|
196
|
+
export function gitChanges(cwd) {
|
|
197
|
+
const top = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
|
|
198
|
+
encoding: "utf8",
|
|
199
|
+
})
|
|
200
|
+
if (top.status !== 0) return null
|
|
201
|
+
const root = top.stdout.trim()
|
|
202
|
+
const res = spawnSync("git", ["-C", cwd, "status", "--porcelain", "-z"], {
|
|
203
|
+
encoding: "utf8",
|
|
204
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
205
|
+
})
|
|
206
|
+
if (res.status !== 0) return null
|
|
207
|
+
|
|
208
|
+
const files = new Map()
|
|
209
|
+
const entries = res.stdout.split("\0")
|
|
210
|
+
for (let i = 0; i < entries.length; i++) {
|
|
211
|
+
const entry = entries[i]
|
|
212
|
+
if (!entry) continue
|
|
213
|
+
const code = entry.slice(0, 2)
|
|
214
|
+
// Renames and copies carry the old path in the next NUL entry. The new
|
|
215
|
+
// path is the one the task is judged on.
|
|
216
|
+
if (code.includes("R") || code.includes("C")) i++
|
|
217
|
+
const path = entry.slice(3)
|
|
218
|
+
if (!path) continue
|
|
219
|
+
let mtimeMs = 0
|
|
220
|
+
try {
|
|
221
|
+
mtimeMs = statSync(join(root, path)).mtimeMs
|
|
222
|
+
} catch {
|
|
223
|
+
// A deleted path has no mtime; its status is the whole signal.
|
|
224
|
+
}
|
|
225
|
+
files.set(path, { code, mtimeMs })
|
|
226
|
+
}
|
|
227
|
+
return { root, files }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The path-shaped tokens in a task's `files:` line.
|
|
232
|
+
*
|
|
233
|
+
* `files:` is written for a human, so a template placeholder like `<exact
|
|
234
|
+
* paths to change>` must not be read as a filename. A token counts only when
|
|
235
|
+
* it looks like a path: it names a directory (`src/`) or carries a file
|
|
236
|
+
* extension (`run.mjs`). Prose without either is ignored, which turns the
|
|
237
|
+
* scope check off rather than inventing a scope from words.
|
|
238
|
+
*/
|
|
239
|
+
export function namedPaths(files) {
|
|
240
|
+
return String(files ?? "")
|
|
241
|
+
.split(/[,;\n]/)
|
|
242
|
+
.flatMap((part) => part.trim().split(/\s+/))
|
|
243
|
+
.map((part) => part.replace(/^`+|`+$/g, "").trim())
|
|
244
|
+
.filter((part) => part && (part.includes("/") || /\.[A-Za-z0-9]{1,8}$/.test(part)))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Files the agent touched that the task did not name.
|
|
249
|
+
*
|
|
250
|
+
* The status BEFORE the agent ran is the baseline: a repository that was
|
|
251
|
+
* already dirty must not blame the agent for changes it did not make. A file
|
|
252
|
+
* that was already dirty and changed again is caught by the mtime.
|
|
253
|
+
*/
|
|
254
|
+
export function outOfScope(task, cwd, before) {
|
|
255
|
+
const named = namedPaths(task.files)
|
|
256
|
+
if (!named.length || !before) return null
|
|
257
|
+
const after = gitChanges(cwd)
|
|
258
|
+
if (!after) return null
|
|
259
|
+
|
|
260
|
+
const moved = [...after.files]
|
|
261
|
+
.filter(([path, now]) => {
|
|
262
|
+
const was = before.files.get(path)
|
|
263
|
+
return !was || was.code !== now.code || was.mtimeMs !== now.mtimeMs
|
|
264
|
+
})
|
|
265
|
+
.map(([path]) => path)
|
|
266
|
+
// `.space/` is the loop's own bookkeeping: the lock, the task file it
|
|
267
|
+
// writes back, the review verdicts, and the plugin's active/telemetry
|
|
268
|
+
// files. The plugin writes telemetry on every agent call, which changes
|
|
269
|
+
// the directory's mtime, so counting it would block every task that
|
|
270
|
+
// touches no code at all.
|
|
271
|
+
.filter((path) => path !== ".space" && !path.startsWith(".space/"))
|
|
272
|
+
if (!moved.length) return null
|
|
273
|
+
|
|
274
|
+
// Both sides go through the same root, and both are resolved the same way.
|
|
275
|
+
// `git rev-parse --show-toplevel` answers with a real path, while the caller
|
|
276
|
+
// may hand over a symlinked one: on macOS /var is /private/var, so a take
|
|
277
|
+
// under /var/folders compared `src/a.js` against
|
|
278
|
+
// ../../../var/folders/.../src/a.js and called every edit out of scope.
|
|
279
|
+
const base = realpathSync(cwd)
|
|
280
|
+
const root = realpathSync(after.root)
|
|
281
|
+
const allowed = named.map((path) => relative(root, resolve(base, path)).split(sep).join("/"))
|
|
282
|
+
const outside = moved.filter(
|
|
283
|
+
(path) => !allowed.some((a) => path === a || path.startsWith(`${a}/`)),
|
|
284
|
+
)
|
|
285
|
+
return outside.length ? { outside, allowed, moved } : null
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The reviewer's final words, from `opencode run --format json`.
|
|
290
|
+
*
|
|
291
|
+
* `--format json` emits one JSON event per line. Only the LAST text part is
|
|
292
|
+
* the answer: a session that says "let me look" before it looks has an earlier
|
|
293
|
+
* text part too, and taking the whole transcript would make the verdict a
|
|
294
|
+
* needle in a haystack. The answer is the last thing the agent said.
|
|
295
|
+
*/
|
|
296
|
+
export function agentText(stdout) {
|
|
297
|
+
let last = ""
|
|
298
|
+
for (const line of String(stdout ?? "").split("\n")) {
|
|
299
|
+
const text = line.trim()
|
|
300
|
+
if (!text.startsWith("{")) continue
|
|
301
|
+
try {
|
|
302
|
+
const event = JSON.parse(text)
|
|
303
|
+
if (event?.type === "text" && typeof event.part?.text === "string") {
|
|
304
|
+
last = event.part.text
|
|
305
|
+
}
|
|
306
|
+
} catch {
|
|
307
|
+
// Not every line of stdout is ours. A log line on stdout is noise, not
|
|
308
|
+
// a verdict, and the caller fails closed when no text part was seen.
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return last
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The only schema `--review` accepts. Bump the version to change it. */
|
|
315
|
+
export const REVIEW_SCHEMA = "oc-harness/review@1"
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Validate a reviewer's verdict.
|
|
319
|
+
*
|
|
320
|
+
* Deliberately strict: prose is not a verdict, a verdict with no blockers when
|
|
321
|
+
* it says fail is not a verdict, and a pass that carries blockers contradicts
|
|
322
|
+
* itself. A malformed report is a failed review, never a pass by default.
|
|
323
|
+
*/
|
|
324
|
+
export function parseVerdict(text) {
|
|
325
|
+
let raw = String(text ?? "").trim()
|
|
326
|
+
if (!raw) return { ok: false, error: "reviewer returned no verdict" }
|
|
327
|
+
|
|
328
|
+
// A fenced block is a container, not prose. Anything around it is prose,
|
|
329
|
+
// and accepting "here is my verdict: {...}" would be parsing prose by
|
|
330
|
+
// another name.
|
|
331
|
+
const fence = raw.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/i)
|
|
332
|
+
if (fence) raw = fence[1].trim()
|
|
333
|
+
|
|
334
|
+
let value
|
|
335
|
+
try {
|
|
336
|
+
value = JSON.parse(raw)
|
|
337
|
+
} catch {
|
|
338
|
+
return { ok: false, error: "reviewer did not return a JSON verdict" }
|
|
339
|
+
}
|
|
340
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
341
|
+
return { ok: false, error: "review verdict is not an object" }
|
|
342
|
+
}
|
|
343
|
+
if (value.schema !== REVIEW_SCHEMA) {
|
|
344
|
+
return { ok: false, error: `review verdict schema is not ${REVIEW_SCHEMA}` }
|
|
345
|
+
}
|
|
346
|
+
if (value.verdict !== "pass" && value.verdict !== "fail") {
|
|
347
|
+
return { ok: false, error: "review verdict must be pass or fail" }
|
|
348
|
+
}
|
|
349
|
+
if (!Array.isArray(value.blockers)) {
|
|
350
|
+
return { ok: false, error: "review verdict has no blockers array" }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const [i, blocker] of value.blockers.entries()) {
|
|
354
|
+
if (!blocker || typeof blocker !== "object" || Array.isArray(blocker)) {
|
|
355
|
+
return { ok: false, error: `blocker ${i} is not an object` }
|
|
356
|
+
}
|
|
357
|
+
if (typeof blocker.file !== "string" || !blocker.file) {
|
|
358
|
+
return { ok: false, error: `blocker ${i} has no file` }
|
|
359
|
+
}
|
|
360
|
+
if (!Number.isInteger(blocker.line) || blocker.line < 0) {
|
|
361
|
+
return { ok: false, error: `blocker ${i} has no line number` }
|
|
362
|
+
}
|
|
363
|
+
if (typeof blocker.issue !== "string" || !blocker.issue) {
|
|
364
|
+
return { ok: false, error: `blocker ${i} has no issue` }
|
|
365
|
+
}
|
|
366
|
+
if (typeof blocker.consequence !== "string" || !blocker.consequence) {
|
|
367
|
+
return { ok: false, error: `blocker ${i} has no consequence` }
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (value.verdict === "pass" && value.blockers.length > 0) {
|
|
372
|
+
return { ok: false, error: "a pass cannot carry blockers" }
|
|
373
|
+
}
|
|
374
|
+
if (value.verdict === "fail" && value.blockers.length === 0) {
|
|
375
|
+
return { ok: false, error: "a fail needs at least one blocker" }
|
|
376
|
+
}
|
|
377
|
+
if (value.notes !== undefined && typeof value.notes !== "string") {
|
|
378
|
+
return { ok: false, error: "review notes must be a string" }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
ok: true,
|
|
383
|
+
verdict: value.verdict,
|
|
384
|
+
blockers: value.blockers,
|
|
385
|
+
notes: value.notes ?? "",
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Human-readable blockers, for the task file and the terminal. */
|
|
390
|
+
export function formatBlockers(blockers) {
|
|
391
|
+
return blockers
|
|
392
|
+
.map((b) => ` - ${b.file}:${b.line} ${b.issue}\n because: ${b.consequence}`)
|
|
393
|
+
.join("\n")
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* What the reviewer reads: the diff, and the untracked files it must open
|
|
398
|
+
* itself. A diff alone cannot show a new file, and a reviewer that is not told
|
|
399
|
+
* that is a reviewer that passes work it never saw.
|
|
400
|
+
*/
|
|
401
|
+
export function reviewDiff(cwd, { maxBytes = 200_000 } = {}) {
|
|
402
|
+
const run = (args) => {
|
|
403
|
+
const res = spawnSync("git", ["-C", cwd, ...args], {
|
|
404
|
+
encoding: "utf8",
|
|
405
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
406
|
+
})
|
|
407
|
+
return res.status === 0 ? res.stdout : ""
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const tracked = run(["diff", "--no-color", "--no-ext-diff", "--", "."])
|
|
411
|
+
const staged = run(["diff", "--cached", "--no-color", "--no-ext-diff", "--", "."])
|
|
412
|
+
const changes = gitChanges(cwd)
|
|
413
|
+
const untracked = changes
|
|
414
|
+
? [...changes.files].filter(([, value]) => value.code === "??").map(([path]) => path)
|
|
415
|
+
: []
|
|
416
|
+
|
|
417
|
+
let diff = [tracked, staged].filter((part) => part.trim()).join("\n")
|
|
418
|
+
const truncated = Buffer.byteLength(diff) > maxBytes
|
|
419
|
+
if (truncated) diff = `${diff.slice(0, maxBytes)}\n[diff truncated at ${maxBytes} bytes]`
|
|
420
|
+
|
|
421
|
+
return { diff, untracked, truncated }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The review request. It asks for one JSON object and nothing else. */
|
|
425
|
+
export function buildReviewPrompt(task, { diff, untracked }, verifyOutput) {
|
|
426
|
+
return [
|
|
427
|
+
`You are the reviewer. Review exactly one task and stop. You never edit, fix or stage anything.`,
|
|
428
|
+
``,
|
|
429
|
+
`The task, as it was written:`,
|
|
430
|
+
taskPrompt(task),
|
|
431
|
+
``,
|
|
432
|
+
`The verify command already ran in the orchestrator and passed: ${task.verify}`,
|
|
433
|
+
`Its real output was:`,
|
|
434
|
+
verifyOutput || "(no output)",
|
|
435
|
+
``,
|
|
436
|
+
`Review the change against the task. Pass 1: does it do exactly what was asked, with no extra changes? Pass 2: defects that will bite next, each with a concrete consequence.`,
|
|
437
|
+
``,
|
|
438
|
+
`The diff (tracked and staged changes):`,
|
|
439
|
+
diff || "(no tracked diff)",
|
|
440
|
+
``,
|
|
441
|
+
untracked.length
|
|
442
|
+
? `These files are untracked and are NOT in the diff. Read them before judging:\n${untracked.map((p) => ` - ${p}`).join("\n")}`
|
|
443
|
+
: `There are no untracked files.`,
|
|
444
|
+
``,
|
|
445
|
+
`Return ONLY one JSON object, with no prose before or after it, matching this schema exactly:`,
|
|
446
|
+
`{`,
|
|
447
|
+
` "schema": "${REVIEW_SCHEMA}",`,
|
|
448
|
+
` "verdict": "pass" | "fail",`,
|
|
449
|
+
` "blockers": [`,
|
|
450
|
+
` { "file": "path/from/repo", "line": 1, "issue": "what is wrong", "consequence": "what breaks because of it" }`,
|
|
451
|
+
` ],`,
|
|
452
|
+
` "notes": "optional short note"`,
|
|
453
|
+
`}`,
|
|
454
|
+
``,
|
|
455
|
+
`Rules for the verdict:`,
|
|
456
|
+
` - pass means blockers is empty. fail means at least one blocker.`,
|
|
457
|
+
` - Every blocker needs a real file, a line number and a consequence. A preference is not a blocker; drop it or put it in notes.`,
|
|
458
|
+
` - If you cannot review the change, return fail with a blocker that says why.`,
|
|
459
|
+
` - Do not wrap the JSON in a sentence. Do not use a code fence. Output the object and nothing else.`,
|
|
460
|
+
].join("\n")
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Where the validated verdict is kept. A review that leaves no file is a
|
|
465
|
+
* review nobody can audit, and the next run cannot tell it ever happened.
|
|
466
|
+
*/
|
|
467
|
+
export function verdictPath(cwd, task, round) {
|
|
468
|
+
const slug = String(task.title)
|
|
469
|
+
.toLowerCase()
|
|
470
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
471
|
+
.replace(/^-+|-+$/g, "")
|
|
472
|
+
.slice(0, 60)
|
|
473
|
+
return join(cwd, ".space", "review", `${String(round).padStart(2, "0")}-${slug || "task"}.json`)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Record a verdict atomically, next to the plan it belongs to. */
|
|
477
|
+
export function writeVerdict(cwd, task, round, verdict, verify) {
|
|
478
|
+
const path = verdictPath(cwd, task, round)
|
|
479
|
+
const record = {
|
|
480
|
+
schema: REVIEW_SCHEMA,
|
|
481
|
+
at: new Date().toISOString(),
|
|
482
|
+
task: task.title,
|
|
483
|
+
round,
|
|
484
|
+
reviewer: "reviewer",
|
|
485
|
+
verify: { command: task.verify, exit: verify.status, seconds: verify.seconds },
|
|
486
|
+
verdict: verdict.verdict,
|
|
487
|
+
blockers: verdict.blockers,
|
|
488
|
+
notes: verdict.notes,
|
|
489
|
+
}
|
|
490
|
+
const tmp = `${path}.tmp`
|
|
491
|
+
try {
|
|
492
|
+
mkdirSync(join(cwd, ".space", "review"), { recursive: true })
|
|
493
|
+
writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`)
|
|
494
|
+
renameSync(tmp, path)
|
|
495
|
+
return { path, record }
|
|
496
|
+
} catch (err) {
|
|
497
|
+
try {
|
|
498
|
+
unlinkSync(tmp)
|
|
499
|
+
} catch {
|
|
500
|
+
// The temporary file may never have existed.
|
|
501
|
+
}
|
|
502
|
+
return { error: errorText(err) }
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Ask `opencode` to do the task. Returns the transcript so a human can read
|
|
508
|
+
* what the agent actually did rather than being told about it.
|
|
80
509
|
*/
|
|
81
510
|
export function invokeAgent(role, prompt, { cwd, timeoutMs }) {
|
|
82
511
|
// `--dir` is not optional, and neither is PWD.
|
|
@@ -97,12 +526,46 @@ export function invokeAgent(role, prompt, { cwd, timeoutMs }) {
|
|
|
97
526
|
env: { ...process.env, PWD: cwd },
|
|
98
527
|
},
|
|
99
528
|
)
|
|
529
|
+
const stdout = res.stdout ?? ""
|
|
100
530
|
return {
|
|
101
531
|
ok: res.status === 0,
|
|
102
532
|
status: res.status,
|
|
103
|
-
|
|
533
|
+
// stdout is kept apart from stderr because `--format json` is a protocol.
|
|
534
|
+
// A progress line on stderr is not a verdict, and merging the two streams
|
|
535
|
+
// would make it look like one.
|
|
536
|
+
stdout,
|
|
537
|
+
stderr: res.stderr ?? "",
|
|
538
|
+
output: `${stdout}${res.stderr ?? ""}`.trimEnd(),
|
|
104
539
|
error: res.error ? String(res.error.message) : "",
|
|
540
|
+
steps: countSteps(stdout),
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* How many model turns the transcript holds.
|
|
546
|
+
*
|
|
547
|
+
* `opencode run --format json` writes one JSON object per line; `step_finish`
|
|
548
|
+
* marks the end of a turn. The count is what tells a failed verify apart from
|
|
549
|
+
* a lane that spent its whole allowance and was forced to answer in text.
|
|
550
|
+
*
|
|
551
|
+
* A line that is not JSON is skipped rather than fatal: a wrapper that prints
|
|
552
|
+
* a banner on stdout must not make the count throw.
|
|
553
|
+
*
|
|
554
|
+
* @param {string} stdout
|
|
555
|
+
* @returns {number}
|
|
556
|
+
*/
|
|
557
|
+
export function countSteps(stdout) {
|
|
558
|
+
let steps = 0
|
|
559
|
+
for (const line of String(stdout ?? "").split("\n")) {
|
|
560
|
+
const text = line.trim()
|
|
561
|
+
if (!text) continue
|
|
562
|
+
try {
|
|
563
|
+
if (JSON.parse(text)?.type === "step_finish") steps++
|
|
564
|
+
} catch {
|
|
565
|
+
// Not an event line.
|
|
566
|
+
}
|
|
105
567
|
}
|
|
568
|
+
return steps
|
|
106
569
|
}
|
|
107
570
|
|
|
108
571
|
/** The agent's own instructions for one task, plus what the loop expects back. */
|
|
@@ -123,6 +586,43 @@ export function buildPrompt(task) {
|
|
|
123
586
|
].join("\n")
|
|
124
587
|
}
|
|
125
588
|
|
|
589
|
+
/**
|
|
590
|
+
* Find the task we were working on in a freshly parsed file.
|
|
591
|
+
*
|
|
592
|
+
* A title alone is not an identity: two tasks can share one, and the ordinal
|
|
593
|
+
* among same-titled tasks shifts when a duplicate before ours is removed or
|
|
594
|
+
* renamed. Matching title + change + files is stable across edits to status
|
|
595
|
+
* and output, which are the only fields the loop writes.
|
|
596
|
+
*
|
|
597
|
+
* When more than one task still fits, there is no honest answer. Returning
|
|
598
|
+
* `null` stops the run; guessing would mark the wrong task done.
|
|
599
|
+
*
|
|
600
|
+
* @param {import("../tasks.js").TaskFile} parsed
|
|
601
|
+
* @param {import("../tasks.js").Task} task
|
|
602
|
+
* @returns {import("../tasks.js").Task | null}
|
|
603
|
+
*/
|
|
604
|
+
export function findTask(parsed, task) {
|
|
605
|
+
const candidates = parsed.tasks.filter((t) => t.title === task.title)
|
|
606
|
+
if (candidates.length === 0) return null
|
|
607
|
+
|
|
608
|
+
// Two same-titled tasks are told apart by what they say they change. The
|
|
609
|
+
// match is checked even when only one candidate is left: if that one has
|
|
610
|
+
// different content, it is a different task and the one we were running is
|
|
611
|
+
// gone.
|
|
612
|
+
const narrowed = candidates.filter(
|
|
613
|
+
(t) => t.change === task.change && t.files === task.files,
|
|
614
|
+
)
|
|
615
|
+
if (narrowed.length === 1) return narrowed[0]
|
|
616
|
+
|
|
617
|
+
// A bare title with no change or files is the only case where nothing else
|
|
618
|
+
// identifies the task, so a sole candidate is the best available answer.
|
|
619
|
+
if (candidates.length === 1 && !task.change && !task.files) {
|
|
620
|
+
const [only] = candidates
|
|
621
|
+
return !only.change && !only.files ? only : null
|
|
622
|
+
}
|
|
623
|
+
return null
|
|
624
|
+
}
|
|
625
|
+
|
|
126
626
|
/**
|
|
127
627
|
* Run the loop.
|
|
128
628
|
*
|
|
@@ -140,7 +640,13 @@ export function runLoop({
|
|
|
140
640
|
role = "build",
|
|
141
641
|
maxRounds = 20,
|
|
142
642
|
budgetMinutes = 120,
|
|
643
|
+
allowExtraFiles = false,
|
|
644
|
+
forceUnlock = false,
|
|
645
|
+
/** The role's configured step cap, or 0 when it is unknown. */
|
|
646
|
+
stepCap = 0,
|
|
647
|
+
review = false,
|
|
143
648
|
runAgent = invokeAgent,
|
|
649
|
+
runReviewer = invokeAgent,
|
|
144
650
|
runVerify = sh,
|
|
145
651
|
} = {}) {
|
|
146
652
|
const started = Date.now()
|
|
@@ -157,6 +663,21 @@ export function runLoop({
|
|
|
157
663
|
let text = readFileSync(join(cwd, TASKS_PATH), "utf8")
|
|
158
664
|
let rounds = 0
|
|
159
665
|
|
|
666
|
+
// One writer per task file. The lock is taken before the first read of the
|
|
667
|
+
// file and released on EVERY exit, including a throw from a custom runner.
|
|
668
|
+
const lock = acquireLock(cwd, { force: forceUnlock })
|
|
669
|
+
if (lock.busy) {
|
|
670
|
+
return stop(log, {
|
|
671
|
+
reason: `another run is already in progress in this project (pid ${lock.busy.pid}). Wait for it, or remove ${LOCK_PATH} if it is gone.`,
|
|
672
|
+
code: 2,
|
|
673
|
+
})
|
|
674
|
+
}
|
|
675
|
+
if (lock.error) {
|
|
676
|
+
return stop(log, { reason: lock.error, code: 2 })
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
try {
|
|
680
|
+
|
|
160
681
|
/**
|
|
161
682
|
* The task file, read fresh, and the task we were working on inside it.
|
|
162
683
|
*
|
|
@@ -174,16 +695,78 @@ export function runLoop({
|
|
|
174
695
|
try {
|
|
175
696
|
fresh = readFileSync(join(cwd, TASKS_PATH), "utf8")
|
|
176
697
|
} catch {
|
|
177
|
-
|
|
698
|
+
// The file is gone. Writing the copy we read at the start would recreate
|
|
699
|
+
// it from stale state, including any task the agent or a human removed.
|
|
700
|
+
return null
|
|
178
701
|
}
|
|
179
702
|
const parsed = parseTasks(fresh)
|
|
180
|
-
const same = parsed
|
|
181
|
-
if (!same) return
|
|
703
|
+
const same = findTask(parsed, task)
|
|
704
|
+
if (!same) return null
|
|
182
705
|
return { text: fresh, task: same }
|
|
183
706
|
}
|
|
184
707
|
|
|
708
|
+
/**
|
|
709
|
+
* Apply an outcome to the CURRENT file and write it back atomically.
|
|
710
|
+
*
|
|
711
|
+
* Returns false when the file changed underneath us: the caller must stop
|
|
712
|
+
* rather than write a stale list. The write goes through a temporary file so
|
|
713
|
+
* a reader never sees half a task file, and so a crash cannot leave a
|
|
714
|
+
* truncated plan behind.
|
|
715
|
+
*/
|
|
716
|
+
const writeBack = (task, outcome) => {
|
|
717
|
+
const fresh = reload(task)
|
|
718
|
+
if (!fresh) return false
|
|
719
|
+
text = markTask(fresh.text, fresh.task, outcome)
|
|
720
|
+
const target = join(cwd, TASKS_PATH)
|
|
721
|
+
const tmp = `${target}.tmp`
|
|
722
|
+
try {
|
|
723
|
+
writeFileSync(tmp, text)
|
|
724
|
+
renameSync(tmp, target)
|
|
725
|
+
} catch {
|
|
726
|
+
try {
|
|
727
|
+
unlinkSync(tmp)
|
|
728
|
+
} catch {
|
|
729
|
+
// There is nothing to clean up if the temporary file was never made.
|
|
730
|
+
}
|
|
731
|
+
return false
|
|
732
|
+
}
|
|
733
|
+
return true
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const changedUnderneath = (task) =>
|
|
737
|
+
stop(log, {
|
|
738
|
+
reason: `the task file changed or disappeared while "${task.title}" was in flight. Not writing a stale task list -- check ${TASKS_PATH} and run again.`,
|
|
739
|
+
code: 2,
|
|
740
|
+
rounds,
|
|
741
|
+
})
|
|
742
|
+
|
|
185
743
|
for (;;) {
|
|
186
744
|
const parsed = parseTasks(text)
|
|
745
|
+
|
|
746
|
+
// An empty plan is not a finished plan. It used to fall through to
|
|
747
|
+
// `nextPending()`, find nothing, and print "every task is done (0/0)" --
|
|
748
|
+
// a green result for a file that holds no tasks at all.
|
|
749
|
+
if (parsed.tasks.length === 0) {
|
|
750
|
+
return stop(log, {
|
|
751
|
+
reason: `no tasks found in ${TASKS_PATH}. Run space-plan first -- this loop needs a plan to walk.`,
|
|
752
|
+
code: 2,
|
|
753
|
+
rounds,
|
|
754
|
+
})
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Work is done in file order. `nextPending()` skips blocked tasks, so
|
|
758
|
+
// checking it alone ran every task AFTER a blocker and only reported the
|
|
759
|
+
// blocker at the end. Find the first task that is not done and stop if it
|
|
760
|
+
// is blocked; pending work before it still runs.
|
|
761
|
+
const firstOpen = parsed.tasks.find((t) => !isDone(t))
|
|
762
|
+
if (firstOpen && isBlocked(firstOpen)) {
|
|
763
|
+
return stop(log, {
|
|
764
|
+
reason: `task is blocked and needs a person: ${firstOpen.title}`,
|
|
765
|
+
code: 2,
|
|
766
|
+
rounds,
|
|
767
|
+
})
|
|
768
|
+
}
|
|
769
|
+
|
|
187
770
|
const task = nextPending(parsed)
|
|
188
771
|
|
|
189
772
|
if (!task) {
|
|
@@ -199,17 +782,12 @@ export function runLoop({
|
|
|
199
782
|
})
|
|
200
783
|
}
|
|
201
784
|
|
|
202
|
-
if (isBlocked(task)) {
|
|
203
|
-
return stop(log, { reason: `task is blocked and needs a person: ${task.title}`, code: 2, rounds })
|
|
204
|
-
}
|
|
205
|
-
|
|
206
785
|
if (!task.verify) {
|
|
207
|
-
const
|
|
208
|
-
text = markTask(fresh.text, fresh.task, {
|
|
786
|
+
const wrote = writeBack(task, {
|
|
209
787
|
status: "blocked",
|
|
210
788
|
verifyOutput: "blocked by the loop: this task has no verify command, so nothing could prove it finished.",
|
|
211
789
|
})
|
|
212
|
-
|
|
790
|
+
if (!wrote) return changedUnderneath(task)
|
|
213
791
|
return stop(log, {
|
|
214
792
|
reason: `"${task.title}" has no verify command. It cannot be checked, so it cannot be run unattended. Add one, or do it yourself.`,
|
|
215
793
|
code: 2,
|
|
@@ -217,6 +795,22 @@ export function runLoop({
|
|
|
217
795
|
})
|
|
218
796
|
}
|
|
219
797
|
|
|
798
|
+
// `verify: true` and `verify: echo ok` exit 0 whether the change landed or
|
|
799
|
+
// not. A command that cannot fail is not a check, and running it would
|
|
800
|
+
// mark the task done on no evidence at all.
|
|
801
|
+
if (isVacuousVerify(task.verify)) {
|
|
802
|
+
const wrote = writeBack(task, {
|
|
803
|
+
status: "blocked",
|
|
804
|
+
verifyOutput: `blocked by the loop: "${task.verify}" cannot fail, so it proves nothing.`,
|
|
805
|
+
})
|
|
806
|
+
if (!wrote) return changedUnderneath(task)
|
|
807
|
+
return stop(log, {
|
|
808
|
+
reason: `"${task.title}" has a verify command that cannot fail ("${task.verify}"). Replace it with a command that fails when the change is wrong.`,
|
|
809
|
+
code: 2,
|
|
810
|
+
rounds,
|
|
811
|
+
})
|
|
812
|
+
}
|
|
813
|
+
|
|
220
814
|
if (rounds >= maxRounds) {
|
|
221
815
|
return stop(log, { reason: `stopped at the round cap (${maxRounds}). Raise it with --max-rounds, or run fewer tasks.`, code: 2, rounds })
|
|
222
816
|
}
|
|
@@ -234,25 +828,90 @@ export function runLoop({
|
|
|
234
828
|
console.log(`\n ${bold(`[${rounds}]`)} ${task.title}`)
|
|
235
829
|
if (task.files) console.log(` ${stamp(task.files)}`)
|
|
236
830
|
|
|
831
|
+
// What the tree looked like before the agent touched it. The scope check
|
|
832
|
+
// compares against this, so a repository that was already dirty does not
|
|
833
|
+
// blame the agent for someone else's changes.
|
|
834
|
+
const beforeAgent = allowExtraFiles ? null : gitChanges(cwd)
|
|
835
|
+
|
|
836
|
+
const agentTimeoutMs = remaining()
|
|
237
837
|
const agent = runAgent(role, buildPrompt(task), {
|
|
238
838
|
cwd,
|
|
239
|
-
timeoutMs:
|
|
839
|
+
timeoutMs: agentTimeoutMs,
|
|
240
840
|
})
|
|
241
841
|
if (!agent.ok) {
|
|
242
842
|
const why = agent.error || agent.output || `exit ${agent.status}`
|
|
243
|
-
|
|
244
|
-
|
|
843
|
+
// A route that never answers is a timeout, not a failed edit: it hangs
|
|
844
|
+
// until the ceiling kills it. The ceiling is the budget, so at the
|
|
845
|
+
// default that is two hours spent on one task with nothing to show for
|
|
846
|
+
// it, and the stop message is the only place that says why.
|
|
847
|
+
const timedOut = /ETIMEDOUT|timed out/i.test(String(agent.error ?? ""))
|
|
848
|
+
const limit =
|
|
849
|
+
agentTimeoutMs >= 60_000
|
|
850
|
+
? `${Math.round(agentTimeoutMs / 60_000)} min`
|
|
851
|
+
: `${Math.round(agentTimeoutMs / 1000)}s`
|
|
852
|
+
const capNote = timedOut
|
|
853
|
+
? `The agent did not answer within the ${limit} the budget allowed. ` +
|
|
854
|
+
`That is what a spent or unreachable model route looks like: check it with \`opencode run --print-logs -m <model> hi\`, ` +
|
|
855
|
+
`and lower --budget if you would rather fail sooner.`
|
|
856
|
+
: ""
|
|
857
|
+
const wrote = writeBack(task, {
|
|
245
858
|
status: "blocked",
|
|
246
|
-
verifyOutput: `blocked by the loop: the ${role} agent failed.\n${tail(why)}`,
|
|
859
|
+
verifyOutput: `blocked by the loop: the ${role} agent failed.\n${tail(why)}${capNote ? `\n\n${capNote}` : ""}`,
|
|
247
860
|
})
|
|
248
|
-
|
|
861
|
+
if (!wrote) return changedUnderneath(task)
|
|
249
862
|
return stop(log, {
|
|
250
|
-
reason:
|
|
863
|
+
reason: timedOut
|
|
864
|
+
? `the ${role} agent timed out on "${task.title}". ${capNote}`
|
|
865
|
+
: `the ${role} agent failed on "${task.title}": ${tail(why, 3).split("\n")[0]}`,
|
|
866
|
+
code: 3,
|
|
867
|
+
rounds,
|
|
868
|
+
})
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Exit 0 with nothing at all on stdout is not an agent that chose to do
|
|
872
|
+
// nothing: it is the shape of a route that never answered. A spent free
|
|
873
|
+
// route makes `opencode run` print the provider's reason on stderr (only
|
|
874
|
+
// with --print-logs) and exit 0, so without this the loop runs the verify
|
|
875
|
+
// against an untouched tree and blocks the task for a change the model
|
|
876
|
+
// never attempted. `stdout` is checked rather than `steps` because a
|
|
877
|
+
// custom runner in a test has no event stream to count.
|
|
878
|
+
if (agent.ok && typeof agent.stdout === "string" && agent.stdout.trim() === "") {
|
|
879
|
+
const why = agent.stderr || agent.error || "(nothing on stderr either)"
|
|
880
|
+
const wrote = writeBack(task, {
|
|
881
|
+
status: "blocked",
|
|
882
|
+
verifyOutput: `blocked by the loop: the ${role} agent exited 0 without producing a word.\n${tail(why)}`,
|
|
883
|
+
})
|
|
884
|
+
if (!wrote) return changedUnderneath(task)
|
|
885
|
+
return stop(log, {
|
|
886
|
+
reason:
|
|
887
|
+
`the ${role} agent answered nothing on "${task.title}". The model route is probably spent or unreachable; ` +
|
|
888
|
+
`run \`opencode run --print-logs -m <model> hi\` to see the provider's reason. Nothing was verified, so no task was scored.`,
|
|
251
889
|
code: 3,
|
|
252
890
|
rounds,
|
|
253
891
|
})
|
|
254
892
|
}
|
|
255
893
|
|
|
894
|
+
// A task names the files it may touch. An agent that edits outside that
|
|
895
|
+
// list has gone beyond the plan, and an unattended loop that lets that
|
|
896
|
+
// pass is an unattended loop that can rewrite the repository one task at
|
|
897
|
+
// a time. `--allow-extra-files` is the deliberate escape hatch.
|
|
898
|
+
if (!allowExtraFiles) {
|
|
899
|
+
const scope = outOfScope(task, cwd, beforeAgent)
|
|
900
|
+
if (scope) {
|
|
901
|
+
const listed = scope.outside.map((p) => ` - ${p}`).join("\n")
|
|
902
|
+
const wrote = writeBack(task, {
|
|
903
|
+
status: "blocked",
|
|
904
|
+
verifyOutput: `blocked by the loop: the agent changed files the task did not name.\n${listed}`,
|
|
905
|
+
})
|
|
906
|
+
if (!wrote) return changedUnderneath(task)
|
|
907
|
+
return stop(log, {
|
|
908
|
+
reason: `"${task.title}" changed files outside its scope:\n${listed}\nAdd them to the task's files: line, or rerun with --allow-extra-files.`,
|
|
909
|
+
code: 2,
|
|
910
|
+
rounds,
|
|
911
|
+
})
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
256
915
|
// The agent's claim is not the evidence. Re-run the verify here, because a
|
|
257
916
|
// loop that trusts the agent's own word about whether it passed is a loop
|
|
258
917
|
// that reports success it never checked.
|
|
@@ -261,27 +920,106 @@ export function runLoop({
|
|
|
261
920
|
log.push({ round: rounds, task: task.title, ok: check.ok, seconds: check.seconds })
|
|
262
921
|
|
|
263
922
|
if (!check.ok) {
|
|
264
|
-
|
|
265
|
-
|
|
923
|
+
// A lane at its step cap is forced into a text-only answer. opencode
|
|
924
|
+
// exits 0, so without this the run reports a plain verify failure and
|
|
925
|
+
// the actual cause - a cap nobody looked at - stays in the log.
|
|
926
|
+
const capped = stepCap > 0 && (agent.steps ?? 0) >= stepCap
|
|
927
|
+
const capNote = capped
|
|
928
|
+
? `the agent used its whole ${stepCap}-step allowance before the check ran. Raise agent.${role}.steps, or split the task.`
|
|
929
|
+
: ""
|
|
930
|
+
const wrote = writeBack(task, {
|
|
266
931
|
status: "blocked",
|
|
267
|
-
verifyOutput: `verify failed (exit ${check.status}, ${check.seconds}s)\n${tail(check.output)}`,
|
|
932
|
+
verifyOutput: `verify failed (exit ${check.status}, ${check.seconds}s)\n${tail(check.output)}${capNote ? `\n\n${capNote}` : ""}`,
|
|
268
933
|
})
|
|
269
|
-
|
|
934
|
+
if (!wrote) return changedUnderneath(task)
|
|
270
935
|
return stop(log, {
|
|
271
|
-
reason:
|
|
936
|
+
reason:
|
|
937
|
+
`verify failed on "${task.title}". Marked blocked -- the real output is in ${TASKS_PATH}.` +
|
|
938
|
+
(capNote ? `\n ${capNote}` : ""),
|
|
272
939
|
code: 4,
|
|
273
940
|
rounds,
|
|
274
941
|
})
|
|
275
942
|
}
|
|
276
943
|
|
|
277
|
-
|
|
278
|
-
|
|
944
|
+
let reviewNote = ""
|
|
945
|
+
if (review) {
|
|
946
|
+
// A separate lane reviews, and it is asked for one JSON object. The
|
|
947
|
+
// verdict is validated here rather than trusted, and the file it is
|
|
948
|
+
// written to is the record a human can audit afterwards. The reviewer
|
|
949
|
+
// cannot edit, so the orchestrator is the one that persists the verdict.
|
|
950
|
+
console.log(` ${stamp("review: reviewer")}`)
|
|
951
|
+
const diff = reviewDiff(cwd)
|
|
952
|
+
const reviewed = runReviewer("reviewer", buildReviewPrompt(task, diff, check.output), {
|
|
953
|
+
cwd,
|
|
954
|
+
timeoutMs: remaining(),
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
if (!reviewed.ok) {
|
|
958
|
+
const why = reviewed.error || reviewed.output || `exit ${reviewed.status}`
|
|
959
|
+
const wrote = writeBack(task, {
|
|
960
|
+
status: "blocked",
|
|
961
|
+
verifyOutput: `review failed: the reviewer agent failed.\n${tail(why)}`,
|
|
962
|
+
})
|
|
963
|
+
if (!wrote) return changedUnderneath(task)
|
|
964
|
+
return stop(log, {
|
|
965
|
+
reason: `the reviewer agent failed on "${task.title}": ${tail(why, 3).split("\n")[0]}`,
|
|
966
|
+
code: 5,
|
|
967
|
+
rounds,
|
|
968
|
+
})
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const verdict = parseVerdict(agentText(reviewed.stdout))
|
|
972
|
+
if (!verdict.ok) {
|
|
973
|
+
const said = tail(agentText(reviewed.stdout) || reviewed.stdout || reviewed.output)
|
|
974
|
+
const wrote = writeBack(task, {
|
|
975
|
+
status: "blocked",
|
|
976
|
+
verifyOutput: `review failed: ${verdict.error}\n${said}`,
|
|
977
|
+
})
|
|
978
|
+
if (!wrote) return changedUnderneath(task)
|
|
979
|
+
return stop(log, {
|
|
980
|
+
reason: `the reviewer did not return a schema'd verdict on "${task.title}": ${verdict.error}`,
|
|
981
|
+
code: 5,
|
|
982
|
+
rounds,
|
|
983
|
+
})
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const saved = writeVerdict(cwd, task, rounds, verdict, check)
|
|
987
|
+
if (saved.error) {
|
|
988
|
+
return stop(log, {
|
|
989
|
+
reason: `the review verdict could not be written: ${saved.error}`,
|
|
990
|
+
code: 5,
|
|
991
|
+
rounds,
|
|
992
|
+
})
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (verdict.verdict === "fail") {
|
|
996
|
+
const blockers = formatBlockers(verdict.blockers)
|
|
997
|
+
const wrote = writeBack(task, {
|
|
998
|
+
status: "blocked",
|
|
999
|
+
verifyOutput: `review failed (${relative(cwd, saved.path)})\n${blockers}`,
|
|
1000
|
+
})
|
|
1001
|
+
if (!wrote) return changedUnderneath(task)
|
|
1002
|
+
return stop(log, {
|
|
1003
|
+
reason: `review failed on "${task.title}":\n${blockers}\nThe verdict is in ${relative(cwd, saved.path)}.`,
|
|
1004
|
+
code: 5,
|
|
1005
|
+
rounds,
|
|
1006
|
+
})
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
reviewNote = `\nreview: pass (${relative(cwd, saved.path)})`
|
|
1010
|
+
console.log(` ${green("reviewed")} ${stamp(relative(cwd, saved.path))}`)
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const wrote = writeBack(task, {
|
|
279
1014
|
status: "done",
|
|
280
|
-
verifyOutput: `exit ${check.status} in ${check.seconds}s\n${tail(check.output)}`,
|
|
1015
|
+
verifyOutput: `exit ${check.status} in ${check.seconds}s\n${tail(check.output)}${reviewNote}`,
|
|
281
1016
|
})
|
|
282
|
-
|
|
1017
|
+
if (!wrote) return changedUnderneath(task)
|
|
283
1018
|
console.log(` ${green("done")} ${stamp(`${check.seconds}s`)}`)
|
|
284
1019
|
}
|
|
1020
|
+
} finally {
|
|
1021
|
+
lock.release()
|
|
1022
|
+
}
|
|
285
1023
|
}
|
|
286
1024
|
|
|
287
1025
|
/**
|