cheap-agent 0.0.0-stage → 1.0.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/LICENSE +95 -0
- package/README.md +151 -2
- package/dist/allow.js +53 -0
- package/dist/cleanup.js +177 -0
- package/dist/config.js +133 -0
- package/dist/consent.js +129 -0
- package/dist/credentials.js +37 -0
- package/dist/hook.js +263 -0
- package/dist/index.js +16 -0
- package/dist/install.js +74 -0
- package/dist/jobs.js +1170 -0
- package/dist/login.js +223 -0
- package/dist/opencode.js +258 -0
- package/dist/permissions.js +122 -0
- package/dist/plan.js +117 -0
- package/dist/platform.js +52 -0
- package/dist/report.js +178 -0
- package/dist/research.js +264 -0
- package/dist/routing.js +212 -0
- package/dist/rules.js +25 -0
- package/dist/server.js +203 -0
- package/dist/status.js +105 -0
- package/dist/verify.js +121 -0
- package/dist/worker.js +237 -0
- package/dist/workspace.js +278 -0
- package/package.json +53 -4
- package/plugin/.claude-plugin/plugin.json +8 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/README.md +38 -0
- package/plugin/hooks/hooks.json +40 -0
- package/plugin/skills/allow/SKILL.md +11 -0
- package/plugin/skills/clean/SKILL.md +11 -0
- package/plugin/skills/hook/SKILL.md +11 -0
- package/plugin/skills/plan-writing/SKILL.md +72 -0
- package/plugin/skills/status/SKILL.md +9 -0
package/dist/jobs.js
ADDED
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
4
|
+
import { redact } from "./credentials.js";
|
|
5
|
+
import { holdOpencode } from "./opencode.js";
|
|
6
|
+
import { needsOf, overlap, parsePlan, PLAN_FORMAT, selectGroups } from "./plan.js";
|
|
7
|
+
import { failBlock, renderBatch, renderJob } from "./report.js";
|
|
8
|
+
import { loadConfig } from "./config.js";
|
|
9
|
+
import { projectScripts, workerRuleset } from "./permissions.js";
|
|
10
|
+
import { consentGate } from "./consent.js";
|
|
11
|
+
import { chain, DIFFICULTIES, escalationSkip, modelName, recordSpend, runRouted, tierModel, usedBalance } from "./routing.js";
|
|
12
|
+
import { errorTail, runVerify, sameFailure, verifyCommands } from "./verify.js";
|
|
13
|
+
import { finishReport, openSession } from "./worker.js";
|
|
14
|
+
import { conflictPaths, createWorktree, git, jobWorktree, land, leftoverMarkers, operationInProgress, projectRoot, reapply, removeWorktree, withLock, worktreeDiff, } from "./workspace.js";
|
|
15
|
+
import { isAlive, normalisePath } from "./platform.js";
|
|
16
|
+
/** Jobs: runs each group of plan steps as one job, start to landing, under per-job caps (several groups make a batch); lists, shows and cancels jobs. */
|
|
17
|
+
/**
|
|
18
|
+
* The worker session's ruleset for this job. The step's own done-when checks are allowed exactly as written, so the
|
|
19
|
+
* worker can check its work before finishing (Cheap Agent runs them anyway); the hard denylist still comes last.
|
|
20
|
+
*/
|
|
21
|
+
function rulesetFor(worktree, cmds, config) {
|
|
22
|
+
const { scripts, exact } = projectScripts(worktree.path);
|
|
23
|
+
return workerRuleset({ scripts, exact: [...exact, ...cmds], allowCommands: config.allow_commands, linked: worktree.linked });
|
|
24
|
+
}
|
|
25
|
+
/** More rejections than this in one job earn a risk line. */
|
|
26
|
+
const MAX_QUIET_REJECTIONS = 5;
|
|
27
|
+
/** A brief longer than this is cut in the worker's prompt. */
|
|
28
|
+
const MAX_BRIEF = 6_000;
|
|
29
|
+
/** A source longer than this is cut in the worker's prompt (the issue's head is what matters; comment threads trail). */
|
|
30
|
+
const MAX_SOURCE = 8000;
|
|
31
|
+
/** `source=`: the text itself, or a repo-relative file holding it (the benchmark writes the issue to one; a user may point at a ticket export). */
|
|
32
|
+
function resolveSource(root, source) {
|
|
33
|
+
const value = source?.trim();
|
|
34
|
+
if (!value)
|
|
35
|
+
return undefined;
|
|
36
|
+
const file = join(root, value);
|
|
37
|
+
const text = !value.includes("\n") && value.length < 512 && !isAbsolute(value) && existsSync(file) ? readFileSync(file, "utf8").trim() : value;
|
|
38
|
+
return text.length > MAX_SOURCE ? `${text.slice(0, MAX_SOURCE)}\n[… cut at ${MAX_SOURCE} characters]` : text;
|
|
39
|
+
}
|
|
40
|
+
/** `brief=`: a research id (its block from the log) or the brief's text itself. */
|
|
41
|
+
function resolveBrief(root, brief) {
|
|
42
|
+
const value = brief?.trim();
|
|
43
|
+
if (!value)
|
|
44
|
+
return {};
|
|
45
|
+
if (!/^r[0-9a-z]{4}$/.test(value))
|
|
46
|
+
return { text: value.slice(0, MAX_BRIEF) };
|
|
47
|
+
const file = join(root, ".cheapag", "jobs", value, "result.txt");
|
|
48
|
+
if (!existsSync(file))
|
|
49
|
+
return { fail: `brief ${value}: no research with that id (cleaned up?); pass the brief's text instead` };
|
|
50
|
+
return { text: readFileSync(file, "utf8").trim().slice(0, MAX_BRIEF) };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* An inline task becomes a one-step plan under .cheapag/plans/ so everything downstream (records, integration checks,
|
|
54
|
+
* re-delegation) sees an ordinary plan file. Without done_when, verification falls back to the repo's test_cmd.
|
|
55
|
+
* shortcut: these plan files are never cleaned up; fold them into the job-log retention if they pile up.
|
|
56
|
+
*/
|
|
57
|
+
function inlinePlan(root, req) {
|
|
58
|
+
const task = req.task.trim();
|
|
59
|
+
if (!task)
|
|
60
|
+
return { fail: "task is empty: say what to change, in which files, and how to check it" };
|
|
61
|
+
if (req.plan || req.steps)
|
|
62
|
+
return { fail: "give either task, or plan and steps, not both" };
|
|
63
|
+
const indent = (text) => text.split(/\r?\n/).map((l, i) => (i ? " " + l : l)).join("\n");
|
|
64
|
+
const title = task.split(/\r?\n/)[0].slice(0, 70);
|
|
65
|
+
const body = [`# ${title}`, "", "## 1 task", `files: ${req.files?.trim() || "./"}`, `do: ${indent(task)}`,
|
|
66
|
+
`done-when: ${indent(req.done_when?.trim() || "the change described in do: is made and the existing tests still pass")}`, ""].join("\n");
|
|
67
|
+
const rel = join(".cheapag", "plans", `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(2).toString("hex")}.md`);
|
|
68
|
+
mkdirSync(join(root, ".cheapag", "plans"), { recursive: true });
|
|
69
|
+
writeFileSync(join(root, rel), body);
|
|
70
|
+
return { plan: rel };
|
|
71
|
+
}
|
|
72
|
+
export async function delegate(req, ctx) {
|
|
73
|
+
const root = projectRoot(process.cwd());
|
|
74
|
+
// first: an unapproved repo gets CONSENT before any job, worktree, OpenCode boot or provider call
|
|
75
|
+
const gate = consentGate(root, ctx.provider.provider);
|
|
76
|
+
if ("block" in gate)
|
|
77
|
+
return gate.block;
|
|
78
|
+
const { route } = gate;
|
|
79
|
+
let input;
|
|
80
|
+
const brief = resolveBrief(root, req.brief);
|
|
81
|
+
if ("fail" in brief)
|
|
82
|
+
return failBlock(brief.fail);
|
|
83
|
+
const source = resolveSource(root, req.source);
|
|
84
|
+
if (req.task !== undefined) {
|
|
85
|
+
const made = inlinePlan(root, req);
|
|
86
|
+
if ("fail" in made)
|
|
87
|
+
return failBlock(made.fail);
|
|
88
|
+
input = { plan: made.plan, steps: "1", note: req.note, wait: req.wait, brief: brief.text, source, difficulty: req.difficulty };
|
|
89
|
+
}
|
|
90
|
+
else if (req.plan && req.steps)
|
|
91
|
+
input = { plan: req.plan, steps: req.steps, note: req.note, wait: req.wait, brief: brief.text, source, difficulty: req.difficulty };
|
|
92
|
+
else
|
|
93
|
+
return failBlock("give task (what to change, files, how to check), or plan and steps");
|
|
94
|
+
const planFile = join(root, input.plan);
|
|
95
|
+
if (isAbsolute(input.plan) || !inside(root, planFile))
|
|
96
|
+
return failBlock(`plan ${input.plan} must be a path inside the repository, relative to its root`);
|
|
97
|
+
if (!existsSync(planFile))
|
|
98
|
+
return failBlock(`plan ${input.plan} not found`);
|
|
99
|
+
let plan;
|
|
100
|
+
let groups;
|
|
101
|
+
try {
|
|
102
|
+
plan = parsePlan(readFileSync(planFile, "utf8"));
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return failBlock(`plan ${input.plan}: ${err.message}\n${PLAN_FORMAT}`);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
groups = selectGroups(plan, input.steps);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
return failBlock(`plan ${input.plan}: ${err.message}`);
|
|
112
|
+
}
|
|
113
|
+
let config;
|
|
114
|
+
try {
|
|
115
|
+
config = loadConfig(root);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
return failBlock(err.message);
|
|
119
|
+
}
|
|
120
|
+
const busy = operationInProgress(root);
|
|
121
|
+
if (busy)
|
|
122
|
+
return failBlock(`${busy} in the working tree; finish or abort it, then delegate again`);
|
|
123
|
+
// one boot with one route for every group: a config change would restart OpenCode under the batch's running jobs
|
|
124
|
+
const opencode = await ctx.opencode(route);
|
|
125
|
+
const release = holdOpencode();
|
|
126
|
+
const shared = { root, input, plan, config, route, opencode, provider: ctx.provider, progress: ctx.progress };
|
|
127
|
+
// wait=false: every group is RUNNING the moment it is created
|
|
128
|
+
if (groups.length === 1) {
|
|
129
|
+
const one = runGroup({ ...shared, group: groups[0], batch: null });
|
|
130
|
+
void one.done.finally(release);
|
|
131
|
+
return input.wait === false ? renderJob(one.record()) : one.outcome;
|
|
132
|
+
}
|
|
133
|
+
const run = runBatch(groups, shared);
|
|
134
|
+
void run.all.catch(() => { }).finally(release);
|
|
135
|
+
return input.wait === false ? run.render() : run.look(false, ctx.progress);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Runs one group as one job, start to landing (spec: Job lifecycle). `outcome` is the first block a caller sees (BLOCKED,
|
|
139
|
+
* or final); `done` resolves to the final block; `record()` is the job record as it stands; `settles()` resolves at the
|
|
140
|
+
* job's next settlement (BLOCKED, or final).
|
|
141
|
+
*/
|
|
142
|
+
function runGroup(g) {
|
|
143
|
+
const { root, input, plan, config, route, opencode } = g;
|
|
144
|
+
const steps = g.group.steps;
|
|
145
|
+
const cmds = g.fix ? [g.fix.cmd] : verifyCommands(steps.map((s) => s.fields["done-when"]), config.test_cmd);
|
|
146
|
+
const { job, dir } = newJob(root);
|
|
147
|
+
const write = (name, value) => writeWhole(join(dir, name), redact(typeof value === "string" ? value : JSON.stringify(value, null, 2) + "\n"));
|
|
148
|
+
const stepIds = g.group.label;
|
|
149
|
+
// a step's own difficulty: wins over the call's; in a range the hardest step decides (ticket 26)
|
|
150
|
+
const difficulty = [...DIFFICULTIES].reverse().find((d) => steps.some((s) => s.fields.difficulty === d)) ?? input.difficulty;
|
|
151
|
+
const model = tierModel(route, difficulty);
|
|
152
|
+
// against the implement model, so a tier model shows as model= in the headline
|
|
153
|
+
const requested = modelName(route.implement);
|
|
154
|
+
const startedAt = new Date();
|
|
155
|
+
write("request.json", { ...input, steps: stepIds, batch: g.batch, model: modelName(model), difficulty, startedAt });
|
|
156
|
+
const escalation = route.escalation;
|
|
157
|
+
// the job's model, then its allowed fallbacks: a provider limit moves the session on to the next one
|
|
158
|
+
const models = chain(model, route.fallbacks.filter((m) => modelName(m) !== modelName(model)));
|
|
159
|
+
let billing;
|
|
160
|
+
// whoever is waiting on this job right now: delegate first, then each reply
|
|
161
|
+
let progress = g.progress;
|
|
162
|
+
let settle;
|
|
163
|
+
const nextOutcome = () => new Promise((resolve) => (settle = resolve));
|
|
164
|
+
const outcome = nextOutcome();
|
|
165
|
+
// every settlement also wakes `jobs wait` and a batch watching this job
|
|
166
|
+
let pulse;
|
|
167
|
+
let settled = new Promise((resolve) => (pulse = resolve));
|
|
168
|
+
const announce = (text) => {
|
|
169
|
+
settle(text);
|
|
170
|
+
const wake = pulse;
|
|
171
|
+
settled = new Promise((resolve) => (pulse = resolve));
|
|
172
|
+
wake();
|
|
173
|
+
};
|
|
174
|
+
let notStarted;
|
|
175
|
+
let asked = null;
|
|
176
|
+
let blockedAt;
|
|
177
|
+
let expiry; // max_blocked_minutes, while BLOCKED
|
|
178
|
+
let worktree;
|
|
179
|
+
let session;
|
|
180
|
+
let report = finishReport({ text: "" });
|
|
181
|
+
let changed = [];
|
|
182
|
+
let reason;
|
|
183
|
+
const verified = [];
|
|
184
|
+
const tried = [];
|
|
185
|
+
let found; // a stopped worker's findings note (ticket 29)
|
|
186
|
+
let selfFix = 0;
|
|
187
|
+
let escalated = false;
|
|
188
|
+
let repro; // the server's check of the worker's reproduction test (OK jobs only)
|
|
189
|
+
let reproRisk;
|
|
190
|
+
let failing;
|
|
191
|
+
let checked = false; // whether verify has seen the worker's latest changes
|
|
192
|
+
let redone = 0; // 1 once a landing conflict started the redo (spec: Landing and redo)
|
|
193
|
+
let conflict; // set when the redo could not land either: the job is CONFLICT
|
|
194
|
+
// caps count across the whole job; any stop aborts the session, and all but the turn cap also kill verify
|
|
195
|
+
const { limits } = config;
|
|
196
|
+
const halt = new AbortController(); // the worker session
|
|
197
|
+
const kill = new AbortController(); // verify processes: a turn cap still runs one last check, so PARTIAL never hides failing tests
|
|
198
|
+
let stopped = undefined; // set from callbacks: keep TS from narrowing it to undefined
|
|
199
|
+
let landing = false;
|
|
200
|
+
const stop = (why) => {
|
|
201
|
+
// a landing in progress finishes; a turn cap's last check may still be cut short by the clock or a cancel
|
|
202
|
+
if (landing || (stopped && (stopped.cap !== "turns" || why.cap === "turns")))
|
|
203
|
+
return;
|
|
204
|
+
stopped = why;
|
|
205
|
+
clearTimeout(expiry);
|
|
206
|
+
blocked.delete(job); // a reply arriving now must not resume a stopped job
|
|
207
|
+
halt.abort();
|
|
208
|
+
if (why.cap !== "turns")
|
|
209
|
+
kill.abort();
|
|
210
|
+
};
|
|
211
|
+
const overCost = () => {
|
|
212
|
+
if (session && session.totals().usage.costUsd >= limits.max_cost_usd)
|
|
213
|
+
stop({ cap: "cost", reason: `job cap ($${limits.max_cost_usd.toFixed(2)}) reached` });
|
|
214
|
+
};
|
|
215
|
+
const overTurns = () => {
|
|
216
|
+
if (session && session.totals().turns >= limits.max_turns)
|
|
217
|
+
stop({ cap: "turns", reason: `turn cap (${limits.max_turns}) reached` });
|
|
218
|
+
};
|
|
219
|
+
// fail fast (ticket 28): a step with checks that has no test file in its worktree by repro_by_turns stops now, so Claude
|
|
220
|
+
// takes over without a 40-turn failed job in its context (v2 hard tier: FAIL sessions averaged 482k Claude tokens, stock 385k)
|
|
221
|
+
// shortcut: counts any test file in the worktree's status, carried-over dirty ones included; tighten to the job's own edits if that misfires
|
|
222
|
+
let reproSeen = false;
|
|
223
|
+
const reproDue = () => {
|
|
224
|
+
if (reproSeen || !cmds.length || !worktree || !session || session.totals().turns < limits.repro_by_turns)
|
|
225
|
+
return;
|
|
226
|
+
reproSeen = git(worktree.path, ["status", "--porcelain"]).split("\n").some((l) => TEST_FILE.test(l.slice(3).trim()));
|
|
227
|
+
if (!reproSeen)
|
|
228
|
+
stop({ cap: "turns", reason: `no reproduction test by turn ${limits.repro_by_turns}; the worker was still exploring` });
|
|
229
|
+
};
|
|
230
|
+
// the minute cap counts working time only: the clock is paused while the job waits BLOCKED on a reply
|
|
231
|
+
let clockLeft = limits.max_minutes * 60_000;
|
|
232
|
+
let clockStarted = Date.now();
|
|
233
|
+
let timer;
|
|
234
|
+
const startClock = () => {
|
|
235
|
+
clockStarted = Date.now();
|
|
236
|
+
timer = setTimeout(() => stop({ cap: "minutes", reason: `minute cap (${limits.max_minutes} min) reached` }), clockLeft);
|
|
237
|
+
};
|
|
238
|
+
const pauseClock = () => {
|
|
239
|
+
if (timer === undefined)
|
|
240
|
+
return; // not started yet, or already paused: never count the same stretch twice
|
|
241
|
+
clearTimeout(timer);
|
|
242
|
+
timer = undefined;
|
|
243
|
+
clockLeft -= Date.now() - clockStarted;
|
|
244
|
+
};
|
|
245
|
+
let finished;
|
|
246
|
+
const done = new Promise((resolve) => (finished = resolve));
|
|
247
|
+
const halted = new Promise((resolve) => (halt.signal.aborted ? resolve() : halt.signal.addEventListener("abort", () => resolve(), { once: true })));
|
|
248
|
+
let parked = false; // finished, waiting for earlier groups to land: it can only move once they settle
|
|
249
|
+
running.set(job, { stop, done, batch: g.batch, settles: () => settled, block: () => renderJob(last), parked: () => parked });
|
|
250
|
+
/** Runs the done conditions in the worktree; returns the failing run and its error tail, if any. */
|
|
251
|
+
const verify = async () => {
|
|
252
|
+
const runs = await runVerify(worktree.path, cmds, (cmd) => progress(session.totals().turns, `verify: ${cmd}`), kill.signal);
|
|
253
|
+
checked = true;
|
|
254
|
+
verified.push(...runs);
|
|
255
|
+
// the full output stays on disk; Claude only ever sees the tail
|
|
256
|
+
for (const r of runs)
|
|
257
|
+
appendFileSync(join(dir, "verify.log"), redact(`$ ${r.cmd}\n${r.output}\n(exit ${r.ok ? "ok" : "failed"}, ${r.ms} ms)\n\n`));
|
|
258
|
+
const run = runs.find((r) => !r.ok);
|
|
259
|
+
return run && { run, tail: errorTail(run, worktree.path) };
|
|
260
|
+
};
|
|
261
|
+
let last;
|
|
262
|
+
/** Builds the job record as it stands, writes it and returns its block. */
|
|
263
|
+
const finalize = (status) => {
|
|
264
|
+
const landed = status === "OK";
|
|
265
|
+
// a BLOCKED worker has not finished: no report, no verify yet (the renderer omits what is absent)
|
|
266
|
+
const waiting = status === "BLOCKED";
|
|
267
|
+
const silent = waiting || notStarted !== undefined; // likewise a group that never started
|
|
268
|
+
const rejections = session?.totals().rejections ?? [];
|
|
269
|
+
// server risks go first, so the 5-risk cap never cuts them
|
|
270
|
+
const risks = [
|
|
271
|
+
...(reproRisk ? [reproRisk] : []),
|
|
272
|
+
...(report.disagree && !/^none\b/i.test(report.disagree) ? [`spec vs source: ${report.disagree}`] : []),
|
|
273
|
+
...(rejections.length > MAX_QUIET_REJECTIONS ? [`${rejections.length} tool calls or permission requests were rejected; check the worker stayed in scope (see the log)`] : []),
|
|
274
|
+
...report.risks,
|
|
275
|
+
];
|
|
276
|
+
const record = {
|
|
277
|
+
format: "cheapag/1", job, batch: g.batch, kind: g.fix ? "fix" : "implement", status, landed,
|
|
278
|
+
plan: input.plan, steps: stepIds, provider: g.provider.provider,
|
|
279
|
+
// the server process running the job: a restart finds RUNNING/BLOCKED records whose pid is gone
|
|
280
|
+
pid: process.pid,
|
|
281
|
+
models: {
|
|
282
|
+
requested, final: modelName(escalated ? escalation : models.current), escalated,
|
|
283
|
+
fallbacks: models.used,
|
|
284
|
+
},
|
|
285
|
+
difficulty,
|
|
286
|
+
billing,
|
|
287
|
+
// tree: what the worktree's diff is measured against, so `land apply` can land it later
|
|
288
|
+
baseline: { head: worktree?.head ?? null, stash: worktree?.stash ?? null, tree: worktree?.base ?? null },
|
|
289
|
+
worktree: !landed && worktree ? normalisePath(relative(root, worktree.path)) : null,
|
|
290
|
+
sessionID: session?.sessionID ?? null,
|
|
291
|
+
// server-measured: "none" when nothing ran (no conditions, or the worker failed before verify)
|
|
292
|
+
tests: silent ? undefined : (!verified.length ? "none" : failing ? "fail" : "pass"),
|
|
293
|
+
error: failing?.tail,
|
|
294
|
+
tried, ...(found && { found }),
|
|
295
|
+
summary: silent ? "" : report.summary, changed, risks: silent ? [] : risks,
|
|
296
|
+
...(changed.some((c) => TEST_FILE.test(c.path)) && { test: changed.find((c) => TEST_FILE.test(c.path)).path }),
|
|
297
|
+
// a landed job's files are in the working tree; an unlanded one's only in its worktree
|
|
298
|
+
review: reviewRanges(report.review, landed || !worktree ? root : worktree.path),
|
|
299
|
+
// a worker stopped by a cap rarely says what it left undone
|
|
300
|
+
missing: status === "PARTIAL" && !report.missing.length ? [`unknown: stopped (${stopped?.reason ?? "cap"}); compare the diff with the step`] : report.missing,
|
|
301
|
+
reason, conflict,
|
|
302
|
+
verify: verified.map(({ cmd, ok, ms }) => ({ cmd, ok, ms })), selfFix, redo: redone, ...(repro && { repro }), question: asked?.length === 1 ? asked[0] : null, ...(asked && asked.length > 1 && { questions: asked }), autoRejected: rejections,
|
|
303
|
+
usage: { turns: session?.totals().turns ?? 0, ...(session?.totals().usage ?? { costUsd: 0 }) },
|
|
304
|
+
timing: { startedAt, endedAt: status === "RUNNING" || waiting ? null : new Date(), ms: Date.now() - startedAt.getTime(), ...(waiting && { blockedAt }) },
|
|
305
|
+
};
|
|
306
|
+
last = record;
|
|
307
|
+
const text = renderJob(record);
|
|
308
|
+
write("result.json", { ...record, blockTokensApprox: Math.ceil(text.length / 4) });
|
|
309
|
+
write("result.txt", text + "\n");
|
|
310
|
+
return text;
|
|
311
|
+
};
|
|
312
|
+
/** The worker asked: the job is BLOCKED, its block goes to whoever waits, and the session stays paused until `reply` resumes it. */
|
|
313
|
+
const onQuestion = (questions) => new Promise((answer) => {
|
|
314
|
+
pauseClock();
|
|
315
|
+
asked = questions.map((q) => ({ text: q.question, options: q.options, answer: null }));
|
|
316
|
+
changed = worktreeDiff(worktree).changed;
|
|
317
|
+
blockedAt = new Date();
|
|
318
|
+
// a forgotten question must not hold the session, the worktree and a worker slot forever
|
|
319
|
+
expiry = setTimeout(() => stop({ cap: "cancel", reason: `no reply within ${limits.max_blocked_minutes} min` }), limits.max_blocked_minutes * 60_000).unref();
|
|
320
|
+
blocked.set(job, {
|
|
321
|
+
job, questions,
|
|
322
|
+
resume: (answers, replyProgress) => {
|
|
323
|
+
clearTimeout(expiry);
|
|
324
|
+
blockedAt = undefined;
|
|
325
|
+
blocked.delete(job);
|
|
326
|
+
startClock();
|
|
327
|
+
progress = replyProgress;
|
|
328
|
+
asked = questions.map((q, i) => ({ text: q.question, options: q.options, answer: answers[i] }));
|
|
329
|
+
const after = nextOutcome();
|
|
330
|
+
answer(answers);
|
|
331
|
+
finalize("RUNNING"); // a batch block shown before its next turn must not repeat the answered question
|
|
332
|
+
return after;
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
announce(finalize("BLOCKED"));
|
|
336
|
+
});
|
|
337
|
+
/**
|
|
338
|
+
* Its patch clashed with the working tree on landing (spec: Landing and redo): the tree is untouched, and one redo
|
|
339
|
+
* applies the change again onto a fresh worktree of the current state, then takes the normal path in the same
|
|
340
|
+
* session (same job, so caps and usage carry on). It lands OK with redo=1, or sets `conflict`: the files, and the jobs
|
|
341
|
+
* that changed them meanwhile. Called where the job lands, so a batch's landing order holds.
|
|
342
|
+
*/
|
|
343
|
+
const redo = async (patch, clash, work) => {
|
|
344
|
+
redone = 1;
|
|
345
|
+
write("conflict.patch", patch); // log copy of the change that clashed
|
|
346
|
+
removeWorktree(root, worktree);
|
|
347
|
+
// the same path, so the session's directory stays valid
|
|
348
|
+
worktree = createWorktree(root, job, config.link_dirs);
|
|
349
|
+
const marked = reapply(worktree, patch);
|
|
350
|
+
const named = conflictPaths(clash);
|
|
351
|
+
const files = marked.length ? marked : named.length ? named : changed.map((c) => c.path);
|
|
352
|
+
landing = false;
|
|
353
|
+
await Promise.race([g.beforeRedo?.(), halted]); // max_parallel counts a redo's worker too
|
|
354
|
+
startClock();
|
|
355
|
+
finalize("RUNNING");
|
|
356
|
+
const result = await work(redoPrompt(steps, files, marked, patch), "redo ");
|
|
357
|
+
write("finish.json", report);
|
|
358
|
+
const diff = worktreeDiff(worktree);
|
|
359
|
+
changed = diff.changed;
|
|
360
|
+
write("diff.patch", diff.patch);
|
|
361
|
+
if (stopped)
|
|
362
|
+
return; // the cap or cancel explains it
|
|
363
|
+
let why = result.error ? "redo worker error"
|
|
364
|
+
: failing ? "redo failed verification"
|
|
365
|
+
: leftoverMarkers(worktree).length ? "redo left conflict markers"
|
|
366
|
+
: !diff.changed.length ? "redo made no changes"
|
|
367
|
+
: undefined;
|
|
368
|
+
if (!why) {
|
|
369
|
+
landing = true;
|
|
370
|
+
pauseClock();
|
|
371
|
+
if (!(await land(root, job, diff.patch, diff.changed.map((c) => c.path)))) {
|
|
372
|
+
removeWorktree(root, worktree);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
why = "redo could not reconcile";
|
|
376
|
+
}
|
|
377
|
+
const others = landedSince(root, job, startedAt);
|
|
378
|
+
conflict = files.map((f) => {
|
|
379
|
+
const by = others.filter((r) => r.changed.some((c) => c.path === f)).map((r) => r.job);
|
|
380
|
+
return `${f} (${by.length ? `also changed by ${by.join(", ")}, landed first` : "changed in the working tree meanwhile"}; ${why})`;
|
|
381
|
+
});
|
|
382
|
+
};
|
|
383
|
+
finalize("RUNNING");
|
|
384
|
+
// the job outlives this call when it blocks: it settles each outcome in turn (BLOCKED, then whatever follows a reply)
|
|
385
|
+
void (async () => {
|
|
386
|
+
try {
|
|
387
|
+
notStarted = await Promise.race([g.start, halted.then(() => undefined)]);
|
|
388
|
+
if (notStarted)
|
|
389
|
+
throw new Error(notStarted);
|
|
390
|
+
if (stopped)
|
|
391
|
+
throw new Error("stopped before it started"); // cancelled while queued; the stop's reason is recorded
|
|
392
|
+
startClock(); // queued time is not working time
|
|
393
|
+
worktree = createWorktree(root, job, config.link_dirs);
|
|
394
|
+
session = await openSession({
|
|
395
|
+
opencode,
|
|
396
|
+
directory: worktree.path,
|
|
397
|
+
model,
|
|
398
|
+
permission: rulesetFor(worktree, cmds, config),
|
|
399
|
+
// nobody watches a fix job for a BLOCKED block: its questions are rejected (with a risk line)
|
|
400
|
+
onQuestion: g.fix ? undefined : onQuestion,
|
|
401
|
+
signal: halt.signal,
|
|
402
|
+
onEvent: (event, turns) => {
|
|
403
|
+
appendFileSync(join(dir, "events.jsonl"), redact(JSON.stringify(event)) + "\n");
|
|
404
|
+
// one progress notification per worker turn; finer events (deltas, usage) would only be noise in /tasks
|
|
405
|
+
if (event.type === "message.part.updated" && event.properties.part.type === "tool") {
|
|
406
|
+
const part = event.properties.part;
|
|
407
|
+
if (part.state.status === "completed" || part.state.status === "error") {
|
|
408
|
+
progress(turns, `turn ${turns}: ${part.tool}`);
|
|
409
|
+
if (!stopped && !blocked.has(job))
|
|
410
|
+
finalize("RUNNING"); // so `jobs list`/`show` see live turns
|
|
411
|
+
// finishing on the last allowed turn is fine
|
|
412
|
+
if (part.tool !== "finish") {
|
|
413
|
+
overTurns();
|
|
414
|
+
reproDue();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (event.type === "message.updated")
|
|
419
|
+
overCost();
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
finalize("RUNNING"); // now with the worktree, so a restart can point at it
|
|
423
|
+
const provider = g.provider.provider;
|
|
424
|
+
// a provider limit is not a cap: the worker aborts the session itself and the job falls back, or stops as BUDGET
|
|
425
|
+
const routed = async (text, on, interrupt) => {
|
|
426
|
+
const r = await runRouted(session, text, models, provider, on, interrupt);
|
|
427
|
+
if (r.budget)
|
|
428
|
+
stopped ??= { cap: "provider", reason: r.budget };
|
|
429
|
+
return r;
|
|
430
|
+
};
|
|
431
|
+
/** One prompt through the normal path: the run, then verify with up to 2 self-fixes and 1 escalation. `tag` prefixes tried: lines. */
|
|
432
|
+
const work = async (prompt, tag = "") => {
|
|
433
|
+
// no hand-over at the turn cap (ticket 28): it rescued nothing in the model bake-off and doubled a failed job's length
|
|
434
|
+
let result = await routed(prompt);
|
|
435
|
+
report = finishReport(result);
|
|
436
|
+
failing = undefined;
|
|
437
|
+
checked = false;
|
|
438
|
+
if (!result.error && cmds.length) {
|
|
439
|
+
failing = await verify();
|
|
440
|
+
// up to 2 self-fixes in the same session, then 1 escalation on the stronger model (spec: Job lifecycle)
|
|
441
|
+
for (const attempt of ["fix 1", "fix 2", "esc"]) {
|
|
442
|
+
if (!failing)
|
|
443
|
+
break;
|
|
444
|
+
overTurns(); // a fix needs at least one more turn
|
|
445
|
+
if (stopped)
|
|
446
|
+
break; // a cap, cancel or provider budget (cast: set from callbacks, keep TS from narrowing)
|
|
447
|
+
const esc = attempt === "esc";
|
|
448
|
+
if (esc && escalated)
|
|
449
|
+
break; // already used for the turn-cap hand-over
|
|
450
|
+
const skip = esc && escalationSkip(escalation, models, config.escalation_skip_at, session.totals().spend);
|
|
451
|
+
if (skip) {
|
|
452
|
+
tried.push(tag + skip);
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
if (esc)
|
|
456
|
+
escalated = true;
|
|
457
|
+
else
|
|
458
|
+
selfFix++;
|
|
459
|
+
const before = failing.tail;
|
|
460
|
+
result = await routed(`Verification failed. Fix the cause, then call finish again.\n\n${before.join("\n")}`, esc ? escalation : undefined);
|
|
461
|
+
checked = false;
|
|
462
|
+
// an attempt that reports nothing keeps the last real report rather than a placeholder
|
|
463
|
+
const fresh = result.finish || /```cheapag-result/.test(result.text) ? finishReport(result) : undefined;
|
|
464
|
+
if (fresh)
|
|
465
|
+
report = fresh;
|
|
466
|
+
const label = `${tag}${esc ? "esc" : attempt}: ${(fresh?.summary ?? "(no summary)").slice(0, 100)}`;
|
|
467
|
+
if (result.limit && esc) {
|
|
468
|
+
tried.push(`${label} → usage limit: ${result.limit.message.slice(0, 80)}`);
|
|
469
|
+
escalated = false; // it never ran: the job ends on the implement chain's model and result
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
if (result.error) {
|
|
473
|
+
tried.push(`${label} → ${stopped ? `stopped: ${stopped.reason}` : "worker error"}`);
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
failing = await verify();
|
|
477
|
+
tried.push(`${label} → ${!failing ? "passed" : sameFailure(before, failing.tail) ? "same failure" : "different failure"}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// stopped mid-change by the turn cap: check what the worker left, to tell PARTIAL from FAIL
|
|
481
|
+
if (stopped?.cap === "turns" && cmds.length && !checked)
|
|
482
|
+
failing = await verify();
|
|
483
|
+
return result;
|
|
484
|
+
};
|
|
485
|
+
const result = await work(g.fix ? fixPrompt(plan, steps, g.fix) : contextPack(plan, steps, input.note, input.brief, input.source));
|
|
486
|
+
write("finish.json", report);
|
|
487
|
+
const diff = worktreeDiff(worktree);
|
|
488
|
+
changed = diff.changed;
|
|
489
|
+
write("diff.patch", diff.patch); // the log copy is redacted; landing applies the in-memory patch
|
|
490
|
+
// ticket 29: a capped worker leaves Claude what it learned, so the take-over does not re-read the same files (after the diff, so the note turn cannot change the result)
|
|
491
|
+
const cap = stopped?.cap;
|
|
492
|
+
if (cap === "turns" || cap === "minutes" || cap === "cost")
|
|
493
|
+
found = await findings(session);
|
|
494
|
+
if (stopped) {
|
|
495
|
+
// a capped or cancelled job never lands; its worktree is kept
|
|
496
|
+
}
|
|
497
|
+
else if (result.error) {
|
|
498
|
+
reason = `worker error: ${result.error}`;
|
|
499
|
+
}
|
|
500
|
+
else if (!failing) { // a verify failure is explained by the error: and tried: sections, not a reason
|
|
501
|
+
({ repro, risk: reproRisk } = await checkRepro(worktree.path, report.repro, [...cmds, ...(config.test_cmd ? [config.test_cmd] : []), ...config.allow_commands], diff.changed.map((c) => c.path), kill.signal));
|
|
502
|
+
// waiting for earlier groups to land is still cancellable; the clock is paused, since an earlier group may sit BLOCKED for long
|
|
503
|
+
pauseClock();
|
|
504
|
+
parked = true;
|
|
505
|
+
await Promise.race([g.beforeLand?.(), halted]);
|
|
506
|
+
parked = false;
|
|
507
|
+
if (!stopped) {
|
|
508
|
+
landing = true; // from here on, caps and cancel no longer apply (until a redo works again)
|
|
509
|
+
pauseClock();
|
|
510
|
+
const clash = await land(root, job, diff.patch, diff.changed.map((c) => c.path));
|
|
511
|
+
if (!clash)
|
|
512
|
+
removeWorktree(root, worktree);
|
|
513
|
+
else
|
|
514
|
+
await redo(diff.patch, clash, work);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
reason = err.message;
|
|
520
|
+
}
|
|
521
|
+
clearTimeout(timer);
|
|
522
|
+
running.delete(job);
|
|
523
|
+
blocked.delete(job);
|
|
524
|
+
if (session) {
|
|
525
|
+
// Go windows are per account: the user-wide ledger decides later escalations and shows balance use
|
|
526
|
+
try {
|
|
527
|
+
recordSpend(session.totals().spend);
|
|
528
|
+
if (g.provider.provider === "opencode-go" && usedBalance(session.totals().spend))
|
|
529
|
+
billing = "zen";
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
// the ledger only steers later escalations: a write error (full disk, permissions) must not change this job's result
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
let status = !stopped && reason === undefined && !failing ? "OK" : "FAIL";
|
|
536
|
+
if (stopped) {
|
|
537
|
+
// the cap or cancel explains the stop, not the aborted prompt's error
|
|
538
|
+
reason = stopped.reason;
|
|
539
|
+
status = stopped.cap === "turns" ? (changed.length && !failing ? "PARTIAL" : "FAIL") : STOPPED[stopped.cap];
|
|
540
|
+
if (status === "PARTIAL")
|
|
541
|
+
reason = undefined; // spec D5: PARTIAL says it through missing:
|
|
542
|
+
}
|
|
543
|
+
else if (conflict)
|
|
544
|
+
status = "CONFLICT";
|
|
545
|
+
const text = finalize(status);
|
|
546
|
+
finished(text);
|
|
547
|
+
announce(text);
|
|
548
|
+
})().catch((err) => {
|
|
549
|
+
clearTimeout(timer);
|
|
550
|
+
running.delete(job);
|
|
551
|
+
blocked.delete(job);
|
|
552
|
+
reason = err.message;
|
|
553
|
+
let text;
|
|
554
|
+
try {
|
|
555
|
+
text = finalize("FAIL"); // never leave a RUNNING/BLOCKED record behind for this live process
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
text = failBlock(err.message);
|
|
559
|
+
}
|
|
560
|
+
finished(text);
|
|
561
|
+
announce(text);
|
|
562
|
+
});
|
|
563
|
+
return { job, outcome, done, record: () => last, settles: () => settled };
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Several groups (spec: Plan format contract, D6): each runs as its own job, up to `max_parallel` at once. A group whose
|
|
567
|
+
* `files:` overlap an earlier group's starts after that one settles (with a note); a group with `needs:` starts after its
|
|
568
|
+
* dependencies land OK, or is recorded FAIL without starting. Groups land in step order. Every group's job is created at
|
|
569
|
+
* once (RUNNING until it starts), so any batch block can show it. `all` resolves when every group settled; `look` is the
|
|
570
|
+
* batch block at the next point Claude should see it (spec: Job lifecycle, early return).
|
|
571
|
+
*/
|
|
572
|
+
function runBatch(groups, shared) {
|
|
573
|
+
const { root, config } = shared;
|
|
574
|
+
const dir = join(root, ".cheapag", "batches");
|
|
575
|
+
mkdirSync(dir, { recursive: true });
|
|
576
|
+
let batch;
|
|
577
|
+
do
|
|
578
|
+
batch = newId("b");
|
|
579
|
+
while (existsSync(join(dir, `${batch}.json`)));
|
|
580
|
+
const startedAt = new Date();
|
|
581
|
+
const notes = [];
|
|
582
|
+
const after = groups.map((g, i) => groups.slice(0, i).flatMap((earlier, j) => {
|
|
583
|
+
const path = overlap(earlier, g);
|
|
584
|
+
if (!path)
|
|
585
|
+
return [];
|
|
586
|
+
notes.push(`steps ${earlier.label},${g.label} serialized (${path})`);
|
|
587
|
+
return [j];
|
|
588
|
+
}));
|
|
589
|
+
// shortcut: a need outside this delegation is not checked (it may have landed in an earlier call); check job records if plans rely on it
|
|
590
|
+
const needs = groups.map((g) => needsOf(g).flatMap((id) => {
|
|
591
|
+
const j = groups.findIndex((o) => o.steps.some((s) => s.id === id));
|
|
592
|
+
return j < 0 ? [] : [{ id, j }];
|
|
593
|
+
}));
|
|
594
|
+
const ids = []; // by group
|
|
595
|
+
const finals = [];
|
|
596
|
+
const cmd = config.integration_cmd ?? config.test_cmd;
|
|
597
|
+
const state = { integration: cmd ? "pending" : "none", verify: [] };
|
|
598
|
+
let fix;
|
|
599
|
+
const record = () => ({ format: "cheapag/1", batch, groups: groups.map((g) => g.label), notes, ...state, jobs: ids.filter(Boolean) });
|
|
600
|
+
const write = () => {
|
|
601
|
+
const ended = finals.filter((r) => r !== undefined);
|
|
602
|
+
const landed = ended.filter((r) => r.landed).map((r) => r.job); // in landing order: groups land in step order
|
|
603
|
+
const { plan, steps } = shared.input;
|
|
604
|
+
const endedAt = ended.length === groups.length ? new Date() : null;
|
|
605
|
+
writeWhole(join(dir, `${batch}.json`), redact(JSON.stringify({ ...record(), plan, steps, landed, startedAt, endedAt }, null, 2) + "\n"));
|
|
606
|
+
};
|
|
607
|
+
// worker slots: held while a worker runs (BLOCKED included: its worktree and session stay live, and a reply resumes it at
|
|
608
|
+
// once), released before waiting to land, so a later group can never starve an earlier one
|
|
609
|
+
let free = config.limits.max_parallel;
|
|
610
|
+
const queue = [];
|
|
611
|
+
const acquire = () => (free > 0 ? (free--, Promise.resolve()) : new Promise((resolve) => queue.push(resolve)));
|
|
612
|
+
const release = () => {
|
|
613
|
+
const next = queue.shift();
|
|
614
|
+
if (next)
|
|
615
|
+
next();
|
|
616
|
+
else
|
|
617
|
+
free++;
|
|
618
|
+
};
|
|
619
|
+
// whoever is looking at the batch right now (delegate, a reply, a wait); nobody between looks
|
|
620
|
+
let progress = () => { };
|
|
621
|
+
const forward = (turns, message) => progress(turns, message);
|
|
622
|
+
// overlap and needs only ever point at earlier groups (selectGroups rejects a need on a later one), so nothing waits in a cycle
|
|
623
|
+
const settled = [];
|
|
624
|
+
// a group lands only after every earlier group has settled: a cancelled or BLOCKED group holds back its successors
|
|
625
|
+
const turns = [];
|
|
626
|
+
const runs = groups.map((group, i) => {
|
|
627
|
+
let holding = false;
|
|
628
|
+
const releaseSlot = () => {
|
|
629
|
+
if (holding)
|
|
630
|
+
release();
|
|
631
|
+
holding = false;
|
|
632
|
+
};
|
|
633
|
+
const start = (async () => {
|
|
634
|
+
await Promise.all(after[i].map((j) => settled[j]));
|
|
635
|
+
const deps = await Promise.all(needs[i].map(async ({ id, j }) => ({ id, status: (await settled[j]).status })));
|
|
636
|
+
const failed = deps.find((d) => d.status !== "OK");
|
|
637
|
+
if (failed)
|
|
638
|
+
return `needs ${failed.id} (${failed.status})`;
|
|
639
|
+
await acquire();
|
|
640
|
+
holding = true;
|
|
641
|
+
})();
|
|
642
|
+
const run = runGroup({
|
|
643
|
+
...shared, progress: forward, group, batch, start,
|
|
644
|
+
beforeLand: () => (releaseSlot(), turns[i - 1]),
|
|
645
|
+
beforeRedo: () => acquire().then(() => (holding = true)),
|
|
646
|
+
});
|
|
647
|
+
ids[i] = run.job;
|
|
648
|
+
settled[i] = (async () => {
|
|
649
|
+
await run.done;
|
|
650
|
+
void start.then(releaseSlot, () => { }); // a group cancelled while queued gives back the slot it gets later
|
|
651
|
+
const final = run.record();
|
|
652
|
+
finals[i] = final;
|
|
653
|
+
write();
|
|
654
|
+
return final;
|
|
655
|
+
})();
|
|
656
|
+
turns[i] = Promise.all([settled[i], turns[i - 1]]);
|
|
657
|
+
return run;
|
|
658
|
+
});
|
|
659
|
+
write();
|
|
660
|
+
// a group left unlanded runs the check later, from `land`, once nothing is left to land
|
|
661
|
+
const all = Promise.all(settled).then(async (ended) => {
|
|
662
|
+
if (cmd && allLanded(ended))
|
|
663
|
+
await integrate(root, cmd, batch, state, ended.map((r) => r.landed), write, async () => ({ ...shared, progress: forward }), (run) => (fix = run));
|
|
664
|
+
return ended;
|
|
665
|
+
});
|
|
666
|
+
// jobs in group order (renderBatch sorts by urgency)
|
|
667
|
+
const render = () => renderBatch(record(), runs.map((r) => r.record()), fix?.record());
|
|
668
|
+
const live = () => runs.filter((r) => running.has(r.job) && !blocked.has(r.job));
|
|
669
|
+
// nothing can move without a reply: every group still live is parked (waiting to land behind a BLOCKED one)
|
|
670
|
+
const stuck = () => anyBlocked() && live().every((r) => running.get(r.job)?.parked());
|
|
671
|
+
const anyBlocked = () => runs.some((r) => blocked.has(r.job));
|
|
672
|
+
/**
|
|
673
|
+
* Waits for the next settlement (`once`), or until a group is BLOCKED or every group settled, then returns the batch
|
|
674
|
+
* block. With nothing left working it returns at once: a BLOCKED group only moves on a reply.
|
|
675
|
+
*/
|
|
676
|
+
const look = async (once, to) => {
|
|
677
|
+
progress = to;
|
|
678
|
+
try {
|
|
679
|
+
do {
|
|
680
|
+
const working = live();
|
|
681
|
+
if (!working.length || stuck())
|
|
682
|
+
break; // waiting would hang: a BLOCKED group only moves on a reply
|
|
683
|
+
await Promise.race(working.map((r) => r.settles()));
|
|
684
|
+
} while (!once && !anyBlocked());
|
|
685
|
+
if (!live().length && !anyBlocked())
|
|
686
|
+
await all; // every group settled: the batch record is complete
|
|
687
|
+
return render();
|
|
688
|
+
}
|
|
689
|
+
finally {
|
|
690
|
+
if (progress === to)
|
|
691
|
+
progress = () => { };
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
const entry = { look, render, forward };
|
|
695
|
+
batches.set(batch, entry);
|
|
696
|
+
void all.catch(() => { }).then(() => batches.delete(batch)); // a rejection reaches whoever looks
|
|
697
|
+
return { all, ...entry };
|
|
698
|
+
}
|
|
699
|
+
/** Every group has ended with nothing left to land (each landed, was discarded or never got a worktree), and something did land. */
|
|
700
|
+
const allLanded = (jobs) => jobs.every((j) => j.status !== "RUNNING" && j.status !== "BLOCKED" && (j.landed || !j.worktree)) && jobs.some((j) => j.landed);
|
|
701
|
+
/**
|
|
702
|
+
* The integration check (spec: Job lifecycle, A3 item 3): `cmd` on the combined working tree, under the land lock. A
|
|
703
|
+
* failure starts one fix job on a fresh worktree of the combined state, on the normal path (self-fixes, escalation,
|
|
704
|
+
* landing): integration passes when it lands OK, else fails with the check's error. `save` records each state change;
|
|
705
|
+
* `fixer` gives what the fix job needs, only once one is needed (it may boot OpenCode).
|
|
706
|
+
*/
|
|
707
|
+
async function integrate(root, cmd, batch, state, landed, save, fixer, started) {
|
|
708
|
+
try {
|
|
709
|
+
state.integration = "running";
|
|
710
|
+
save();
|
|
711
|
+
// shortcut: the lock is held for the whole check, so a check longer than the lock timeout (10 min) makes other landings FAIL; split the lock if suites get that long
|
|
712
|
+
const [run] = await withLock(root, () => runVerify(root, [cmd], () => { }));
|
|
713
|
+
state.verify.push({ cmd: run.cmd, ok: run.ok, ms: run.ms });
|
|
714
|
+
if (run.ok) {
|
|
715
|
+
state.integration = "pass";
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const error = errorTail(run, root);
|
|
719
|
+
const shared = await fixer();
|
|
720
|
+
const groups = selectGroups(shared.plan, shared.input.steps).filter((_, i) => landed[i]); // discarded groups are not in this code
|
|
721
|
+
const fix = runGroup({ ...shared, group: { label: groups.map((g) => g.label).join(","), steps: groups.flatMap((g) => g.steps) }, batch, fix: { cmd, error } });
|
|
722
|
+
state.fix = fix.job;
|
|
723
|
+
started(fix);
|
|
724
|
+
save();
|
|
725
|
+
await fix.done;
|
|
726
|
+
// shortcut: a fix that landed OK passes without checking the tree again (its own verify ran cmd on the combined state); re-check if the tree can drift meanwhile
|
|
727
|
+
if (fix.record().status === "OK")
|
|
728
|
+
state.integration = "pass";
|
|
729
|
+
else
|
|
730
|
+
Object.assign(state, { integration: "fail", integrationError: error });
|
|
731
|
+
}
|
|
732
|
+
catch (err) {
|
|
733
|
+
Object.assign(state, { integration: "fail", integrationError: [`integration check: ${err.message}`] });
|
|
734
|
+
}
|
|
735
|
+
finally {
|
|
736
|
+
save();
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/** Batches with a group still live in this server process, by id: how `jobs wait` and `reply` attach to them. */
|
|
740
|
+
const batches = new Map();
|
|
741
|
+
/** Why a job was stopped before it could finish: a cap, or `jobs cancel`. */
|
|
742
|
+
/** A test file, by the usual conventions: a tests/ directory, test_x / x_test / x.test / x.spec names, conftest.py. */
|
|
743
|
+
const TEST_FILE = /(^|\/)(tests?|testing|__tests__)\/|(^|\/)(test_[^/]*|[^/]*_tests?\.[a-z]+|[^/]*\.(test|spec)\.[a-z]+|conftest\.py)$/i;
|
|
744
|
+
/**
|
|
745
|
+
* Checks the worker's reproduction test (ticket 24.1): its command must pass on the fixed tree and fail once the non-test
|
|
746
|
+
* changes are set aside, which proves it tests the problem. The command comes from the worker, so it only runs when it
|
|
747
|
+
* starts with a command this job may already run (done-when, test_cmd, allow_commands); anything else stays unverified.
|
|
748
|
+
*/
|
|
749
|
+
const FINDINGS_PROMPT = "You were stopped before finishing. Without calling any tool, answer in at most 600 characters: where the bug is (file:function), your best hypothesis, and what you ruled out.";
|
|
750
|
+
const FINDINGS_MS = 30_000;
|
|
751
|
+
/** One last prompt on a stopped session for its findings; any error, timeout or empty answer gives none. */
|
|
752
|
+
async function findings(session) {
|
|
753
|
+
try {
|
|
754
|
+
const r = await session.run(FINDINGS_PROMPT, undefined, AbortSignal.timeout(FINDINGS_MS), true);
|
|
755
|
+
const text = (r.error || r.interrupted ? "" : r.text || r.finish?.summary || "").replace(/\s+/g, " ").trim();
|
|
756
|
+
return text ? (text.length > 600 ? `${text.slice(0, 599)}…` : text) : undefined;
|
|
757
|
+
}
|
|
758
|
+
catch {
|
|
759
|
+
return undefined;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
async function checkRepro(cwd, claim, allowed, changed, signal) {
|
|
763
|
+
if (!claim || /^n\/?a\b/i.test(claim))
|
|
764
|
+
return {};
|
|
765
|
+
if (/^none\b/i.test(claim))
|
|
766
|
+
return { repro: "none", risk: `no reproduction: ${claim.replace(/^none:?\s*/i, "") || "the worker could not reproduce the problem"}` };
|
|
767
|
+
const runs = (cmd) => runVerify(cwd, [cmd], () => { }, signal).then((r) => r[0]?.ok ?? false);
|
|
768
|
+
const unverified = (why) => ({ repro: "unverified", risk: `reproduction not verified: ${why}` });
|
|
769
|
+
if (!allowed.some((a) => claim === a || claim.startsWith(a + " ")))
|
|
770
|
+
return unverified(`\`${claim}\` is not one of this job's check commands`);
|
|
771
|
+
if (!(await runs(claim)))
|
|
772
|
+
return unverified(`\`${claim}\` fails on the fixed code`);
|
|
773
|
+
const fix = changed.filter((p) => !TEST_FILE.test(p));
|
|
774
|
+
if (!fix.length)
|
|
775
|
+
return unverified("the change has no code outside tests");
|
|
776
|
+
// set the fix aside (tests stay), run the reproduction, then restore; landing uses the diff taken before this
|
|
777
|
+
try {
|
|
778
|
+
git(cwd, ["stash", "push", "--include-untracked", "--quiet", "--", ...fix]);
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
return unverified("could not set the fix aside to rerun the reproduction");
|
|
782
|
+
}
|
|
783
|
+
let failsWithout = false;
|
|
784
|
+
try {
|
|
785
|
+
failsWithout = !(await runs(claim));
|
|
786
|
+
}
|
|
787
|
+
finally {
|
|
788
|
+
// a failed restore only affects the worktree: the landing applies the diff taken before this check
|
|
789
|
+
try {
|
|
790
|
+
git(cwd, ["stash", "pop", "--quiet"]);
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
failsWithout = false;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return failsWithout ? { repro: "fail→pass" } : unverified(`\`${claim}\` also passes without the fix`);
|
|
797
|
+
}
|
|
798
|
+
const STOPPED = { minutes: "TIMEOUT", cost: "BUDGET", cancel: "CANCELLED", provider: "BUDGET" };
|
|
799
|
+
/**
|
|
800
|
+
* Jobs running in this server process, by id: how `jobs cancel` and `jobs wait` reach them. `done` resolves to the final
|
|
801
|
+
* block, `settles()` at the next settlement (BLOCKED or final); `block()` is the current block.
|
|
802
|
+
*/
|
|
803
|
+
const running = new Map();
|
|
804
|
+
/** The `jobs` tool: list jobs, show one's last block again, wait for a job's or batch's next settlement, or cancel a running job. */
|
|
805
|
+
export async function jobs(input, progress = () => { }) {
|
|
806
|
+
const root = projectRoot(process.cwd());
|
|
807
|
+
if (input.action === "list") {
|
|
808
|
+
const all = records(root).sort((a, b) => String(b.timing?.startedAt ?? "").localeCompare(String(a.timing?.startedAt ?? "")));
|
|
809
|
+
if (!all.length)
|
|
810
|
+
return "no jobs yet";
|
|
811
|
+
const lines = all.slice(0, LIST_MAX).map((r) => renderJob(r).split("\n")[0] + blockedFor(r));
|
|
812
|
+
if (all.length > LIST_MAX)
|
|
813
|
+
lines.push(`… +${all.length - LIST_MAX} older`);
|
|
814
|
+
return lines.join("\n");
|
|
815
|
+
}
|
|
816
|
+
if (input.action === "wait" && input.job && /^b[0-9a-z]{4}$/.test(input.job))
|
|
817
|
+
return waitBatch(root, input.job, progress);
|
|
818
|
+
// the id becomes a path: only well-formed ids get that far
|
|
819
|
+
if (!input.job || !/^j[0-9a-z]{4}$/.test(input.job))
|
|
820
|
+
return failBlock(`jobs ${input.action} needs a job id, e.g. j7f3a${input.action === "wait" ? " or a batch id, e.g. b4k1x" : ""}`);
|
|
821
|
+
const live = running.get(input.job);
|
|
822
|
+
if (input.action === "wait" && live) {
|
|
823
|
+
// a BLOCKED job only moves on a reply: its block now, rather than a wait that never ends
|
|
824
|
+
if (!blocked.has(input.job))
|
|
825
|
+
await live.settles();
|
|
826
|
+
return live.block();
|
|
827
|
+
}
|
|
828
|
+
if (input.action === "cancel" && live) {
|
|
829
|
+
live.stop({ cap: "cancel", reason: "cancelled on request" });
|
|
830
|
+
return live.done;
|
|
831
|
+
}
|
|
832
|
+
const dir = join(root, ".cheapag", "jobs", input.job);
|
|
833
|
+
const record = readRecord(dir);
|
|
834
|
+
if (!record)
|
|
835
|
+
return failBlock(`job ${input.job} not found`);
|
|
836
|
+
const block = () => readFileSync(join(dir, "result.txt"), "utf8").trimEnd();
|
|
837
|
+
if (input.action === "show")
|
|
838
|
+
return block();
|
|
839
|
+
if (record.status === "RUNNING" || record.status === "BLOCKED") {
|
|
840
|
+
return failBlock(`job ${input.job} is ${record.status} but not in this session (server pid ${record.pid ?? "unknown"}); ${input.action === "wait" ? "wait for" : "cancel"} it from the session that started it`);
|
|
841
|
+
}
|
|
842
|
+
if (input.action === "wait")
|
|
843
|
+
return block(); // finished: its final block at once
|
|
844
|
+
return failBlock(`job ${input.job} already finished (${record.status}); nothing to cancel`);
|
|
845
|
+
}
|
|
846
|
+
/** `jobs wait` on a batch: the next settlement of a live one, or a finished one's block (from its records) at once. */
|
|
847
|
+
function waitBatch(root, batch, progress) {
|
|
848
|
+
const live = batches.get(batch);
|
|
849
|
+
if (live)
|
|
850
|
+
return live.look(true, progress);
|
|
851
|
+
let record;
|
|
852
|
+
try {
|
|
853
|
+
record = JSON.parse(readFileSync(join(root, ".cheapag", "batches", `${batch}.json`), "utf8"));
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
return failBlock(`batch ${batch} not found`);
|
|
857
|
+
}
|
|
858
|
+
// shortcut: a batch another live session runs is shown as its records stand, not waited on; wait from that session
|
|
859
|
+
const jobRecords = record.jobs.map((id) => readRecord(join(root, ".cheapag", "jobs", id)));
|
|
860
|
+
if (jobRecords.some((r) => !r))
|
|
861
|
+
return failBlock(`batch ${batch}: a job record is missing (cleaned up?)`);
|
|
862
|
+
return renderBatch(record, jobRecords, record.fix ? readRecord(join(root, ".cheapag", "jobs", record.fix)) : undefined);
|
|
863
|
+
}
|
|
864
|
+
const LIST_MAX = 20;
|
|
865
|
+
/** How long a BLOCKED job has waited for a reply, as a suffix for its one-line headline (jobs list, cheapag status). */
|
|
866
|
+
export const blockedFor = (r) => r.status === "BLOCKED" && r.timing?.blockedAt && r.pid && isAlive(r.pid) ? ` waiting=${Math.floor((Date.now() - Date.parse(r.timing.blockedAt)) / 60_000)}m` : "";
|
|
867
|
+
function readRecord(dir) {
|
|
868
|
+
try {
|
|
869
|
+
return JSON.parse(readFileSync(join(dir, "result.json"), "utf8"));
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
return undefined; // no record yet, or unreadable
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/** Every readable job record in the project, with the folder it lives in. */
|
|
876
|
+
export function records(root) {
|
|
877
|
+
const base = join(root, ".cheapag", "jobs");
|
|
878
|
+
if (!existsSync(base))
|
|
879
|
+
return [];
|
|
880
|
+
return readdirSync(base).flatMap((id) => {
|
|
881
|
+
const record = readRecord(join(base, id));
|
|
882
|
+
return record ? [{ ...record, dir: join(base, id) }] : [];
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Crash recovery at server start (spec: Job lifecycle, restart): a RUNNING or BLOCKED job whose server process is gone
|
|
887
|
+
* becomes CANCELLED with `reason: server restart`; its worktree is kept.
|
|
888
|
+
*/
|
|
889
|
+
export function recoverJobs(cwd) {
|
|
890
|
+
let root;
|
|
891
|
+
try {
|
|
892
|
+
root = projectRoot(cwd);
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
return; // not a repository: no jobs
|
|
896
|
+
}
|
|
897
|
+
for (const { dir, ...record } of records(root)) {
|
|
898
|
+
if (record.status !== "RUNNING" && record.status !== "BLOCKED")
|
|
899
|
+
continue;
|
|
900
|
+
// our own pid can only be a reused one: whoever wrote it is gone
|
|
901
|
+
if (record.pid && record.pid !== process.pid && isAlive(record.pid))
|
|
902
|
+
continue;
|
|
903
|
+
const cancelled = { ...record, status: "CANCELLED", landed: false, reason: "server restart" };
|
|
904
|
+
const text = renderJob(cancelled);
|
|
905
|
+
writeWhole(join(dir, "result.json"), redact(JSON.stringify({ ...cancelled, blockTokensApprox: Math.ceil(text.length / 4) }, null, 2) + "\n"));
|
|
906
|
+
writeWhole(join(dir, "result.txt"), redact(text + "\n"));
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
/** Jobs other than `job` that landed since `since`: who changed a file under it meanwhile. */
|
|
910
|
+
function landedSince(root, job, since) {
|
|
911
|
+
// shortcut: a job landed later by `land apply` counts by when it ended, not when it landed; add a landedAt if that misleads
|
|
912
|
+
return records(root).filter((r) => r.job !== job && r.landed && Date.parse(r.timing?.endedAt ?? "") >= since.getTime());
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* The `land` tool (spec: Tool contracts, A13): `apply` 3-way applies unlanded jobs' worktree changes to the working
|
|
916
|
+
* tree under the lock, in the order given, stopping at the first that fails; `discard` deletes their worktrees. One line
|
|
917
|
+
* per job. Records are updated (`landed`, `worktree`, `discarded`) so research's unlanded note and cleanup stay truthful.
|
|
918
|
+
*/
|
|
919
|
+
export async function landJobs(input, ctx) {
|
|
920
|
+
const root = projectRoot(process.cwd());
|
|
921
|
+
const ids = input.job.split(",").map((s) => s.trim()).filter(Boolean);
|
|
922
|
+
if (!ids.length)
|
|
923
|
+
return failBlock('land needs a job id, or several in landing order, e.g. "j7f3a,j7f3b"');
|
|
924
|
+
let config;
|
|
925
|
+
try {
|
|
926
|
+
config = loadConfig(root);
|
|
927
|
+
}
|
|
928
|
+
catch (err) {
|
|
929
|
+
return failBlock(err.message);
|
|
930
|
+
}
|
|
931
|
+
const lines = [];
|
|
932
|
+
let failed;
|
|
933
|
+
for (const id of ids) {
|
|
934
|
+
if (failed && input.action === "apply") {
|
|
935
|
+
lines.push(`FAIL ${id} reason: not applied, since ${failed} before it did not land`);
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
try {
|
|
939
|
+
lines.push(await landOne(root, config, id, input.action));
|
|
940
|
+
}
|
|
941
|
+
catch (err) {
|
|
942
|
+
lines.push(`FAIL ${id} reason: ${err.message}`);
|
|
943
|
+
failed = id;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
// the last of a batch's groups landed or discarded: its integration check runs now
|
|
947
|
+
const touched = new Set(ids.filter((id) => /^j[0-9a-z]{4}$/.test(id)).map((id) => readRecord(join(root, ".cheapag", "jobs", id))?.batch));
|
|
948
|
+
for (const batch of touched) {
|
|
949
|
+
const block = batch && (await integrateLater(root, config, batch, ctx));
|
|
950
|
+
if (block)
|
|
951
|
+
lines.push("", block);
|
|
952
|
+
}
|
|
953
|
+
return lines.join("\n");
|
|
954
|
+
}
|
|
955
|
+
/** A settled batch, still pending, with nothing left to land: runs its integration check and returns the batch block. */
|
|
956
|
+
async function integrateLater(root, config, batch, ctx) {
|
|
957
|
+
const file = join(root, ".cheapag", "batches", `${batch}.json`);
|
|
958
|
+
let record;
|
|
959
|
+
try {
|
|
960
|
+
record = JSON.parse(readFileSync(file, "utf8"));
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
return undefined; // cleaned up
|
|
964
|
+
}
|
|
965
|
+
const cmd = config.integration_cmd ?? config.test_cmd;
|
|
966
|
+
const jobRecords = record.jobs.map((id) => readRecord(join(root, ".cheapag", "jobs", id)));
|
|
967
|
+
if (batches.has(batch) || !cmd || !jobRecords.every((r) => r) || !allLanded(jobRecords))
|
|
968
|
+
return undefined;
|
|
969
|
+
// claimed under the lock: two sessions landing a batch's last groups at once must not both run its check
|
|
970
|
+
const claimed = await withLock(root, () => {
|
|
971
|
+
const now = JSON.parse(readFileSync(file, "utf8"));
|
|
972
|
+
if (now.integration !== "pending")
|
|
973
|
+
return false;
|
|
974
|
+
writeWhole(file, redact(JSON.stringify({ ...now, integration: "running" }, null, 2) + "\n"));
|
|
975
|
+
return true;
|
|
976
|
+
});
|
|
977
|
+
if (!claimed)
|
|
978
|
+
return undefined;
|
|
979
|
+
record.verify ??= []; // a record from before integration checks
|
|
980
|
+
let release;
|
|
981
|
+
let fix;
|
|
982
|
+
const fixer = async () => {
|
|
983
|
+
const gate = consentGate(root, ctx.provider.provider);
|
|
984
|
+
if ("block" in gate)
|
|
985
|
+
throw new Error(`no consent for a fix job: ${gate.block.split("\n")[0]}`);
|
|
986
|
+
const plan = parsePlan(readFileSync(join(root, record.plan), "utf8"));
|
|
987
|
+
const opencode = await ctx.opencode(gate.route);
|
|
988
|
+
release = holdOpencode();
|
|
989
|
+
return { root, input: { plan: record.plan, steps: record.steps }, plan, config, route: gate.route, opencode, provider: ctx.provider, progress: ctx.progress };
|
|
990
|
+
};
|
|
991
|
+
try {
|
|
992
|
+
await integrate(root, cmd, batch, record, jobRecords.map((r) => r.landed), () => writeWhole(file, redact(JSON.stringify(record, null, 2) + "\n")), fixer, (run) => (fix = run));
|
|
993
|
+
}
|
|
994
|
+
finally {
|
|
995
|
+
release?.();
|
|
996
|
+
}
|
|
997
|
+
return renderBatch(record, jobRecords, fix?.record());
|
|
998
|
+
}
|
|
999
|
+
/** Applies or discards one finished, unlanded job; returns its OK line, or throws why not. */
|
|
1000
|
+
async function landOne(root, config, id, action) {
|
|
1001
|
+
if (!/^j[0-9a-z]{4}$/.test(id))
|
|
1002
|
+
throw new Error(`${id} is not a job id, e.g. j7f3a`); // the id becomes a path
|
|
1003
|
+
const dir = join(root, ".cheapag", "jobs", id);
|
|
1004
|
+
const record = readRecord(dir);
|
|
1005
|
+
if (!record)
|
|
1006
|
+
throw new Error(`job ${id} not found`);
|
|
1007
|
+
if (running.has(id))
|
|
1008
|
+
throw new Error(`job ${id} is still ${record.status}; wait for it, or cancel it first`);
|
|
1009
|
+
if (record.status === "RUNNING" || record.status === "BLOCKED")
|
|
1010
|
+
throw new Error(`job ${id} is ${record.status} in another session (server pid ${record.pid ?? "unknown"})`);
|
|
1011
|
+
if (record.landed)
|
|
1012
|
+
throw new Error(`job ${id} already landed`);
|
|
1013
|
+
if (record.discarded)
|
|
1014
|
+
throw new Error(`job ${id} was already discarded`);
|
|
1015
|
+
// landing it now would put it ahead of its batch's later groups, out of step order
|
|
1016
|
+
if (record.batch && batches.has(record.batch))
|
|
1017
|
+
throw new Error(`job ${id} belongs to batch ${record.batch}, still running; land it once the batch settles (jobs wait)`);
|
|
1018
|
+
const worktree = jobWorktree(root, id, record.baseline?.tree ?? "", config.link_dirs);
|
|
1019
|
+
if (!worktree)
|
|
1020
|
+
throw new Error(`job ${id} has no worktree left (cleaned up); nothing to ${action}`);
|
|
1021
|
+
const save = (update) => {
|
|
1022
|
+
const next = { ...record, ...update, worktree: null };
|
|
1023
|
+
const text = renderJob(next);
|
|
1024
|
+
writeWhole(join(dir, "result.json"), redact(JSON.stringify({ ...next, blockTokensApprox: Math.ceil(text.length / 4) }, null, 2) + "\n"));
|
|
1025
|
+
writeWhole(join(dir, "result.txt"), redact(text + "\n"));
|
|
1026
|
+
};
|
|
1027
|
+
if (action === "discard") {
|
|
1028
|
+
removeWorktree(root, worktree); // dependency links are unlinked first
|
|
1029
|
+
save({ discarded: true });
|
|
1030
|
+
return `OK ${id} discarded`;
|
|
1031
|
+
}
|
|
1032
|
+
if (!record.baseline?.tree)
|
|
1033
|
+
throw new Error(`job ${id} has no recorded base (made by an older version); land it by hand from .cheapag/worktrees/${id}/`);
|
|
1034
|
+
const diff = worktreeDiff(worktree);
|
|
1035
|
+
// shortcut: two sessions applying the same job at once could both pass the checks above; the second 3-way apply then conflicts
|
|
1036
|
+
const clash = await land(root, id, diff.patch, diff.changed.map((c) => c.path));
|
|
1037
|
+
if (clash) {
|
|
1038
|
+
const files = conflictPaths(clash);
|
|
1039
|
+
throw new Error(`landing conflict${files.length ? ` in ${files.join(", ")}` : ""}, working tree untouched; discard it, or fix the overlap and delegate again`);
|
|
1040
|
+
}
|
|
1041
|
+
removeWorktree(root, worktree);
|
|
1042
|
+
save({ landed: true, changed: diff.changed });
|
|
1043
|
+
const add = diff.changed.reduce((n, c) => n + c.add, 0);
|
|
1044
|
+
const del = diff.changed.reduce((n, c) => n + c.del, 0);
|
|
1045
|
+
return `OK ${id} applied files=${diff.changed.length} +${add}/-${del}`;
|
|
1046
|
+
}
|
|
1047
|
+
const blocked = new Map();
|
|
1048
|
+
/** The jobs waiting for a reply right now, by job id (for the jobs tool and a batch's early return). */
|
|
1049
|
+
export const blockedJobs = () => blocked;
|
|
1050
|
+
/** `reply`: a letter picks that option's label, anything else is passed through as free text (spec D8). */
|
|
1051
|
+
export async function reply(input, progress) {
|
|
1052
|
+
const entry = blocked.get(input.job);
|
|
1053
|
+
if (!entry)
|
|
1054
|
+
return failBlock(notWaiting(input.job));
|
|
1055
|
+
if (!input.answer.trim())
|
|
1056
|
+
return failBlock("empty answer; reply with an option letter or free text"); // the job stays BLOCKED
|
|
1057
|
+
// one question also takes the numbered form ("1a", "1: text") that several questions need
|
|
1058
|
+
const numbered = /^\s*1\s*(?:[a-d]\)?\s*$|:)/i.test(input.answer);
|
|
1059
|
+
const answers = entry.questions.length === 1 && !numbered ? [pick(input.answer, entry.questions[0])] : answerEach(input.answer, entry.questions);
|
|
1060
|
+
if (typeof answers === "string")
|
|
1061
|
+
return failBlock(answers); // the job stays BLOCKED
|
|
1062
|
+
// in a batch, the same early return as delegate from here (spec: reply resolves like delegate): the first BLOCKED, or all settled
|
|
1063
|
+
const batch = batches.get(running.get(input.job)?.batch ?? "");
|
|
1064
|
+
const next = entry.resume(answers, batch ? batch.forward : progress);
|
|
1065
|
+
return batch ? batch.look(false, progress) : next;
|
|
1066
|
+
}
|
|
1067
|
+
/** A letter picks that option's label; anything else is free text. */
|
|
1068
|
+
function pick(answer, question) {
|
|
1069
|
+
const letter = answer.trim().toLowerCase().match(/^([a-d])\)?$/)?.[1];
|
|
1070
|
+
return (letter && question.options[letter.charCodeAt(0) - 97]) || answer;
|
|
1071
|
+
}
|
|
1072
|
+
/** Several questions take one answer each, "1a, 2b" or "1a, 2: free text" (commas allowed in free text); the error when any is left out. */
|
|
1073
|
+
function answerEach(answer, questions) {
|
|
1074
|
+
const answers = [];
|
|
1075
|
+
for (const part of answer.split(/,(?=\s*\d+\s*(?:[a-d]\)?\s*(?:,|$)|:))/i)) {
|
|
1076
|
+
const m = part.trim().match(/^(\d+)\s*(?:([a-d])\)?|:\s*([\s\S]*\S))$/i);
|
|
1077
|
+
const n = Number(m?.[1]) - 1;
|
|
1078
|
+
const option = m?.[2] && questions[n]?.options[m[2].toLowerCase().charCodeAt(0) - 97];
|
|
1079
|
+
if (!m || !questions[n] || answers[n] !== undefined || (m[2] && !option))
|
|
1080
|
+
return `cannot read "${part.trim()}" (or answered twice); answer each question as "1a, 2b" or "1a, 2: free text"`;
|
|
1081
|
+
answers[n] = option || m[3];
|
|
1082
|
+
}
|
|
1083
|
+
const missing = questions.map((_, i) => i + 1).filter((i) => !answers[i - 1]);
|
|
1084
|
+
return missing.length ? `unanswered: question ${missing.join(", ")}; answer each question as "1a, 2b" or "1a, 2: free text"` : answers;
|
|
1085
|
+
}
|
|
1086
|
+
/** Why `job` cannot take a reply, from its record on disk. */
|
|
1087
|
+
function notWaiting(job) {
|
|
1088
|
+
const file = join(projectRoot(process.cwd()), ".cheapag", "jobs", job, "result.json");
|
|
1089
|
+
if (!/^j[0-9a-z]{4}$/.test(job) || !existsSync(file))
|
|
1090
|
+
return `no job ${job}; reply only to a BLOCKED job's id`;
|
|
1091
|
+
const { status } = JSON.parse(readFileSync(file, "utf8"));
|
|
1092
|
+
return status === "BLOCKED"
|
|
1093
|
+
? `job ${job} is no longer waiting for a reply (already answered, or its session ended); delegate the step again`
|
|
1094
|
+
: `job ${job} is ${status}, not BLOCKED; only a BLOCKED job takes a reply`;
|
|
1095
|
+
}
|
|
1096
|
+
/** A fresh `j` + 4 base36 id whose job folder does not exist yet. */
|
|
1097
|
+
function newJob(root) {
|
|
1098
|
+
for (;;) {
|
|
1099
|
+
const job = newId("j");
|
|
1100
|
+
const dir = join(root, ".cheapag", "jobs", job);
|
|
1101
|
+
if (existsSync(dir))
|
|
1102
|
+
continue;
|
|
1103
|
+
mkdirSync(dir, { recursive: true });
|
|
1104
|
+
return { job, dir };
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
/** `prefix` + 4 base36 chars: a job (`j`) or batch (`b`) id. */
|
|
1108
|
+
const newId = (prefix) => `${prefix}${randomBytes(4).readUInt32BE().toString(36).slice(-4).padStart(4, "0")}`;
|
|
1109
|
+
/**
|
|
1110
|
+
* The prompt after the agent's own rules (spec E2): the whole plan, then the current step(s), then the note.
|
|
1111
|
+
* shortcut: no repo map or CLAUDE.md/AGENTS.md excerpt yet; add them when worker quality needs the context.
|
|
1112
|
+
*/
|
|
1113
|
+
function contextPack(plan, steps, note, brief, source) {
|
|
1114
|
+
return [
|
|
1115
|
+
planText(plan),
|
|
1116
|
+
`>>> CURRENT STEP(S): ${steps.map((s) => s.id).join(", ")}\n\n${steps.map((s) => s.text).join("\n\n")}`,
|
|
1117
|
+
note && `Note: ${note}`,
|
|
1118
|
+
// start from what the scout found instead of re-exploring; an "unverified" repro is a lead, not a fact
|
|
1119
|
+
brief && `Scout brief (from research; start here, and check anything marked unverified):\n${brief}`,
|
|
1120
|
+
// the user's own words: the step is Claude's reading of them, and only the worker sees both (ticket 28)
|
|
1121
|
+
source && `Source (the user's request or the issue, verbatim; check the step against it and report disagreements in finish's disagree):\n${source}`,
|
|
1122
|
+
].filter(Boolean).join("\n\n");
|
|
1123
|
+
}
|
|
1124
|
+
const planText = (plan) => [`# ${plan.title}`, plan.context, ...plan.steps.map((s) => s.text)].filter(Boolean).join("\n\n");
|
|
1125
|
+
/** The integration fix job's prompt: the whole plan, then which landed steps fail the check together, and its error tail. */
|
|
1126
|
+
function fixPrompt(plan, steps, fix) {
|
|
1127
|
+
const ids = steps.map((s) => s.id).join(", ");
|
|
1128
|
+
return [
|
|
1129
|
+
planText(plan),
|
|
1130
|
+
`>>> INTEGRATION FIX: ${ids}`,
|
|
1131
|
+
`Steps ${ids} were done by separate jobs and have all landed in this code, but together they fail \`${fix.cmd}\`. ` +
|
|
1132
|
+
"Fix the cause so it passes, keeping every step's intent, then call finish.",
|
|
1133
|
+
fix.error.join("\n"),
|
|
1134
|
+
].join("\n\n");
|
|
1135
|
+
}
|
|
1136
|
+
/** A redo diff longer than this is cut in its prompt. */
|
|
1137
|
+
const MAX_REDO_PATCH = 50_000;
|
|
1138
|
+
/** The redo's prompt to the same session: what clashed, where the conflict markers are, the step again and the original diff. */
|
|
1139
|
+
function redoPrompt(steps, files, marked, patch) {
|
|
1140
|
+
return [
|
|
1141
|
+
`>>> REDO STEP(S): ${steps.map((s) => s.id).join(", ")}`,
|
|
1142
|
+
`Your change could not land: the working tree changed meanwhile (${files.join(", ")}). This worktree was rebuilt from the current code, ` +
|
|
1143
|
+
`with your change applied again where it could be. Files where both changed the same lines hold <<<<<<< ======= >>>>>>> conflict markers: ` +
|
|
1144
|
+
`${marked.join(", ") || "none"}. Keep the current code's changes, redo the step's intent on top of them, leave no conflict markers, then call finish again.`,
|
|
1145
|
+
steps.map((s) => s.text).join("\n\n"),
|
|
1146
|
+
// shortcut: a huge diff is cut here; the worktree still holds the change wherever it applied
|
|
1147
|
+
`Your original change:\n\`\`\`diff\n${patch.length > MAX_REDO_PATCH ? `${patch.slice(0, MAX_REDO_PATCH)}\n… (cut)` : patch}\`\`\``,
|
|
1148
|
+
].join("\n\n");
|
|
1149
|
+
}
|
|
1150
|
+
/** Keeps well-formed `path:start-end` ranges for files that exist under `base`, at most 5 (the server validates, not the worker). */
|
|
1151
|
+
function reviewRanges(review, base) {
|
|
1152
|
+
return review
|
|
1153
|
+
.map((r) => r.match(/^(.+):(\d+)(?:-(\d+))?$/))
|
|
1154
|
+
.filter((m) => !!m && inside(base, join(base, m[1])) && existsSync(join(base, m[1])))
|
|
1155
|
+
.map((m) => ({ path: m[1], start: Number(m[2]), end: Number(m[3] ?? m[2]) }))
|
|
1156
|
+
.slice(0, 5);
|
|
1157
|
+
}
|
|
1158
|
+
const inside = (root, path) => {
|
|
1159
|
+
const rel = relative(root, path);
|
|
1160
|
+
return rel !== "" && !rel.startsWith(`..${"/"}`) && rel !== ".." && !isAbsolute(rel);
|
|
1161
|
+
};
|
|
1162
|
+
/**
|
|
1163
|
+
* Writes a file whole: to a temporary name, then renamed into place. Records are rewritten on every turn while other
|
|
1164
|
+
* processes read them (`cheapag clean`, another session's `jobs list`); a half-written record must never be seen.
|
|
1165
|
+
*/
|
|
1166
|
+
function writeWhole(path, text) {
|
|
1167
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
1168
|
+
writeFileSync(temp, text);
|
|
1169
|
+
renameSync(temp, path);
|
|
1170
|
+
}
|