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/verify.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { killProcessGroup } from "./platform.js";
|
|
3
|
+
// a backstop per command; the job's minute cap (its `signal`) bounds the whole job
|
|
4
|
+
const COMMAND_TIMEOUT_MS = 5 * 60_000;
|
|
5
|
+
const OUTPUT_LIMIT = 1024 * 1024;
|
|
6
|
+
/** The backticked fragments of each step's `done-when`, in order; else the configured test_cmd; else none. */
|
|
7
|
+
export function verifyCommands(doneWhen, testCmd) {
|
|
8
|
+
const backticked = doneWhen.flatMap((text) => [...text.matchAll(/`([^`]+)`/g)].map((m) => m[1].trim())).filter(Boolean);
|
|
9
|
+
return backticked.length ? backticked : testCmd ? [testCmd] : [];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Runs the commands in order in `cwd`, stopping at the first failure; `onStart` hears each command as it begins.
|
|
13
|
+
* Aborting `signal` kills the running command's process group (cap or cancel) and fails it.
|
|
14
|
+
*/
|
|
15
|
+
export async function runVerify(cwd, cmds, onStart, signal) {
|
|
16
|
+
const runs = [];
|
|
17
|
+
for (const cmd of cmds) {
|
|
18
|
+
if (signal?.aborted)
|
|
19
|
+
break;
|
|
20
|
+
onStart(cmd);
|
|
21
|
+
const run = await runOne(cwd, cmd, signal);
|
|
22
|
+
runs.push(run);
|
|
23
|
+
if (!run.ok)
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
return runs;
|
|
27
|
+
}
|
|
28
|
+
function runOne(cwd, cmd, signal) {
|
|
29
|
+
const started = Date.now();
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
// its own process group, so everything it spawned can be stopped with it
|
|
32
|
+
const child = spawn("sh", ["-c", cmd], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, CI: "1", FORCE_COLOR: "0" } });
|
|
33
|
+
const killGroup = () => killProcessGroup(child.pid, "SIGKILL");
|
|
34
|
+
let output = "";
|
|
35
|
+
let code = null;
|
|
36
|
+
// keep the end: failures and summaries come last
|
|
37
|
+
const collect = (chunk) => (output = (output + chunk).slice(-OUTPUT_LIMIT));
|
|
38
|
+
child.stdout.setEncoding("utf8").on("data", collect);
|
|
39
|
+
child.stderr.setEncoding("utf8").on("data", collect);
|
|
40
|
+
const timer = setTimeout(() => {
|
|
41
|
+
output += `\n(cheapag: stopped after ${COMMAND_TIMEOUT_MS / 60_000} min)`;
|
|
42
|
+
killGroup();
|
|
43
|
+
}, COMMAND_TIMEOUT_MS);
|
|
44
|
+
const halt = () => {
|
|
45
|
+
output += "\n(cheapag: stopped, job cap or cancel)";
|
|
46
|
+
killGroup();
|
|
47
|
+
};
|
|
48
|
+
signal?.addEventListener("abort", halt, { once: true });
|
|
49
|
+
// a backgrounded child (`server &`) would hold the pipes open: when the shell exits, stop its whole group
|
|
50
|
+
child.on("exit", (exitCode) => {
|
|
51
|
+
code = exitCode;
|
|
52
|
+
killGroup();
|
|
53
|
+
});
|
|
54
|
+
child.on("close", () => {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
signal?.removeEventListener("abort", halt);
|
|
57
|
+
resolve({ cmd, ok: code === 0 && !signal?.aborted, ms: Date.now() - started, output });
|
|
58
|
+
});
|
|
59
|
+
child.on("error", (err) => {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
signal?.removeEventListener("abort", halt);
|
|
62
|
+
resolve({ cmd, ok: false, ms: Date.now() - started, output: err.message });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const MARKER = /✗|✘|×|\bFAIL\b|Error:|error TS\d+|AssertionError|Traceback|panicked|^E\s{2,}/;
|
|
67
|
+
const DETAIL = /expected|received|\bgot\b|\bwant\b|^\s*[-+] /i;
|
|
68
|
+
const FRAME = /^\s*(at |File ")|\.(ts|tsx|js|mjs|cjs|py|go|rs):\d+/;
|
|
69
|
+
// pass lines and progress only: a line that also carries a failure marker is never noise
|
|
70
|
+
const NOISE = /^\s*(✓|√|PASS\b|ok\b)|^\s*(Tests?|Test Files|Suites?)\b.*\bpassed\b|^\s*[.·•⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]+$/;
|
|
71
|
+
const MAX_FAILURES = 3;
|
|
72
|
+
const MAX_LINES = 25;
|
|
73
|
+
/**
|
|
74
|
+
* Spec D9: the failing command, then up to 3 distinct failures (similar ones counted ×N), each with its message,
|
|
75
|
+
* expected/received lines and first in-repo frame; at most 25 lines of 200 chars, with `root` made repo-relative.
|
|
76
|
+
* Falls back to the last lines when no failure marker is found.
|
|
77
|
+
*/
|
|
78
|
+
export function errorTail(run, root) {
|
|
79
|
+
const lines = run.output
|
|
80
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
81
|
+
.split(`${root}/`).join("")
|
|
82
|
+
.split(/\r?\n/)
|
|
83
|
+
.map((l) => l.replace(/\t/g, " ").trimEnd())
|
|
84
|
+
.filter((l) => l.trim() && (MARKER.test(l) || !NOISE.test(l)));
|
|
85
|
+
const failures = [];
|
|
86
|
+
for (let i = 0; i < lines.length; i++) {
|
|
87
|
+
if (!MARKER.test(lines[i]))
|
|
88
|
+
continue;
|
|
89
|
+
const block = [lines[i].trim()];
|
|
90
|
+
let frame = false;
|
|
91
|
+
let j = i + 1;
|
|
92
|
+
// a runner's headline ("FAIL a > b") and its error ("AssertionError: …") on adjacent lines are one failure
|
|
93
|
+
if (j < lines.length && MARKER.test(lines[j]) && block[0].length + lines[j].length < 400 && /^\s*(FAIL|✗|×)/.test(lines[i]) && /Error/.test(lines[j])) {
|
|
94
|
+
block.push(` ${lines[j].trim()}`);
|
|
95
|
+
j++;
|
|
96
|
+
}
|
|
97
|
+
for (; j < lines.length && j <= i + 12 && !MARKER.test(lines[j]); j++) {
|
|
98
|
+
if (DETAIL.test(lines[j]) && block.length < 5)
|
|
99
|
+
block.push(` ${lines[j].trim()}`);
|
|
100
|
+
else if (!frame && FRAME.test(lines[j]) && !/node_modules|node:internal|site-packages/.test(lines[j])) {
|
|
101
|
+
block.push(` ${lines[j].trim()}`);
|
|
102
|
+
frame = true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
i = Math.max(i, j - 1);
|
|
106
|
+
const key = block[0].replace(/\d+/g, "#");
|
|
107
|
+
const same = failures.find((f) => f.key === key);
|
|
108
|
+
if (same)
|
|
109
|
+
same.count++;
|
|
110
|
+
else
|
|
111
|
+
failures.push({ key, lines: block, count: 1 });
|
|
112
|
+
}
|
|
113
|
+
const body = failures.length
|
|
114
|
+
? failures.slice(0, MAX_FAILURES).flatMap((f) => [f.count > 1 ? `${f.lines[0]} (×${f.count})` : f.lines[0], ...f.lines.slice(1)])
|
|
115
|
+
: lines.slice(-(MAX_LINES - 1));
|
|
116
|
+
return [`\`${run.cmd}\``, ...body.slice(0, MAX_LINES - 1).map((l) => clip(` ${l}`))];
|
|
117
|
+
}
|
|
118
|
+
/** Whether two tails show the same failure, ignoring numbers (timings, counts, ports). */
|
|
119
|
+
export const sameFailure = (a, b) => a.join("\n").replace(/\d+/g, "#") === b.join("\n").replace(/\d+/g, "#");
|
|
120
|
+
/** At most 200 chars, eliding the middle. */
|
|
121
|
+
const clip = (line) => (line.length <= 200 ? line : `${line.slice(0, 120)} … ${line.slice(-77)}`);
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { createOpencodeClient } from "@opencode-ai/sdk/v2";
|
|
2
|
+
import { rejectionMessage } from "./permissions.js";
|
|
3
|
+
import { modelName } from "./routing.js";
|
|
4
|
+
/** Worker: one OpenCode session for one job, reduced to what the job layer needs. */
|
|
5
|
+
export const WORKER_AGENT = "cheapag-worker";
|
|
6
|
+
/** How long an aborted prompt may take to go idle before its run is ended anyway. */
|
|
7
|
+
const ABORT_GRACE_MS = 10_000;
|
|
8
|
+
/**
|
|
9
|
+
* Opens one worker session. `run` prompts it (optionally on another model, e.g. escalation) and resolves when the
|
|
10
|
+
* session goes idle; self-fixes and escalation are further `run`s of the same session.
|
|
11
|
+
*/
|
|
12
|
+
export async function openSession(opts) {
|
|
13
|
+
const { opencode, directory } = opts;
|
|
14
|
+
const client = createOpencodeClient({
|
|
15
|
+
baseUrl: opencode.url,
|
|
16
|
+
directory,
|
|
17
|
+
headers: { authorization: `Basic ${Buffer.from(`opencode:${opencode.password}`).toString("base64")}` },
|
|
18
|
+
});
|
|
19
|
+
const created = await client.session.create({
|
|
20
|
+
directory, agent: opts.agent ?? WORKER_AGENT, model: { providerID: opts.model.providerID, id: opts.model.id }, ...(opts.permission && { permission: opts.permission }),
|
|
21
|
+
});
|
|
22
|
+
if (!created.data)
|
|
23
|
+
throw new Error(`could not create a worker session: ${JSON.stringify(created.error)}`);
|
|
24
|
+
const sessionID = created.data.id;
|
|
25
|
+
const calls = new Set();
|
|
26
|
+
const messages = new Map(); // assistant message id → its model and latest running totals
|
|
27
|
+
const rejections = [];
|
|
28
|
+
/**
|
|
29
|
+
* `interrupt` ends this prompt only (the session stays usable, e.g. to hand it to another model); `opts.signal` ends the
|
|
30
|
+
* session. `afterStop` runs one more prompt on a stopped session (the findings note), bounded by `interrupt` alone.
|
|
31
|
+
*/
|
|
32
|
+
async function run(prompt, model, interrupt, afterStop = false) {
|
|
33
|
+
const signal = afterStop ? undefined : opts.signal;
|
|
34
|
+
if (signal?.aborted)
|
|
35
|
+
return { text: "", error: "aborted" };
|
|
36
|
+
const abort = new AbortController();
|
|
37
|
+
let backstop;
|
|
38
|
+
const stop = () => {
|
|
39
|
+
void client.session.abort({ sessionID, directory }).catch(() => { });
|
|
40
|
+
// live, an abort ends in session.idle; if it never comes, drop the stream so the run cannot hang
|
|
41
|
+
backstop = setTimeout(() => abort.abort(), ABORT_GRACE_MS);
|
|
42
|
+
};
|
|
43
|
+
// the SDK's stream only connects when first read: wait for server.connected before prompting, or early events are lost
|
|
44
|
+
const { stream } = await client.event.subscribe({ directory }, { signal: abort.signal });
|
|
45
|
+
await stream.next();
|
|
46
|
+
try {
|
|
47
|
+
const texts = new Map(); // text part id → latest text
|
|
48
|
+
let idle = false;
|
|
49
|
+
let finish;
|
|
50
|
+
let error;
|
|
51
|
+
let httpStatus;
|
|
52
|
+
let limit;
|
|
53
|
+
const prompted = await client.session.promptAsync({
|
|
54
|
+
sessionID, directory, parts: [{ type: "text", text: prompt }],
|
|
55
|
+
...(model && { model: { providerID: model.providerID, modelID: model.id } }),
|
|
56
|
+
});
|
|
57
|
+
if (prompted.error)
|
|
58
|
+
throw new Error(`could not prompt the worker: ${JSON.stringify(prompted.error)}`);
|
|
59
|
+
// after the prompt, so an abort can never race ahead of it
|
|
60
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
61
|
+
if (signal?.aborted)
|
|
62
|
+
stop();
|
|
63
|
+
interrupt?.addEventListener("abort", stop, { once: true });
|
|
64
|
+
if (interrupt?.aborted)
|
|
65
|
+
stop();
|
|
66
|
+
for await (const raw of stream) {
|
|
67
|
+
const event = raw;
|
|
68
|
+
const props = event.properties;
|
|
69
|
+
if (props.sessionID !== sessionID)
|
|
70
|
+
continue;
|
|
71
|
+
if (event.type === "message.part.updated") {
|
|
72
|
+
const part = event.properties.part;
|
|
73
|
+
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
|
|
74
|
+
// shortcut: OpenCode 1.19's wording for a call its ruleset denied; update if a new pin rewords it
|
|
75
|
+
if (!calls.has(part.callID) && part.state.status === "error" && part.state.error.startsWith("The user has specified a rule which prevents")) {
|
|
76
|
+
rejections.push({ kind: "denied", permission: part.tool, what: describeCall(part.state.input) });
|
|
77
|
+
}
|
|
78
|
+
calls.add(part.callID);
|
|
79
|
+
if (part.tool === "finish" && part.state.status === "completed")
|
|
80
|
+
finish = asReport(part.state.input);
|
|
81
|
+
}
|
|
82
|
+
if (part.type === "text")
|
|
83
|
+
texts.set(part.id, { messageID: part.messageID, text: part.text });
|
|
84
|
+
}
|
|
85
|
+
else if (event.type === "message.updated" && event.properties.info.role === "assistant") {
|
|
86
|
+
const { id, cost, tokens, error: failed, providerID, modelID } = event.properties.info;
|
|
87
|
+
// seen live on 1.19.0: some failures (e.g. abort) arrive only on the assistant message, with no session.error
|
|
88
|
+
if (failed) {
|
|
89
|
+
error ??= describe(failed);
|
|
90
|
+
httpStatus ??= statusOf(failed);
|
|
91
|
+
}
|
|
92
|
+
messages.set(id, {
|
|
93
|
+
model: `${providerID}/${modelID}`,
|
|
94
|
+
inputTokens: tokens.input, outputTokens: tokens.output, reasoningTokens: tokens.reasoning,
|
|
95
|
+
cacheRead: tokens.cache.read, cacheWrite: tokens.cache.write, costUsd: cost,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
else if (event.type === "permission.asked") {
|
|
99
|
+
// an unanswered ask blocks forever in server mode: reject every one, with a reason the worker sees
|
|
100
|
+
const what = event.properties.patterns.join(" ") || event.properties.permission;
|
|
101
|
+
rejections.push({ kind: "asked", permission: event.properties.permission, what });
|
|
102
|
+
const replied = await client.permission
|
|
103
|
+
.reply({ requestID: event.properties.id, directory, reply: "reject", message: rejectionMessage(what) })
|
|
104
|
+
.catch((err) => ({ error: err.message }));
|
|
105
|
+
// a reject that did not go through would leave the session blocked: end this run as a worker error instead
|
|
106
|
+
if (replied.error) {
|
|
107
|
+
error ??= `could not reject a ${event.properties.permission} permission request: ${JSON.stringify(replied.error)}`;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else if (event.type === "question.asked") {
|
|
112
|
+
opts.onEvent(event, calls.size); // logged now: the answer may be a long way off
|
|
113
|
+
const questions = event.properties.questions.map((q) => ({ question: q.question, options: q.options.map((o) => o.label) }));
|
|
114
|
+
const requestID = event.properties.id;
|
|
115
|
+
// a stop (cap or cancel) while waiting ends the wait: the question is rejected and the session aborted
|
|
116
|
+
const stopped = new Promise((resolve) => (signal?.aborted ? resolve(undefined) : signal?.addEventListener("abort", () => resolve(undefined), { once: true })));
|
|
117
|
+
// a question asked after a stop is never put to Claude: the job is ending, not blocked
|
|
118
|
+
const answers = opts.onQuestion && questions.length && !signal?.aborted ? await Promise.race([opts.onQuestion(questions), stopped]) : undefined;
|
|
119
|
+
// nowhere to put the question (a parallel group until early return exists, or the login check): it is rejected, and said so
|
|
120
|
+
if (!opts.onQuestion && questions.length)
|
|
121
|
+
rejections.push({ kind: "asked", permission: "question", what: questions.map((q) => q.question).join(" / ") });
|
|
122
|
+
const replied = await (answers === undefined
|
|
123
|
+
? client.question.reject({ requestID, directory })
|
|
124
|
+
: client.question.reply({ requestID, directory, answers: answers.map((a) => [a]) })).catch((err) => ({ error: err.message }));
|
|
125
|
+
// an answer that did not go through would leave the session paused forever: end this run as a worker error instead
|
|
126
|
+
if (replied.error) {
|
|
127
|
+
error ??= `could not answer the worker's question: ${JSON.stringify(replied.error)}`;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
else if (event.type === "session.error") {
|
|
133
|
+
error = describe(event.properties.error);
|
|
134
|
+
httpStatus = statusOf(event.properties.error);
|
|
135
|
+
}
|
|
136
|
+
else if (event.type === "session.status" && event.properties.status.type === "retry" && !limit && isLimit(event.properties.status, (model ?? opts.model).providerID)) {
|
|
137
|
+
// a provider limit or rate limit: OpenCode would retry, honouring retry-after, possibly for hours. Stop now, so the
|
|
138
|
+
// job can fall back; this aborts the session directly, not through the job's stop signal (which means a cap)
|
|
139
|
+
const { action, message, next } = event.properties.status;
|
|
140
|
+
limit = { model: modelName(model ?? opts.model), reason: action?.reason, message: action?.message ?? message, next };
|
|
141
|
+
stop();
|
|
142
|
+
}
|
|
143
|
+
opts.onEvent(event, calls.size);
|
|
144
|
+
if (event.type === "session.idle") {
|
|
145
|
+
idle = true;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!idle)
|
|
150
|
+
error ??= "worker event stream ended before the session went idle";
|
|
151
|
+
// the fallback block is read from this prompt's last message only, so a stale one from earlier cannot win
|
|
152
|
+
const last = [...texts.values()].at(-1)?.messageID;
|
|
153
|
+
const text = [...texts.values()].filter((t) => t.messageID === last).map((t) => t.text).join("\n");
|
|
154
|
+
// an interrupted prompt ends on OpenCode's abort error; that is the hand-over, not a worker failure
|
|
155
|
+
if (interrupt?.aborted && !signal?.aborted)
|
|
156
|
+
return { finish, text, interrupted: true };
|
|
157
|
+
return { finish, text, error, ...(httpStatus && { httpStatus }), ...(limit && { limit }) };
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
signal?.removeEventListener("abort", stop);
|
|
161
|
+
interrupt?.removeEventListener("abort", stop);
|
|
162
|
+
clearTimeout(backstop);
|
|
163
|
+
abort.abort();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function totals() {
|
|
167
|
+
const usage = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };
|
|
168
|
+
const spend = {};
|
|
169
|
+
for (const m of messages.values()) {
|
|
170
|
+
for (const key of Object.keys(usage))
|
|
171
|
+
usage[key] += m[key];
|
|
172
|
+
spend[m.model] = (spend[m.model] ?? 0) + m.costUsd;
|
|
173
|
+
}
|
|
174
|
+
return { sessionID, turns: calls.size, usage, rejections: [...rejections], spend };
|
|
175
|
+
}
|
|
176
|
+
return { sessionID, run, totals };
|
|
177
|
+
}
|
|
178
|
+
/** One prompt in a fresh session (the login live check). */
|
|
179
|
+
export async function runSession(opts) {
|
|
180
|
+
const session = await openSession(opts);
|
|
181
|
+
const result = await session.run(opts.prompt);
|
|
182
|
+
return { ...session.totals(), ...result };
|
|
183
|
+
}
|
|
184
|
+
/** OpenCode validates the tool's args, but the record must not trust a malformed one either. */
|
|
185
|
+
function asReport(input) {
|
|
186
|
+
const list = (v) => (Array.isArray(v) ? v.map(String) : typeof v === "string" && v ? [v] : []);
|
|
187
|
+
return { summary: String(input.summary ?? ""), risks: list(input.risks), review: list(input.review), missing: list(input.missing), ...(typeof input.repro === "string" && input.repro.trim() && { repro: input.repro.trim() }), ...(typeof input.disagree === "string" && input.disagree.trim() && { disagree: input.disagree.trim() }) };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* A retry that means a limit, not a blip: a Go account or free-tier limit, any OpenRouter retry (its 429s), or a retry
|
|
191
|
+
* scheduled more than a minute out. A transient 5xx retry on Go or DeepSeek is left to OpenCode's own quick retries.
|
|
192
|
+
*/
|
|
193
|
+
function isLimit(status, providerID) {
|
|
194
|
+
return ["account_rate_limit", "free_tier_limit"].includes(status.action?.reason ?? "") || providerID === "openrouter" || status.next - Date.now() > 60_000;
|
|
195
|
+
}
|
|
196
|
+
/** The command or path a tool call targeted, for rejection records. */
|
|
197
|
+
function describeCall(input) {
|
|
198
|
+
return String(input.command ?? input.filePath ?? input.url ?? input.pattern ?? JSON.stringify(input)).slice(0, 200);
|
|
199
|
+
}
|
|
200
|
+
function describe(err) {
|
|
201
|
+
const e = err;
|
|
202
|
+
return e?.data?.message ?? e?.name ?? "unknown worker error";
|
|
203
|
+
}
|
|
204
|
+
/** The HTTP status of a provider error (APIError), e.g. OpenRouter's 402 for insufficient credits. */
|
|
205
|
+
const statusOf = (err) => err?.data?.statusCode;
|
|
206
|
+
const NO_REPORT = { summary: "(worker gave no summary)", risks: ["worker did not report; review diff"], review: [], missing: [] };
|
|
207
|
+
/** The finish tool's report; else the last fenced `cheapag-result` block, parsed leniently; else a placeholder. */
|
|
208
|
+
export function finishReport(result) {
|
|
209
|
+
if (result.finish)
|
|
210
|
+
return result.finish;
|
|
211
|
+
const block = [...result.text.matchAll(/```cheapag-result\s*\n([\s\S]*?)```/g)].at(-1)?.[1];
|
|
212
|
+
if (!block)
|
|
213
|
+
return NO_REPORT;
|
|
214
|
+
const report = { summary: "", risks: [], review: [], missing: [] };
|
|
215
|
+
let list;
|
|
216
|
+
for (const line of block.split("\n")) {
|
|
217
|
+
const kv = line.match(/^(summary|risks|review|missing|repro|disagree):\s*(.*)$/);
|
|
218
|
+
if (kv) {
|
|
219
|
+
const [, key, value] = kv;
|
|
220
|
+
if (key === "summary" || key === "repro" || key === "disagree") {
|
|
221
|
+
if (value.trim())
|
|
222
|
+
report[key] = value.trim();
|
|
223
|
+
list = undefined;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
list = key;
|
|
227
|
+
// inline values: "review: a.ts:1-2, b.ts:3-4"
|
|
228
|
+
if (value.trim())
|
|
229
|
+
report[list].push(...value.split(",").map((v) => v.trim()).filter(Boolean));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else if (list && /^\s*-\s+/.test(line)) {
|
|
233
|
+
report[list].push(line.replace(/^\s*-\s+/, "").trim());
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { ...report, summary: report.summary || NO_REPORT.summary };
|
|
237
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { copyFileSync, cpSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join, relative } from "node:path";
|
|
5
|
+
import { isAlive, link, normalisePath, removeLink } from "./platform.js";
|
|
6
|
+
export function git(cwd, args, env) {
|
|
7
|
+
return execFileSync("git", args, { cwd, stdio: "pipe", env: env && { ...process.env, ...env }, maxBuffer: 256 * 1024 * 1024 }).toString();
|
|
8
|
+
}
|
|
9
|
+
/** The repository root containing `cwd`; throws when it is not a git repository. */
|
|
10
|
+
export function projectRoot(cwd) {
|
|
11
|
+
try {
|
|
12
|
+
return git(cwd, ["rev-parse", "--show-toplevel"]).trim();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new Error(`${cwd} is not inside a git repository`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A detached worktree under `.cheapag/worktrees/<job>` holding the developer's current state (ADR-0003):
|
|
20
|
+
* HEAD, plus uncommitted tracked changes (a `git stash create` snapshot, which leaves the checkout and the stash list
|
|
21
|
+
* alone), plus copies of untracked non-ignored files such as a freshly written plan. Dependency directories from
|
|
22
|
+
* `linkDirs` are linked in from the main checkout. Nothing in the main checkout is modified.
|
|
23
|
+
*/
|
|
24
|
+
export function createWorktree(root, job, linkDirs) {
|
|
25
|
+
const head = git(root, ["rev-parse", "HEAD"]).trim();
|
|
26
|
+
const stash = git(root, ["stash", "create"]).trim() || null;
|
|
27
|
+
const path = join(root, ".cheapag", "worktrees", job);
|
|
28
|
+
git(root, ["worktree", "add", "--detach", path, head]);
|
|
29
|
+
const links = [];
|
|
30
|
+
try {
|
|
31
|
+
if (stash)
|
|
32
|
+
git(path, ["stash", "apply", "--quiet", stash]);
|
|
33
|
+
// untracked dependency dirs are linked below, never copied
|
|
34
|
+
const untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z", "--", ".", ...outside(linkDirs)]).split("\0").filter(Boolean);
|
|
35
|
+
// an entry ending in "/" is an untracked nested repository: not part of this repo, so not carried over
|
|
36
|
+
// (git would refuse to index it, or record it as a gitlink that would then land)
|
|
37
|
+
for (const file of untracked.filter((f) => !f.endsWith("/"))) {
|
|
38
|
+
mkdirSync(dirname(join(path, file)), { recursive: true });
|
|
39
|
+
cpSync(join(root, file), join(path, file), { verbatimSymlinks: true });
|
|
40
|
+
}
|
|
41
|
+
for (const dir of linkDirs) {
|
|
42
|
+
const source = join(root, dir);
|
|
43
|
+
const target = join(path, dir);
|
|
44
|
+
// tracked dependency dirs (e.g. a committed vendor/) are already in the worktree, and stay part of the diff
|
|
45
|
+
if (!isDirectory(source) || existsSync(target))
|
|
46
|
+
continue;
|
|
47
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
48
|
+
link(source, target);
|
|
49
|
+
links.push(target);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
discard(root, path, links);
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
const linked = links.map((l) => normalisePath(relative(path, l)));
|
|
57
|
+
// the carried-over state as a tree object: the job's baseline, without committing anything
|
|
58
|
+
git(path, ["add", "-A", "--", ".", ...outside(linked)]);
|
|
59
|
+
const base = git(path, ["write-tree"]).trim();
|
|
60
|
+
return { path, head, stash, base, links, linked };
|
|
61
|
+
}
|
|
62
|
+
function discard(root, path, links) {
|
|
63
|
+
for (const l of links)
|
|
64
|
+
removeLink(l); // unlink first, so nothing can reach through into the real dirs
|
|
65
|
+
try {
|
|
66
|
+
git(root, ["worktree", "remove", "--force", path]);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// git no longer knows it (pruned by hand): rm never follows symlinks
|
|
70
|
+
rmSync(path, { recursive: true, force: true });
|
|
71
|
+
git(root, ["worktree", "prune"]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Pathspecs that keep Cheap Agent's state and the given dependency dirs out of a git operation. */
|
|
75
|
+
const outside = (dirs) => [".cheapag", ...dirs].map((d) => `:(exclude,top,literal)${d}`);
|
|
76
|
+
const isDirectory = (path) => {
|
|
77
|
+
try {
|
|
78
|
+
return statSync(path).isDirectory();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
/** Everything the job changed relative to its baseline tree, as a binary patch plus per-file stats. */
|
|
85
|
+
export function worktreeDiff(worktree) {
|
|
86
|
+
const { path, base } = worktree;
|
|
87
|
+
// the worktree's own index: throwaway, never committed. A linked dir is a symlink, which a `node_modules/`
|
|
88
|
+
// ignore rule does not match, so the linked ones (only those: a tracked vendor/ stays in) are excluded.
|
|
89
|
+
git(path, ["add", "-A", "--", ".", ...outside(worktree.linked)]);
|
|
90
|
+
const patch = git(path, ["diff", "--cached", "--binary", base]);
|
|
91
|
+
const stats = git(path, ["diff", "--cached", "--numstat", "-z", "--no-renames", base]).split("\0").filter(Boolean);
|
|
92
|
+
const statuses = new Map();
|
|
93
|
+
const names = git(path, ["diff", "--cached", "--name-status", "-z", "--no-renames", base]).split("\0").filter(Boolean);
|
|
94
|
+
for (let i = 0; i < names.length; i += 2)
|
|
95
|
+
statuses.set(names[i + 1], names[i]);
|
|
96
|
+
const changed = stats.map((line) => {
|
|
97
|
+
const [add, del, file] = line.split("\t");
|
|
98
|
+
// binary files report "-" for both counts
|
|
99
|
+
return { status: statuses.get(file) ?? "M", path: file, add: Number(add) || 0, del: Number(del) || 0 };
|
|
100
|
+
});
|
|
101
|
+
return { patch, changed };
|
|
102
|
+
}
|
|
103
|
+
/** Why the checkout cannot be a job's starting point right now (a merge, rebase… or unresolved conflicts), if it cannot. */
|
|
104
|
+
export function operationInProgress(root) {
|
|
105
|
+
const markers = { MERGE_HEAD: "a merge", "rebase-merge": "a rebase", "rebase-apply": "a rebase", CHERRY_PICK_HEAD: "a cherry-pick", REVERT_HEAD: "a revert" };
|
|
106
|
+
for (const [marker, name] of Object.entries(markers)) {
|
|
107
|
+
if (existsSync(git(root, ["rev-parse", "--path-format=absolute", "--git-path", marker]).trim()))
|
|
108
|
+
return `${name} is in progress`;
|
|
109
|
+
}
|
|
110
|
+
// e.g. after a conflicted `git stash pop`: no marker file, but `git stash create` would fail
|
|
111
|
+
if (git(root, ["ls-files", "--unmerged"]).trim())
|
|
112
|
+
return "there are unresolved conflicts";
|
|
113
|
+
}
|
|
114
|
+
export function removeWorktree(root, worktree) {
|
|
115
|
+
discard(root, worktree.path, worktree.links);
|
|
116
|
+
}
|
|
117
|
+
/** How long landing waits for another holder. `CHEAPAG_LOCK_TIMEOUT_MS` is a test hook (like CHEAPAG_OPENCODE_URL), not a setting. */
|
|
118
|
+
const lockTimeoutMs = () => Number(process.env.CHEAPAG_LOCK_TIMEOUT_MS) || 10 * 60_000;
|
|
119
|
+
/** Serialises landing and integration checks across Cheap Agent processes on one project: `.cheapag/land.lock` holds pid + timestamp + token. */
|
|
120
|
+
export async function withLock(root, fn) {
|
|
121
|
+
const lock = join(root, ".cheapag", "land.lock");
|
|
122
|
+
const token = randomBytes(8).toString("hex");
|
|
123
|
+
// unique per call: parallel groups in one process wait on the lock at once, and must not delete each other's file
|
|
124
|
+
const mine = `${lock}.${process.pid}.${token}`;
|
|
125
|
+
mkdirSync(join(root, ".cheapag"), { recursive: true });
|
|
126
|
+
// written whole, then hard-linked into place: the lock never exists without its contents
|
|
127
|
+
writeFileSync(mine, JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token }));
|
|
128
|
+
const timeout = lockTimeoutMs();
|
|
129
|
+
const deadline = Date.now() + timeout;
|
|
130
|
+
try {
|
|
131
|
+
for (;;) {
|
|
132
|
+
try {
|
|
133
|
+
linkSync(mine, lock);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (err.code !== "EEXIST")
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
const holder = readHolder(lock); // gone: released meanwhile, so try again at once
|
|
141
|
+
if (!holder)
|
|
142
|
+
continue;
|
|
143
|
+
if (!(holder.pid > 0 && isAlive(holder.pid)))
|
|
144
|
+
breakStale(lock, holder);
|
|
145
|
+
else if (Date.now() > deadline) {
|
|
146
|
+
const waited = timeout >= 60_000 ? `${Math.round(timeout / 60_000)} min` : `${timeout / 1000} s`;
|
|
147
|
+
throw new Error(`timed out after ${waited} waiting for .cheapag/land.lock (held by pid ${holder.pid} since ${holder.at}); try again once it is released`);
|
|
148
|
+
}
|
|
149
|
+
else
|
|
150
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
rmSync(mine, { force: true });
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return await fn();
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
// only our own: never a lock someone else holds now
|
|
161
|
+
if (readHolder(lock)?.token === token)
|
|
162
|
+
rmSync(lock, { force: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Removes a lock whose holder is dead. The rename (to a name of our own) lets only one waiter break it. Between reading
|
|
167
|
+
* the dead holder and renaming, another waiter may have broken it and a live process taken it: then that lock goes back.
|
|
168
|
+
*/
|
|
169
|
+
function breakStale(lock, dead) {
|
|
170
|
+
const stale = `${lock}.stale.${randomBytes(4).toString("hex")}`;
|
|
171
|
+
try {
|
|
172
|
+
renameSync(lock, stale);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return; // another waiter already broke it
|
|
176
|
+
}
|
|
177
|
+
if (readHolder(stale)?.token !== dead.token) {
|
|
178
|
+
try {
|
|
179
|
+
linkSync(stale, lock);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// shortcut: a third process took the free name in the instant before the put-back, so two hold it; needs OS file locks (flock) to close
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
rmSync(stale, { force: true });
|
|
186
|
+
}
|
|
187
|
+
/** The lock's holder, or undefined when the file is gone. */
|
|
188
|
+
function readHolder(file) {
|
|
189
|
+
let text;
|
|
190
|
+
try {
|
|
191
|
+
text = readFileSync(file, "utf8");
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
return JSON.parse(text);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return { pid: 0, at: "unknown", token: text }; // never written partly, so not a Cheap Agent lock: no live holder
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const literal = (path) => `:(literal)${path}`;
|
|
204
|
+
/**
|
|
205
|
+
* 3-way applies `patch` to the user's working tree, uncommitted and leaving their index alone.
|
|
206
|
+
* Runs against a throwaway index synced to the working tree, so their unstaged edits are the merge base side.
|
|
207
|
+
* Returns undefined on success, or the conflict text with the tree untouched.
|
|
208
|
+
*/
|
|
209
|
+
export function land(root, job, patch, paths) {
|
|
210
|
+
return withLock(root, () => {
|
|
211
|
+
if (!patch.trim())
|
|
212
|
+
return undefined;
|
|
213
|
+
const index = join(root, ".cheapag", "jobs", job, "land.index");
|
|
214
|
+
copyFileSync(git(root, ["rev-parse", "--path-format=absolute", "--git-path", "index"]).trim(), index);
|
|
215
|
+
const env = { GIT_INDEX_FILE: index };
|
|
216
|
+
try {
|
|
217
|
+
// bring exactly the patch's paths into the throwaway index as they are on disk now, untracked files included:
|
|
218
|
+
// the job's baseline had them (carry-over), so the patch expects them
|
|
219
|
+
const tracked = new Set(git(root, ["ls-files", "-z", "--", ...paths.map(literal)], env).split("\0"));
|
|
220
|
+
const present = paths.filter((p) => tracked.has(p) || existsSync(join(root, p)));
|
|
221
|
+
if (present.length)
|
|
222
|
+
git(root, ["add", "-A", "--", ...present.map(literal)], env);
|
|
223
|
+
const check = applyOutput(root, ["apply", "--3way", "--check", "-"], env, patch);
|
|
224
|
+
// shortcut: `apply --3way --check` exits 0 even when it would leave conflict markers, so read its message; upgrade if git changes the wording
|
|
225
|
+
if (check.failed || /with conflicts/.test(check.output))
|
|
226
|
+
return check.output.trim() || "patch does not apply";
|
|
227
|
+
// shortcut: the lock does not stop the developer's editor; an edit to these files in the milliseconds since the check
|
|
228
|
+
// could still leave markers here. Upgrade by applying with --cached to the throwaway index and checking out its paths.
|
|
229
|
+
const applied = applyOutput(root, ["apply", "--3way", "-"], env, patch);
|
|
230
|
+
return applied.failed ? applied.output.trim() : undefined;
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
rmSync(index, { force: true });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/** git apply's verdict lives in stderr even when it exits 0. */
|
|
238
|
+
function applyOutput(root, args, env, patch) {
|
|
239
|
+
const r = spawnSync("git", args, { cwd: root, env: { ...process.env, ...env }, input: patch, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
240
|
+
return { failed: r.status !== 0, output: `${r.stdout}${r.stderr}` };
|
|
241
|
+
}
|
|
242
|
+
/** The paths a failed landing names as conflicting, from git apply's messages. */
|
|
243
|
+
export function conflictPaths(text) {
|
|
244
|
+
const named = [...text.matchAll(/^Applied patch to '(.+)' with conflicts\.$|^error: (.+?): (?:does not exist in index|does not match index|already exists in (?:index|working directory)|patch does not apply)$/gm)];
|
|
245
|
+
return [...new Set(named.map((m) => m[1] ?? m[2]))];
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* For a redo: 3-way applies a job's earlier patch onto its fresh worktree, so clashing hunks are left there as conflict
|
|
249
|
+
* markers for the worker to reconcile. Returns the paths left conflicted. Only the worktree (and its own index) changes.
|
|
250
|
+
*/
|
|
251
|
+
export function reapply(worktree, patch) {
|
|
252
|
+
applyOutput(worktree.path, ["apply", "--3way", "-"], {}, patch); // a patch that cannot apply at all leaves the worktree as it was
|
|
253
|
+
return git(worktree.path, ["diff", "--name-only", "-z", "--diff-filter=U"]).split("\0").filter(Boolean);
|
|
254
|
+
}
|
|
255
|
+
/** Paths whose changes still add conflict marker lines; run after `worktreeDiff`, which stages the worktree. */
|
|
256
|
+
export function leftoverMarkers(worktree) {
|
|
257
|
+
const r = spawnSync("git", ["diff", "--cached", "--check", worktree.base], { cwd: worktree.path, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
258
|
+
return [...new Set([...r.stdout.matchAll(/^(.+?):\d+: leftover conflict marker$/gm)].map((m) => m[1]))];
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The worktree a finished job left behind, for `land`: always `.cheapag/worktrees/<job>` (never a path read from a
|
|
262
|
+
* record), and only a real directory. The `linkDirs` entries that are links in it are its dependency links.
|
|
263
|
+
*/
|
|
264
|
+
export function jobWorktree(root, job, base, linkDirs) {
|
|
265
|
+
const path = join(root, ".cheapag", "worktrees", job);
|
|
266
|
+
if (!lstatOf(path)?.isDirectory())
|
|
267
|
+
return undefined;
|
|
268
|
+
const links = linkDirs.map((d) => join(path, d)).filter((l) => lstatOf(l)?.isSymbolicLink());
|
|
269
|
+
return { path, head: "", stash: null, base, links, linked: links.map((l) => normalisePath(relative(path, l))) };
|
|
270
|
+
}
|
|
271
|
+
const lstatOf = (path) => {
|
|
272
|
+
try {
|
|
273
|
+
return lstatSync(path);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
};
|