@stacknyu/pi-flow 0.0.0-stage → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +173 -2
- package/extension.ts +883 -0
- package/package.json +44 -3
- package/runner.mjs +1597 -0
- package/setup.mjs +310 -0
- package/skills/pi-flow/SKILL.md +20 -0
- package/skills/pi-flow/references/guide.md +80 -0
- package/skills/pi-flow/references/items.md +68 -0
- package/skills/pi-flow/references/manager.md +59 -0
- package/skills/pi-flow/references/troubleshooting.md +39 -0
- package/supervisor.mjs +185 -0
package/runner.mjs
ADDED
|
@@ -0,0 +1,1597 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pi-flow queue runner. Started in the background by `/queue start` or by the task handoff.
|
|
3
|
+
// Usage: node runner.mjs <repo-dir>
|
|
4
|
+
//
|
|
5
|
+
// Works through every unchecked "- [ ]" line of the queue file, `parallel` items at a time.
|
|
6
|
+
// Each item: its own git worktree + a fresh `pi -p` process. The runner (never the agent) checks
|
|
7
|
+
// scope, size and the check commands. Passing items are rebased onto the branch, re-checked,
|
|
8
|
+
// and fast-forwarded in one at a time. Failed work is kept on queue-failed/<id>.
|
|
9
|
+
import { spawn, execFile } from "node:child_process";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, appendFileSync, rmSync, cpSync, openSync, closeSync, renameSync, lstatSync, readlinkSync, realpathSync, symlinkSync } from "node:fs";
|
|
11
|
+
import { join, isAbsolute, basename, dirname, posix } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
export const DIR = ".pi-queue";
|
|
16
|
+
|
|
17
|
+
export const DEFAULTS = {
|
|
18
|
+
queue: "", // the work list; set by the task handoff (.tasks/<slug>/items.md)
|
|
19
|
+
task: "", // the task slug this run belongs to
|
|
20
|
+
allowedPaths: [], // the only folders/files items may change
|
|
21
|
+
checks: [], // commands that must pass after every item (verified by /task setup and the handoff)
|
|
22
|
+
readFirst: "AGENTS.md",
|
|
23
|
+
|
|
24
|
+
// Parallelism. Each running item gets its own git worktree under .pi-queue/worktrees.
|
|
25
|
+
// The runner adapts the width between parallelMin and parallel: one step down after a conflict,
|
|
26
|
+
// one step up after 3 clean passes in a row. Items whose [areas: …] overlap never run at the same time,
|
|
27
|
+
// and an item waits for the items named in its [after: …] tag.
|
|
28
|
+
worktrees: true,
|
|
29
|
+
parallel: 4,
|
|
30
|
+
parallelMin: 1,
|
|
31
|
+
parallelStart: 0, // 0: start at `parallel`
|
|
32
|
+
adaptive: true,
|
|
33
|
+
setup: "", // install command run once per new worktree, e.g. "pnpm install --frozen-lockfile --prefer-offline"
|
|
34
|
+
setupMinutes: 20,
|
|
35
|
+
copyFromRepo: [], // git-ignored files (e.g. ".env.local") copied into every worktree
|
|
36
|
+
|
|
37
|
+
// Size limits per item. An item that needs more says NEEDS: <files> files, <lines> lines and the runner
|
|
38
|
+
// raises its limits to the first level that fits (same session). Past the last level it is split.
|
|
39
|
+
maxNetLines: 150,
|
|
40
|
+
maxFilesGrowing: 5,
|
|
41
|
+
itemMinutes: 40,
|
|
42
|
+
big: { maxNetLines: 600, maxFilesGrowing: 15, itemMinutes: 90 }, // for items tagged [big]
|
|
43
|
+
escalate: [
|
|
44
|
+
{ maxFilesGrowing: 15, maxNetLines: 600, itemMinutes: 90 },
|
|
45
|
+
{ maxFilesGrowing: 40, maxNetLines: 1500, itemMinutes: 120 },
|
|
46
|
+
{ maxFilesGrowing: 200, maxNetLines: 5000, itemMinutes: 150 },
|
|
47
|
+
],
|
|
48
|
+
autoSplit: true, // an item that still cannot be finished is split into smaller items (kept in state.json)
|
|
49
|
+
splitMax: 8,
|
|
50
|
+
redoOnConflict: true, // a conflicting item is redone on the latest code in the same session
|
|
51
|
+
deferBlocked: true, // an item that says BLOCKED while other work is still pending is retried once after that work
|
|
52
|
+
autoFix: true, // a [check] item that reports GAP: lines gets fix items queued before it, then checks again
|
|
53
|
+
gapRounds: 2, // how many fix rounds a [check] item may trigger
|
|
54
|
+
recheck: true, // a passed [check] item runs again when an item it covers ([areas] overlap or [after]) changes later
|
|
55
|
+
fixRounds: 1,
|
|
56
|
+
checkMinutes: 15,
|
|
57
|
+
stopAfterFailuresInARow: 5,
|
|
58
|
+
|
|
59
|
+
// Sessions
|
|
60
|
+
model: "", // empty: Pi's default model; or "provider/model-id"
|
|
61
|
+
thinking: "high",
|
|
62
|
+
piCommand: "",
|
|
63
|
+
manager: true, // a detached supervisor diagnoses failures after approved handoff; healthy ticks use no model
|
|
64
|
+
runIn: "auto", // "auto": items are Pi Web sessions when Pi Web runs, else background `pi`; or "web" / "cli"
|
|
65
|
+
piWebUrl: "", // empty: find the running Pi Web automatically
|
|
66
|
+
// What happens to finished (passed/split) item sessions: "end" deletes them when the run ends, "pass" right after
|
|
67
|
+
// each pass, "archive" archives them in Pi Web (hidden from the sidebar, still searchable), false keeps them.
|
|
68
|
+
// Sessions of failed items are always kept until /task cleanup.
|
|
69
|
+
deleteSessions: "end",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ---------- helpers ----------
|
|
73
|
+
|
|
74
|
+
export const LOGIN_SHELL = process.env.SHELL || (existsSync("/bin/zsh") ? "/bin/zsh" : "/bin/bash");
|
|
75
|
+
|
|
76
|
+
export function run(cwd, file, args, { timeoutMs = 300_000, env } = {}) {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const child = execFile(file, args, { cwd, env, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
79
|
+
const code = err ? (typeof err.code === "number" ? err.code : 1) : 0;
|
|
80
|
+
resolve({ code, out: `${stdout ?? ""}${stderr ?? ""}` });
|
|
81
|
+
});
|
|
82
|
+
child.stdin?.end(); // nobody types into these commands: give them an empty stdin, not an open pipe
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** Environment for setup and checks. The runner is started from Pi Web (a Node/pnpm process), whose npm_*, PNPM_*,
|
|
86
|
+
* NODE_ENV, NODE_OPTIONS… would leak into a nested `pnpm` and can make it fail with no useful message. */
|
|
87
|
+
const POLLUTED = /^(npm_.*|PNPM_.*|pnpm_.*|INIT_CWD|NODE_ENV|NODE_OPTIONS|NEXT_.*|PORT|HOSTNAME|PI_WEB_.*|FORCE_COLOR|NO_COLOR)$/;
|
|
88
|
+
export function cleanEnv() {
|
|
89
|
+
const env = {};
|
|
90
|
+
for (const [k, v] of Object.entries(process.env)) if (!POLLUTED.test(k)) env[k] = v;
|
|
91
|
+
env.CI = "";
|
|
92
|
+
delete env.CI;
|
|
93
|
+
return env;
|
|
94
|
+
}
|
|
95
|
+
export const git = (cwd, ...args) => run(cwd, "git", args);
|
|
96
|
+
export const firstLine = (s) => String(s ?? "").split("\n")[0];
|
|
97
|
+
export const tail = (s, n) => String(s ?? "").split("\n").slice(-n).join("\n");
|
|
98
|
+
|
|
99
|
+
/** The work list plus any items the runner split off, each right after its parent. Parents that were split are left out. */
|
|
100
|
+
export function allItems(queueText, state) {
|
|
101
|
+
const out = [];
|
|
102
|
+
const push = (it) => {
|
|
103
|
+
for (const line of state.fixes?.[it.key] ?? []) out.push(...parseQueue(line).map((f) => ({ ...f, child: true, fix: true })));
|
|
104
|
+
const wait = state.waitFor?.[it.key];
|
|
105
|
+
out.push(wait ? { ...it, after: [...new Set([...(it.after ?? []), ...wait])] } : it);
|
|
106
|
+
};
|
|
107
|
+
for (const q of parseQueue(queueText)) {
|
|
108
|
+
const kids = state.splits?.[q.key];
|
|
109
|
+
if (kids) {
|
|
110
|
+
for (const line of kids) for (const k of parseQueue(line)) push({ ...k, child: true });
|
|
111
|
+
} else push(q);
|
|
112
|
+
}
|
|
113
|
+
for (const line of state.added ?? []) for (const a of parseQueue(line)) push({ ...a, added: true });
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Changes asked for by you or the manager. Applied by the runner between items, or directly when it is not running. */
|
|
118
|
+
export function applyControl(state, items, ctl) {
|
|
119
|
+
const pick = (ids) => {
|
|
120
|
+
const want = (ids ?? []).map((x) => String(x).trim()).filter(Boolean);
|
|
121
|
+
return items.filter((i) => want.length === 0 || want.some((w) => i.id === w || (ctl.by !== "auto" && i.id.startsWith(w))));
|
|
122
|
+
};
|
|
123
|
+
const changed = [];
|
|
124
|
+
if (ctl.action === "retry") {
|
|
125
|
+
for (const i of pick(ctl.ids)) {
|
|
126
|
+
if (state.failed[i.key] || state.retried?.[i.key] || state.deferred?.[i.key]) changed.push(i.id);
|
|
127
|
+
delete state.failed[i.key];
|
|
128
|
+
if (state.retried) delete state.retried[i.key];
|
|
129
|
+
if (state.deferred) delete state.deferred[i.key];
|
|
130
|
+
}
|
|
131
|
+
} else if (ctl.action === "recheck" || ctl.action === "rerun") {
|
|
132
|
+
for (const i of pick(ctl.ids)) {
|
|
133
|
+
if (ctl.action === "recheck" && !i.check) continue;
|
|
134
|
+
if (state.done[i.key]) changed.push(i.id);
|
|
135
|
+
delete state.done[i.key];
|
|
136
|
+
delete state.failed[i.key];
|
|
137
|
+
}
|
|
138
|
+
} else if (ctl.action === "unpark") {
|
|
139
|
+
for (const i of pick(ctl.ids)) {
|
|
140
|
+
if (state.retried?.[i.key] || state.deferred?.[i.key]) changed.push(i.id);
|
|
141
|
+
if (state.retried) delete state.retried[i.key];
|
|
142
|
+
if (state.deferred) delete state.deferred[i.key];
|
|
143
|
+
}
|
|
144
|
+
} else if (ctl.action === "next") {
|
|
145
|
+
// run these next: un-park them and put them at the front (dependencies and overlapping areas still apply)
|
|
146
|
+
const selected = ctl.ids?.length ? ctl.ids.flatMap((id) => pick([id])) : pick([]);
|
|
147
|
+
const keys = [...new Set(selected.filter((i) => !state.done[i.key]).map((i) => i.key))];
|
|
148
|
+
for (const k of keys) {
|
|
149
|
+
delete state.failed[k];
|
|
150
|
+
if (state.retried) delete state.retried[k];
|
|
151
|
+
if (state.deferred) delete state.deferred[k];
|
|
152
|
+
}
|
|
153
|
+
state.priority = [...new Set([...keys, ...(state.priority ?? [])])];
|
|
154
|
+
changed.push(...items.filter((i) => keys.includes(i.key)).map((i) => i.id));
|
|
155
|
+
} else if (ctl.action === "add") {
|
|
156
|
+
const lines = (ctl.lines ?? []).map((l) => (String(l).trim().startsWith("- [ ]") ? String(l).trim() : `- [ ] ${String(l).trim()}`));
|
|
157
|
+
state.added = [...(state.added ?? []), ...lines];
|
|
158
|
+
changed.push(...lines.flatMap((l) => parseQueue(l).map((i) => i.id)));
|
|
159
|
+
}
|
|
160
|
+
return changed;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function splitPrompt(c, item, reason) {
|
|
164
|
+
return `This item could not be finished in one go (${firstLine(reason).slice(0, 200)}).
|
|
165
|
+
|
|
166
|
+
Do NOT edit any file. Split it into 3 to ${c.splitMax} smaller items that together cover everything it requires. Each must be doable on its own by a fresh agent in one session (about 10 files at most), touch a different set of files than the others, and be checkable with the typecheck and build. Use [after] only for actual prerequisites; independent parts must be runnable in parallel, not chained by list order.
|
|
167
|
+
|
|
168
|
+
Reply with one line per item, in order, each starting with "ITEM: ", then what to do, then "Done when: <something observable>", then the tags [areas: <folders or files it changes, comma separated>] and, only if it needs earlier parts finished first, [after: <their numbers, e.g. 1, 2>]. Parts are numbered from 1 in the order you list them. No other text. If it truly cannot be split, reply with one line: BLOCKED: <reason>`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Do two [areas: …] lists touch the same path? (prefix match; globs cut at the first *) */
|
|
172
|
+
export function areasOverlap(a, b) {
|
|
173
|
+
const norm = (p) => {
|
|
174
|
+
const literal = p.split(/[*?[{]/)[0];
|
|
175
|
+
const prefix = literal === p ? literal : literal.slice(0, literal.lastIndexOf("/") + 1);
|
|
176
|
+
const value = posix.normalize(prefix || ".").replace(/\/+$/, "");
|
|
177
|
+
return value === "." ? "" : value;
|
|
178
|
+
};
|
|
179
|
+
for (const x of a.map(norm))
|
|
180
|
+
for (const y of b.map(norm)) {
|
|
181
|
+
if (!x || !y || x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`)) return true;
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Per-item limits and session selection; omitted tags inherit the queue defaults. */
|
|
187
|
+
export function itemConfig(c, item) {
|
|
188
|
+
const out = { ...c };
|
|
189
|
+
if (c.itemChecks) {
|
|
190
|
+
const checks = c.itemChecks[item.id] ?? c.itemChecks[item.id.match(/^([A-Z]+\d+)[a-z]$/)?.[1]];
|
|
191
|
+
if (!Array.isArray(checks) || checks.some((cmd) => typeof cmd !== "string" || !cmd.trim())) throw new Error(`${item.id}: missing or invalid focused itemChecks`);
|
|
192
|
+
out.checks = [...c.checks, ...checks];
|
|
193
|
+
}
|
|
194
|
+
if (/\[big\]/i.test(item.line)) Object.assign(out, c.big ?? {});
|
|
195
|
+
const tag = item.line.match(/\[((?:files|lines|min)=\d+(?:[\s,]+(?:files|lines|min)=\d+)*)\]/i)?.[1];
|
|
196
|
+
for (const [, k, v] of (tag ?? "").matchAll(/(files|lines|min)=(\d+)/gi)) {
|
|
197
|
+
const key = { files: "maxFilesGrowing", lines: "maxNetLines", min: "itemMinutes" }[k.toLowerCase()];
|
|
198
|
+
out[key] = Number(v);
|
|
199
|
+
}
|
|
200
|
+
for (const key of ["model", "thinking"]) {
|
|
201
|
+
const tags = [...item.line.matchAll(new RegExp(`\\[${key}:\\s*([^\\]]*)\\]`, "gi"))];
|
|
202
|
+
if (tags.length > 1) throw new Error(`${item.id}: duplicate [${key}: …] tags`);
|
|
203
|
+
if (tags.length) {
|
|
204
|
+
const value = tags[0][1].trim();
|
|
205
|
+
if (key === "model" ? !/^[^\s/]+\/\S+$/.test(value) : !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value)) {
|
|
206
|
+
throw new Error(`${item.id}: invalid [${key}: ${value}]`);
|
|
207
|
+
}
|
|
208
|
+
out[key] = value;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function parseQueue(text) {
|
|
215
|
+
const items = [];
|
|
216
|
+
for (const raw of text.split("\n")) {
|
|
217
|
+
const m = raw.match(/^\s*-\s\[\s\]\s+(.+?)\s*$/);
|
|
218
|
+
if (!m) continue;
|
|
219
|
+
const line = m[1];
|
|
220
|
+
for (const tag of ["areas?", "after"]) if ([...line.matchAll(new RegExp(`\\[${tag}:`, "gi"))].length > 1) throw new Error(`duplicate [${tag}] tag: ${line}`);
|
|
221
|
+
const id = line.match(/^([A-Z]+[0-9]+[A-Za-z0-9-]*)/)?.[1] ?? `item${items.length + 1}`;
|
|
222
|
+
const title = line
|
|
223
|
+
.split(/\s+Done when:/i)[0]
|
|
224
|
+
.replace(/^[A-Z]+[0-9]+[A-Za-z0-9-]*\s*[—:-]\s*/, "")
|
|
225
|
+
.slice(0, 120);
|
|
226
|
+
const tag = (name) =>
|
|
227
|
+
(line.match(new RegExp(`\\[${name}:\\s*([^\\]]+)\\]`, "i"))?.[1] ?? "")
|
|
228
|
+
.split(name === "after" ? /[,\s]+/ : ",")
|
|
229
|
+
.map((x) => x.trim().replace(/^\.\//, "").replace(/`/g, ""))
|
|
230
|
+
.filter(Boolean);
|
|
231
|
+
items.push({
|
|
232
|
+
key: line,
|
|
233
|
+
id,
|
|
234
|
+
title: title.replace(/\s*\[(areas?|after|model|thinking|big|check|files=|lines=|min=)[^\]]*\]/gi, "").trim(),
|
|
235
|
+
line,
|
|
236
|
+
areas: tag("areas?"),
|
|
237
|
+
after: tag("after"),
|
|
238
|
+
check: /\[check\]/i.test(line),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return items;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Resolve real prerequisites, including split-parent aliases; reject cycles before dispatch. */
|
|
245
|
+
export function dependencyGraph(items) {
|
|
246
|
+
const byId = new Map(items.map((i) => [i.id, i]));
|
|
247
|
+
if (byId.size !== items.length) throw new Error("duplicate queue item ids");
|
|
248
|
+
const graph = new Map();
|
|
249
|
+
for (const i of items) {
|
|
250
|
+
const deps = (i.after ?? []).flatMap((id) => {
|
|
251
|
+
if (id === "*") return items.filter((o) => o !== i && !(o.after ?? []).includes("*")).map((o) => o.id);
|
|
252
|
+
if (byId.has(id)) return [id];
|
|
253
|
+
const children = items.filter((o) => o.child && o.id.startsWith(id) && /^[a-z]$/.test(o.id.slice(id.length)));
|
|
254
|
+
if (!children.length) throw new Error(`${i.id}: unknown dependency ${id}`);
|
|
255
|
+
return children.map((o) => o.id);
|
|
256
|
+
});
|
|
257
|
+
graph.set(i.id, [...new Set(deps)]);
|
|
258
|
+
}
|
|
259
|
+
const done = new Set(), visiting = [];
|
|
260
|
+
const visit = (id) => {
|
|
261
|
+
if (visiting.includes(id)) throw new Error(`dependency cycle: ${[...visiting.slice(visiting.indexOf(id)), id].join(" → ")}`);
|
|
262
|
+
if (done.has(id)) return;
|
|
263
|
+
visiting.push(id);
|
|
264
|
+
for (const dep of graph.get(id)) visit(dep);
|
|
265
|
+
visiting.pop();
|
|
266
|
+
done.add(id);
|
|
267
|
+
};
|
|
268
|
+
for (const id of graph.keys()) visit(id);
|
|
269
|
+
return graph;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Prefer work that unlocks the longest remaining chain; explicit next order wins. */
|
|
273
|
+
export function orderQueue(items, state) {
|
|
274
|
+
const graph = dependencyGraph(items), children = new Map(items.map((i) => [i.id, []]));
|
|
275
|
+
for (const i of items) if (!state.done?.[i.key] && !state.failed?.[i.key]) for (const dep of graph.get(i.id)) children.get(dep).push(i.id);
|
|
276
|
+
const scores = new Map();
|
|
277
|
+
const score = (id) => {
|
|
278
|
+
if (!scores.has(id)) scores.set(id, 1 + Math.max(0, ...children.get(id).map(score)));
|
|
279
|
+
return scores.get(id);
|
|
280
|
+
};
|
|
281
|
+
const priority = state.priority ?? [];
|
|
282
|
+
const rank = (i) => priority.includes(i.key) ? priority.indexOf(i.key) : priority.length;
|
|
283
|
+
const parked = (i) => Number(!!(state.retried?.[i.key] || state.deferred?.[i.key]));
|
|
284
|
+
return [...items].sort((a, b) => parked(a) - parked(b) || rank(a) - rank(b) || score(b.id) - score(a.id));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function loadJson(path, fallback) {
|
|
288
|
+
try {
|
|
289
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
290
|
+
} catch {
|
|
291
|
+
return fallback;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function saveJson(file, value) {
|
|
296
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
297
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
|
|
298
|
+
renameSync(tmp, file);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function pidAlive(pid) {
|
|
302
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
303
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Exclusive process ownership. Missing/partial owner records fail closed. */
|
|
307
|
+
export function acquireProcessLock(file) {
|
|
308
|
+
const claim = () => {
|
|
309
|
+
const fd = openSync(file, "wx");
|
|
310
|
+
try { writeFileSync(fd, String(process.pid)); } finally { closeSync(fd); }
|
|
311
|
+
};
|
|
312
|
+
try { claim(); } catch (e) {
|
|
313
|
+
if (e.code !== "EEXIST") throw e;
|
|
314
|
+
const reclaim = `${file}.reclaim`;
|
|
315
|
+
const fd = openSync(reclaim, "wx");
|
|
316
|
+
try {
|
|
317
|
+
const pid = Number(readFileSync(file, "utf8"));
|
|
318
|
+
if (!pid || pidAlive(pid)) throw new Error(`process already owns ${file}`);
|
|
319
|
+
rmSync(file);
|
|
320
|
+
claim();
|
|
321
|
+
} finally { closeSync(fd); rmSync(reclaim, { force: true }); }
|
|
322
|
+
}
|
|
323
|
+
let released = false;
|
|
324
|
+
return () => {
|
|
325
|
+
if (released) return;
|
|
326
|
+
released = true;
|
|
327
|
+
try { if (readFileSync(file, "utf8") === String(process.pid)) rmSync(file); } catch {}
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function enqueueControl(repo, control) {
|
|
332
|
+
const dir = join(repo, DIR, "control");
|
|
333
|
+
mkdirSync(dir, { recursive: true });
|
|
334
|
+
saveJson(join(dir, `${Date.now()}-${randomUUID()}.json`), control);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function ensureDir(cwd, extraExcludes = []) {
|
|
338
|
+
mkdirSync(join(cwd, DIR), { recursive: true });
|
|
339
|
+
const rel = (await git(cwd, "rev-parse", "--git-path", "info/exclude")).out.trim();
|
|
340
|
+
if (!rel) return;
|
|
341
|
+
const exclude = isAbsolute(rel) ? rel : join(cwd, rel);
|
|
342
|
+
try {
|
|
343
|
+
mkdirSync(dirname(exclude), { recursive: true });
|
|
344
|
+
const cur = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
|
|
345
|
+
const missing = [`${DIR}/`, ...extraExcludes].filter((entry) => !cur.split("\n").includes(entry));
|
|
346
|
+
if (missing.length) appendFileSync(exclude, `${cur && !cur.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`);
|
|
347
|
+
} catch {}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function readConfig(cwd) {
|
|
351
|
+
return { ...DEFAULTS, ...loadJson(join(cwd, DIR, "config.json"), {}) };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Worktrees live inside the repo's ignored .pi-queue/ folder, so Pi Web groups their sessions
|
|
355
|
+
* with the repo and they inherit the repo's project trust. */
|
|
356
|
+
export function worktreeRoot(repo) {
|
|
357
|
+
return join(repo, DIR, "worktrees");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function makeLog(repo) {
|
|
361
|
+
return (line) =>
|
|
362
|
+
appendFileSync(join(repo, DIR, "log.md"), `- ${new Date().toISOString().slice(0, 16).replace("T", " ")} ${line}\n`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** One-at-a-time lock for integration and ref updates. */
|
|
366
|
+
function mutex() {
|
|
367
|
+
let chain = Promise.resolve();
|
|
368
|
+
return (fn) => {
|
|
369
|
+
const next = chain.then(fn, fn);
|
|
370
|
+
chain = next.catch(() => {});
|
|
371
|
+
return next;
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------- measuring a change against the item's base commit ----------
|
|
376
|
+
|
|
377
|
+
export function pathAllowed(file, allowed) {
|
|
378
|
+
const path = posix.normalize(file.replaceAll("\\", "/")).replace(/\/$/, "");
|
|
379
|
+
if (isAbsolute(path) || path === ".." || path.startsWith("../") || /^[A-Za-z]:/.test(path)) return false;
|
|
380
|
+
return allowed.some((entry) => {
|
|
381
|
+
const prefix = posix.normalize(entry.replaceAll("\\", "/")).replace(/\/$/, "");
|
|
382
|
+
return prefix === "." || path === prefix || path.startsWith(`${prefix}/`);
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function measure(cwd, base, allowed) {
|
|
387
|
+
const files = new Set();
|
|
388
|
+
let net = 0;
|
|
389
|
+
let growing = 0;
|
|
390
|
+
const inScope = (p) => pathAllowed(p, allowed);
|
|
391
|
+
const numstat = await git(cwd, "diff", "--numstat", "--no-renames", base);
|
|
392
|
+
for (const row of numstat.out.split("\n")) {
|
|
393
|
+
const [a, d, p] = row.split("\t");
|
|
394
|
+
if (!p) continue;
|
|
395
|
+
files.add(p);
|
|
396
|
+
const delta = (Number(a) || 0) - (Number(d) || 0);
|
|
397
|
+
if (inScope(p)) {
|
|
398
|
+
net += delta;
|
|
399
|
+
if (delta > 0) growing++;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const untracked = await git(cwd, "ls-files", "--others", "--exclude-standard", "-z");
|
|
403
|
+
for (const p of untracked.out.split("\0").filter(Boolean)) {
|
|
404
|
+
files.add(p);
|
|
405
|
+
let lines = 0;
|
|
406
|
+
try {
|
|
407
|
+
const t = readFileSync(join(cwd, p), "utf8");
|
|
408
|
+
lines = t ? t.split("\n").length - (t.endsWith("\n") ? 1 : 0) : 0;
|
|
409
|
+
} catch {}
|
|
410
|
+
if (inScope(p)) {
|
|
411
|
+
net += lines;
|
|
412
|
+
if (lines > 0) growing++;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const all = [...files];
|
|
416
|
+
return { files: all, outside: all.filter((p) => !inScope(p)), net, growing };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function treeIsClean(cwd) {
|
|
420
|
+
const s = await git(cwd, "status", "--porcelain", "--untracked-files=all");
|
|
421
|
+
return s.code === 0 && s.out.trim() === "";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---------- prompts ----------
|
|
425
|
+
|
|
426
|
+
function itemPrompt(c, item) {
|
|
427
|
+
if (item.check) return checkPrompt(c, item);
|
|
428
|
+
return `You are doing ONE list item, unattended. Nobody can answer questions. You may be in your own git worktree while other items run in parallel in other worktrees. A script checks your work when you stop; it alone decides whether the work is kept.
|
|
429
|
+
|
|
430
|
+
ITEM: ${item.line}
|
|
431
|
+
|
|
432
|
+
Read first: ${c.readFirst}.
|
|
433
|
+
|
|
434
|
+
Rules (the script enforces the first two and throws away the whole change if broken):
|
|
435
|
+
- Change only files under: ${c.allowedPaths.join(", ")}.
|
|
436
|
+
- At most ${c.maxNetLines} net added lines and at most ${c.maxFilesGrowing} files that gain lines.
|
|
437
|
+
- Make the smallest change that meets the item's "Done when". Reuse existing code. No new abstractions, wrappers, helper layers, generic utilities, or refactors the item does not require.
|
|
438
|
+
- Do only this item. Do not fix other items, unrelated warnings or style.
|
|
439
|
+
- You may use subagents to read, search and research. Only you edit files.
|
|
440
|
+
- Do not ask the user questions. Do not commit. Do not touch git history or branches.
|
|
441
|
+
- Before you stop, run: ${checksText(c)}${checkBroker(c) && !c.itemChecks ? "\n- While you work, run tests, type checks and lint with the `check` tool: it runs only what your change affects and reuses results other items already have. Failures it marks pre-existing also fail on the base branch: leave them." : ""}
|
|
442
|
+
- If the item clearly needs more than these size limits, do not start editing: reply with exactly one line NEEDS: <files> files, <lines> lines (your honest estimate). The limits are then raised for you.
|
|
443
|
+
- If it cannot be done for any other reason (a missing contract, a decision nobody made, scope), undo your edits and reply with exactly one line: BLOCKED: <reason>
|
|
444
|
+
- When finished, reply: DONE, then each changed file with one sentence.`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---------- size limits: raised automatically, or when the person says so in the session ----------
|
|
448
|
+
|
|
449
|
+
const UNLIMITED = { maxNetLines: 5000, maxFilesGrowing: 200 };
|
|
450
|
+
const SIZE_REASON = /\b(limit|limits|net added|files? gain|five|5 files|150|broader|larger than|too (big|large)|exceed|beyond)\b/i;
|
|
451
|
+
const SCOPE_REASON = /\b(outside the (permitted|allowed)|outside (apps|the scope)|frozen|not permitted)\b/i;
|
|
452
|
+
|
|
453
|
+
/** Did the item stop only because of the size limit? (The runner's own rejection, or an agent BLOCKED about size.) */
|
|
454
|
+
function isSizeStop(reason) {
|
|
455
|
+
if (!reason) return false;
|
|
456
|
+
if (/^\d+ net added lines|^\d+ files gain lines|^agent: NEEDS /.test(reason)) return true;
|
|
457
|
+
return reason.startsWith("agent: BLOCKED") && SIZE_REASON.test(reason) && !SCOPE_REASON.test(reason);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Has the person typed something like "bypass limits" in the item's session? Short messages only (the runner's own prompts are long). */
|
|
461
|
+
const BYPASS = /\b(bypass|ignore|lift|remove|raise|disable|drop|skip)\b[^.\n]{0,25}\blimits?\b|\bno (size )?limits?\b/i;
|
|
462
|
+
async function bypassAsked(ctx, ref) {
|
|
463
|
+
try {
|
|
464
|
+
let file = ref.file;
|
|
465
|
+
if (!file && ctx.web && ref.id) file = await ctx.web.send(ref.id, { type: "get_state" }).then((r) => r?.data?.sessionFile);
|
|
466
|
+
if (!file) return false;
|
|
467
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
468
|
+
if (!line.includes('"user"')) continue;
|
|
469
|
+
const m = JSON.parse(line)?.message;
|
|
470
|
+
if (m?.role !== "user") continue;
|
|
471
|
+
const text = typeof m.content === "string" ? m.content : (m.content ?? []).map((p) => p?.text ?? "").join(" ");
|
|
472
|
+
if (text.length < 600 && BYPASS.test(text)) return true;
|
|
473
|
+
}
|
|
474
|
+
} catch {}
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function raisePrompt(c, reason) {
|
|
479
|
+
return `The size limits for this item are now raised to ${c.maxFilesGrowing} files gaining lines and ${c.maxNetLines} net added lines. Previous stop: ${reason}
|
|
480
|
+
|
|
481
|
+
Do the item now within the raised limits. Keep any edits that are already right. Same other rules: only ${c.allowedPaths.join(", ")}, no questions, no commits. Run the checks, then reply DONE. If it still cannot be done, reply with one line: BLOCKED: <reason>`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function redoPrompt(c, reason) {
|
|
485
|
+
return `Other items were merged into the branch while you worked, and your change no longer fits (${firstLine(reason)}). The worktree has been reset to the latest code.
|
|
486
|
+
|
|
487
|
+
Do this item again on top of it. Reuse your earlier approach, but first look at what changed in the files you touched. Same rules and limits: only ${c.allowedPaths.join(", ")}, at most ${c.maxNetLines} net added lines, at most ${c.maxFilesGrowing} files gaining lines, no questions, no commits. Run the checks, then reply DONE. If it cannot be done, reply with one line: BLOCKED: <reason>`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function checkPrompt(c, item) {
|
|
491
|
+
return `You are doing ONE CHECK item, unattended. Nobody can answer questions. A script checks your work when you stop.
|
|
492
|
+
|
|
493
|
+
CHECK: ${item.line}
|
|
494
|
+
|
|
495
|
+
Read first: ${c.readFirst}.
|
|
496
|
+
|
|
497
|
+
Verify what the check describes against the current code (read it, run tests/commands that are safe to run).
|
|
498
|
+
- You may fix tests, fixtures and docs under ${c.allowedPaths.join(", ")} when they are wrong or missing. Do not change production code.
|
|
499
|
+
- If production code does not do what the plan requires, do not fix it. Report each gap on its own line:
|
|
500
|
+
GAP: <what is wrong and what to change, naming the files>. Done when: <observable result>. [areas: <folders/files the fix changes>]
|
|
501
|
+
- If everything holds, reply: CHECKED, then one line per thing you verified or fixed.
|
|
502
|
+
- If you cannot check it at all (missing environment, credentials, or a decision), reply with exactly one line: BLOCKED: <reason>
|
|
503
|
+
- Do not ask questions. Do not commit. Before you stop, run: ${checksText(c)}`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function fixPrompt(c, reason) {
|
|
507
|
+
return `The script rejected the change: ${reason}
|
|
508
|
+
|
|
509
|
+
Fix only that. Same rules: only ${c.allowedPaths.join(", ")}, at most ${c.maxNetLines} net added lines, at most ${c.maxFilesGrowing} files gaining lines, no new abstractions, no questions, no commits. If it cannot be fixed within the rules, undo your edits and reply with one line: BLOCKED: <reason>`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ---------- running pi ----------
|
|
513
|
+
|
|
514
|
+
export async function resolvePi(c) {
|
|
515
|
+
if (c.piCommand) return c.piCommand;
|
|
516
|
+
const r = await run(process.cwd(), LOGIN_SHELL, ["-lc", "command -v pi"]);
|
|
517
|
+
return r.out.trim().split("\n").pop() || "pi";
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function loginEnv() {
|
|
521
|
+
const r = await run(process.cwd(), LOGIN_SHELL, ["-lc", 'printf %s "$PATH"']);
|
|
522
|
+
return { ...process.env, PATH: r.out.trim() || process.env.PATH };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function describeTool(name, args = {}) {
|
|
526
|
+
const path = args.path ?? args.file_path ?? args.filePath ?? args.file;
|
|
527
|
+
if (name === "bash" && args.command) return `bash: ${String(args.command).replace(/\s+/g, " ").slice(0, 90)}`;
|
|
528
|
+
if (path) return `${name} ${path}`;
|
|
529
|
+
if (args.pattern || args.query) return `${name} "${String(args.pattern ?? args.query).slice(0, 60)}"`;
|
|
530
|
+
return name;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function textOf(message) {
|
|
534
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
535
|
+
return content.filter((p) => p?.type === "text").map((p) => p.text).join("\n").trim();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** One `pi --mode json` run. Reports live activity through onActivity(text).
|
|
539
|
+
* Resolves { status: ok|timeout|stopped|error, reply, err, code }. */
|
|
540
|
+
function piTurn(ctx, cwd, sessionId, name, prompt, minutes, onActivity = () => {}) {
|
|
541
|
+
const { c, pi, env, stopRequested } = ctx;
|
|
542
|
+
return new Promise((resolve) => {
|
|
543
|
+
// item sessions live in .pi-queue/sessions: easy to open (/queue open <id>, pi --session <file>) and to delete
|
|
544
|
+
const previous = sessionFiles(ctx.repo, [sessionId])[sessionId];
|
|
545
|
+
const args = ["--mode", "json", "--session-dir", join(ctx.repo, DIR, "sessions"), ...(previous ? ["--session", previous] : ["--session-id", sessionId]), "-n", name];
|
|
546
|
+
if (c.model) args.push("--model", c.model);
|
|
547
|
+
if (c.thinking) args.push("--thinking", c.thinking);
|
|
548
|
+
if (ctx.manager) args.push("--no-tools", "--no-extensions", "--no-mcp", "--no-skills", "--no-prompt-templates");
|
|
549
|
+
args.push(prompt);
|
|
550
|
+
const child = spawn(pi, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
551
|
+
ctx.onWorker?.({ pid: child.pid });
|
|
552
|
+
let buf = "";
|
|
553
|
+
let raw = "";
|
|
554
|
+
let reply = "";
|
|
555
|
+
let err = "";
|
|
556
|
+
let status = "ok", assistantError = "", killTimer;
|
|
557
|
+
const onRecord = (line) => {
|
|
558
|
+
let ev;
|
|
559
|
+
try {
|
|
560
|
+
ev = JSON.parse(line);
|
|
561
|
+
} catch {
|
|
562
|
+
raw += `${line}\n`;
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (ev.type === "tool_execution_start") onActivity(describeTool(ev.toolName, ev.args));
|
|
566
|
+
else if (ev.type === "tool_execution_end" && ev.isError) onActivity(`${ev.toolName} failed`);
|
|
567
|
+
else if (ev.type === "turn_start") onActivity("thinking");
|
|
568
|
+
else if (ev.type === "compaction_start") onActivity("compacting context");
|
|
569
|
+
else if (ev.type === "message_end" && ev.message?.role === "assistant") {
|
|
570
|
+
const t = textOf(ev.message);
|
|
571
|
+
if (t) reply = t;
|
|
572
|
+
assistantError = ["error", "aborted", "length"].includes(ev.message.stopReason) ? ev.message.errorMessage || `assistant stopped: ${ev.message.stopReason}` : "";
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
child.stdout.on("data", (d) => {
|
|
576
|
+
buf += d;
|
|
577
|
+
let i;
|
|
578
|
+
while ((i = buf.indexOf("\n")) >= 0) {
|
|
579
|
+
const line = buf.slice(0, i).replace(/\r$/, "");
|
|
580
|
+
buf = buf.slice(i + 1);
|
|
581
|
+
if (line.trim()) onRecord(line);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
child.stderr.on("data", (d) => (err += d));
|
|
585
|
+
const kill = (why) => {
|
|
586
|
+
if (status !== "ok") return;
|
|
587
|
+
status = why;
|
|
588
|
+
try {
|
|
589
|
+
process.kill(-child.pid, "SIGTERM");
|
|
590
|
+
} catch {}
|
|
591
|
+
killTimer = setTimeout(() => {
|
|
592
|
+
try {
|
|
593
|
+
process.kill(-child.pid, "SIGKILL");
|
|
594
|
+
} catch {}
|
|
595
|
+
}, 20_000).unref();
|
|
596
|
+
};
|
|
597
|
+
const timer = setTimeout(() => kill("timeout"), minutes * 60_000);
|
|
598
|
+
const poll = setInterval(() => stopRequested() && kill("stopped"), 3_000);
|
|
599
|
+
child.on("error", (e) => {
|
|
600
|
+
status = "error";
|
|
601
|
+
err += String(e);
|
|
602
|
+
});
|
|
603
|
+
child.on("close", (code) => {
|
|
604
|
+
clearTimeout(timer);
|
|
605
|
+
clearTimeout(killTimer);
|
|
606
|
+
clearInterval(poll);
|
|
607
|
+
if (buf.trim()) onRecord(buf.trim());
|
|
608
|
+
if (status === "ok" && (code !== 0 || assistantError)) status = "error";
|
|
609
|
+
resolve({ status, reply: reply || raw.trim(), err: (err + assistantError).trim(), code });
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ---------- running an item as a Pi Web session ----------
|
|
615
|
+
|
|
616
|
+
export function webClient(c) {
|
|
617
|
+
const base = c.piWebUrl.replace(/\/$/, "");
|
|
618
|
+
const password = process.env.PI_WEB_PASSWORD || c.piWebPassword || "";
|
|
619
|
+
const headers = { "content-type": "application/json" };
|
|
620
|
+
if (password) headers.authorization = `Basic ${Buffer.from(`pi:${password}`).toString("base64")}`;
|
|
621
|
+
const call = async (method, path, body, timeoutMs = 60_000) => {
|
|
622
|
+
const res = await fetch(`${base}${path}`, {
|
|
623
|
+
method,
|
|
624
|
+
headers,
|
|
625
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
626
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
627
|
+
});
|
|
628
|
+
const text = await res.text();
|
|
629
|
+
let json;
|
|
630
|
+
try {
|
|
631
|
+
json = JSON.parse(text);
|
|
632
|
+
} catch {
|
|
633
|
+
json = { error: text.slice(0, 300) };
|
|
634
|
+
}
|
|
635
|
+
if (!res.ok || json?.error || json?.success === false) throw new Error(`Pi Web ${path}: ${res.status} ${json?.error ?? ""}`.trim());
|
|
636
|
+
return json;
|
|
637
|
+
};
|
|
638
|
+
return {
|
|
639
|
+
available: () => call("GET", "/api/agent/running", undefined, 3000).then(() => true, () => false),
|
|
640
|
+
running: async (id) => {
|
|
641
|
+
const snap = await call("GET", "/api/agent/running", undefined, 15_000);
|
|
642
|
+
const active = [...(snap.runningSessionIds ?? []), ...(snap.subagentsActiveSessionIds ?? [])];
|
|
643
|
+
if (id && !active.includes(id) && active.length) {
|
|
644
|
+
const { sessions } = await call("GET", "/api/sessions?summary=1");
|
|
645
|
+
const family = new Set([id]);
|
|
646
|
+
for (let grew = true; grew;) {
|
|
647
|
+
grew = false;
|
|
648
|
+
for (const s of sessions) if (s.relation?.kind === "subagent" && family.has(s.relation.parentSessionId) && !family.has(s.id)) {
|
|
649
|
+
family.add(s.id); grew = true;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (active.some((sid) => family.has(sid))) snap.subagentsActiveSessionIds = [...(snap.subagentsActiveSessionIds ?? []), id];
|
|
653
|
+
}
|
|
654
|
+
return snap;
|
|
655
|
+
},
|
|
656
|
+
create: (body) => call("POST", "/api/agent/new", body, 300_000),
|
|
657
|
+
send: (id, body) => call("POST", `/api/agent/${encodeURIComponent(id)}`, body, 120_000),
|
|
658
|
+
history: async (id) => {
|
|
659
|
+
let before = "";
|
|
660
|
+
while (true) {
|
|
661
|
+
const response = await call("GET", `/api/sessions/${encodeURIComponent(id)}${before ? "/context" : ""}?tree=summary&tail=1&deferMedia=1&deferThinking=1${before ? `&before=${encodeURIComponent(before)}` : ""}`);
|
|
662
|
+
if (response.context.messages.some((m) => m.role === "assistant") || !response.context.hasMore) return response;
|
|
663
|
+
before = response.context.oldestEntryId;
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
del: (ids) => call("POST", "/api/sessions/delete", { ids }, 60_000),
|
|
667
|
+
ui: (body) => call("POST", "/api/sessions/ui-state", body, 30_000),
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Finds the running Pi Web: the configured URL, then every server pi-web recorded in
|
|
672
|
+
* <agentDir>/pi-web-run/*.json, then the default port. */
|
|
673
|
+
export async function findPiWeb(c) {
|
|
674
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || join(process.env.HOME || "", ".pi", "agent");
|
|
675
|
+
const urls = [];
|
|
676
|
+
if (c.piWebUrl) urls.push(c.piWebUrl);
|
|
677
|
+
try {
|
|
678
|
+
const { readdirSync } = await import("node:fs");
|
|
679
|
+
const dir = join(agentDir, "pi-web-run");
|
|
680
|
+
for (const f of readdirSync(dir)) {
|
|
681
|
+
if (!f.endsWith(".json")) continue;
|
|
682
|
+
const rec = loadJson(join(dir, f), {});
|
|
683
|
+
if (rec.url) urls.push(rec.url);
|
|
684
|
+
else if (rec.port) urls.push(`http://127.0.0.1:${rec.port}`);
|
|
685
|
+
}
|
|
686
|
+
} catch {}
|
|
687
|
+
urls.push("http://127.0.0.1:30141");
|
|
688
|
+
const reasons = [];
|
|
689
|
+
for (const url of [...new Set(urls.map((u) => u.replace(/\/$/, "")))]) {
|
|
690
|
+
try {
|
|
691
|
+
const headers = {};
|
|
692
|
+
const password = process.env.PI_WEB_PASSWORD || c.piWebPassword || "";
|
|
693
|
+
if (password) headers.authorization = `Basic ${Buffer.from(`pi:${password}`).toString("base64")}`;
|
|
694
|
+
const res = await fetch(`${url}/api/agent/running`, { headers, signal: AbortSignal.timeout(3000) });
|
|
695
|
+
if (res.ok) return { url };
|
|
696
|
+
reasons.push(
|
|
697
|
+
`${url} answered ${res.status}${res.status === 401 ? " (password protected: set PI_WEB_PASSWORD, or piWebPassword in config.json)" : ""}`,
|
|
698
|
+
);
|
|
699
|
+
} catch (e) {
|
|
700
|
+
reasons.push(`${url} not reachable`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return { reason: reasons.join("; ") };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Last tool call in a session file, for the live panel. */
|
|
707
|
+
function lastToolCall(sessionFile) {
|
|
708
|
+
try {
|
|
709
|
+
const text = readFileSync(sessionFile, "utf8");
|
|
710
|
+
const lines = text.slice(-200_000).split("\n").reverse();
|
|
711
|
+
for (const line of lines) {
|
|
712
|
+
if (!line.includes('"toolCall"')) continue;
|
|
713
|
+
const entry = JSON.parse(line);
|
|
714
|
+
const content = entry?.message?.content;
|
|
715
|
+
if (!Array.isArray(content)) continue;
|
|
716
|
+
const call = [...content].reverse().find((p) => p?.type === "toolCall");
|
|
717
|
+
if (call) return describeTool(call.name, call.arguments);
|
|
718
|
+
}
|
|
719
|
+
} catch {}
|
|
720
|
+
return undefined;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** One prompt in a Pi Web session (creates the session on the first call).
|
|
724
|
+
* ref.id holds the Pi Web session id. Resolves like piTurn. */
|
|
725
|
+
async function webTurn(ctx, cwd, ref, name, prompt, minutes, onActivity = () => {}) {
|
|
726
|
+
const { c, web, stopRequested } = ctx;
|
|
727
|
+
const deadline = Date.now() + minutes * 60_000;
|
|
728
|
+
const [provider, ...rest] = (c.model || "").split("/");
|
|
729
|
+
const modelId = rest.join("/");
|
|
730
|
+
let previousAssistantId;
|
|
731
|
+
try {
|
|
732
|
+
if (!ref.id) {
|
|
733
|
+
const r = await web.create({
|
|
734
|
+
cwd,
|
|
735
|
+
type: "ensure_session",
|
|
736
|
+
...(ctx.manager ? { toolNames: [] } : {}),
|
|
737
|
+
...(provider && modelId ? { provider, modelId } : {}),
|
|
738
|
+
...(c.thinking ? { thinkingLevel: c.thinking } : {}),
|
|
739
|
+
});
|
|
740
|
+
ref.id = r.sessionId;
|
|
741
|
+
ctx.onWorker?.({ sessionId: ref.id, web: true });
|
|
742
|
+
await web.send(ref.id, { type: "set_session_name", name }).catch(() => {});
|
|
743
|
+
}
|
|
744
|
+
const { context } = await web.history(ref.id);
|
|
745
|
+
previousAssistantId = context.entryIds[context.messages.findLastIndex((m) => m.role === "assistant")];
|
|
746
|
+
await web.send(ref.id, { type: "prompt", message: prompt });
|
|
747
|
+
} catch (e) {
|
|
748
|
+
return { status: "error", reply: "", err: String(e?.message ?? e), code: -1, quiescent: false };
|
|
749
|
+
}
|
|
750
|
+
onActivity("thinking");
|
|
751
|
+
let sessionFile;
|
|
752
|
+
let status = "ok";
|
|
753
|
+
let awaitingSince = 0;
|
|
754
|
+
let notRunning = 0;
|
|
755
|
+
let lastSeen = "";
|
|
756
|
+
while (true) {
|
|
757
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
758
|
+
let snap;
|
|
759
|
+
try {
|
|
760
|
+
snap = await web.running(ref.id);
|
|
761
|
+
} catch {
|
|
762
|
+
if (!stopRequested() && Date.now() <= deadline) continue;
|
|
763
|
+
await web.send(ref.id, { type: "abort" }).catch(() => {});
|
|
764
|
+
return { status: "error", reply: "", err: "Pi Web unreachable; worker termination is unconfirmed", code: -1, quiescent: false };
|
|
765
|
+
}
|
|
766
|
+
const running = [...(snap.runningSessionIds ?? []), ...(snap.subagentsActiveSessionIds ?? [])].includes(ref.id);
|
|
767
|
+
const awaiting = (snap.awaitingInputSessionIds ?? []).includes(ref.id);
|
|
768
|
+
if (!running) {
|
|
769
|
+
if (++notRunning >= 2) break;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
notRunning = 0;
|
|
773
|
+
if (!sessionFile) {
|
|
774
|
+
sessionFile = await web
|
|
775
|
+
.send(ref.id, { type: "get_state" })
|
|
776
|
+
.then((r) => r?.data?.sessionFile)
|
|
777
|
+
.catch(() => undefined);
|
|
778
|
+
if (sessionFile) ref.file = sessionFile;
|
|
779
|
+
}
|
|
780
|
+
if (sessionFile) {
|
|
781
|
+
const doing = lastToolCall(sessionFile);
|
|
782
|
+
if (doing && doing !== lastSeen) {
|
|
783
|
+
lastSeen = doing;
|
|
784
|
+
onActivity(doing);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
awaitingSince = awaiting ? awaitingSince || Date.now() : 0;
|
|
788
|
+
const why = stopRequested()
|
|
789
|
+
? "stopped"
|
|
790
|
+
: Date.now() > deadline
|
|
791
|
+
? "timeout"
|
|
792
|
+
: awaitingSince && Date.now() - awaitingSince > 120_000
|
|
793
|
+
? "error"
|
|
794
|
+
: "";
|
|
795
|
+
if (why) {
|
|
796
|
+
status = why;
|
|
797
|
+
await web.send(ref.id, { type: "abort" }).catch(() => {});
|
|
798
|
+
let quiescent = false;
|
|
799
|
+
for (let i = 0; i < 20; i++) {
|
|
800
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
801
|
+
const s2 = await web.running(ref.id).catch(() => null);
|
|
802
|
+
if (s2 && ![...(s2.runningSessionIds ?? []), ...(s2.subagentsActiveSessionIds ?? [])].includes(ref.id)) { quiescent = true; break; }
|
|
803
|
+
}
|
|
804
|
+
if (!quiescent) return { status: "error", reply: "", err: "worker did not confirm termination", code: -1, quiescent: false };
|
|
805
|
+
if (why === "error") return { status, reply: "", err: "the agent waited for a person's answer for 2 minutes", code: -1 };
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
const { context } = await web.history(ref.id);
|
|
811
|
+
const index = context.messages.findLastIndex((m) => m.role === "assistant");
|
|
812
|
+
const last = context.messages[index];
|
|
813
|
+
if (!last || context.entryIds[index] === previousAssistantId) return { status: "error", reply: "", err: "Pi Web returned no new assistant result", code: -1 };
|
|
814
|
+
const err = last.stopReason === "stop" ? "" : last.errorMessage || `assistant stopped: ${last.stopReason}`;
|
|
815
|
+
return { status: status === "ok" && err ? "error" : status, reply: textOf(last), err, code: err ? -1 : 0 };
|
|
816
|
+
} catch (e) { return { status: "error", reply: "", err: String(e), code: -1 }; }
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function problem(repo, text) {
|
|
820
|
+
try {
|
|
821
|
+
writeFileSync(join(repo, DIR, "problem.txt"), `${text}\n`);
|
|
822
|
+
} catch {}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** The baseline failed in a fresh worktree: find out whether the code itself fails, or only the worktree. */
|
|
826
|
+
async function diagnose(ctx, wt, failure) {
|
|
827
|
+
const { repo, c } = ctx;
|
|
828
|
+
const cmd = firstLine(failure).replace(/^check failed: /, "").replace(/ \(exit -?\d+\)$/, "");
|
|
829
|
+
const here = await run(repo, LOGIN_SHELL, ["-lc", cmd], { timeoutMs: c.checkMinutes * 60_000, env: cleanEnv() });
|
|
830
|
+
if (here.code !== 0)
|
|
831
|
+
return `The same command also fails in your main folder, so the code itself does not pass it.\nFix that first, or remove the command from "checks" in ${DIR}/config.json.\n\nOutput there:\n${tail(here.out, 25)}`;
|
|
832
|
+
const ig = await git(repo, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory");
|
|
833
|
+
const missing = ig.out
|
|
834
|
+
.split("\n")
|
|
835
|
+
.filter((f) => f && !f.includes("node_modules") && !f.startsWith(`${DIR}/`) && !existsSync(join(wt, f)))
|
|
836
|
+
.slice(0, 20);
|
|
837
|
+
return [
|
|
838
|
+
"The command PASSES in your main folder but FAILS in the fresh worktree.",
|
|
839
|
+
"So something git-ignored is needed that a fresh checkout does not have (build output of another package, generated types, .env files).",
|
|
840
|
+
missing.length ? `Ignored files/folders in your main folder that the worktree lacks:\n ${missing.join("\n ")}` : "",
|
|
841
|
+
`Fix: for env files, list them in "copyFromRepo" in ${DIR}/config.json. For build output, add the build to "setup", e.g.`,
|
|
842
|
+
` "setup": "pnpm install --frozen-lockfile --prefer-offline && pnpm --filter @example/web^... build"`,
|
|
843
|
+
]
|
|
844
|
+
.filter(Boolean)
|
|
845
|
+
.join("\n");
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ---------- pi-check (separate plugin, optional) ----------
|
|
849
|
+
|
|
850
|
+
let brokerInfo;
|
|
851
|
+
/** When pi-check is installed, checks go through its shared broker: one run per file content, shared by all
|
|
852
|
+
* worktrees, throttled, and failures that also fail on the base branch do not fail Go checks. */
|
|
853
|
+
export function checkBroker(c) {
|
|
854
|
+
if (c?.checkBroker === false) return undefined;
|
|
855
|
+
if (brokerInfo === undefined) {
|
|
856
|
+
try {
|
|
857
|
+
const info = JSON.parse(readFileSync(join(process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), "pi-check", "cli.json"), "utf8"));
|
|
858
|
+
brokerInfo = existsSync(info.cli) && existsSync(join(info.shims, "go")) ? info : null;
|
|
859
|
+
} catch {
|
|
860
|
+
brokerInfo = null;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return brokerInfo || undefined;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
867
|
+
|
|
868
|
+
export function brokerCommand(b, cmd, cwd) {
|
|
869
|
+
const pre = `export PATH=${shq(b.shims)}:"$PATH"; `;
|
|
870
|
+
// go test / go vet: the go shim (per-package results); other commands: the broker runs them whole
|
|
871
|
+
const goOnly = /\bgo\s+(test|vet)\b/.test(cmd) && !/\b(vitest|jest|pytest|tsc|eslint|oxlint|ruff|mypy|pyright|pnpm|npm|yarn|bun|make)\b/.test(cmd);
|
|
872
|
+
return goOnly ? `${pre}${cmd}` : `${pre}${shq(b.node)} ${shq(b.cli)} exec --cwd ${shq(cwd)} -- ${shq(cmd)}`;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** How the item prompt tells the agent to run the checks. A "pi-check" entry means the `check` tool. */
|
|
876
|
+
function checksText(c) {
|
|
877
|
+
if (c.itemChecks) {
|
|
878
|
+
const broker = checkBroker(c);
|
|
879
|
+
const lines = c.checks.map((cmd) => {
|
|
880
|
+
const selected = checkLine(c, cmd, ".");
|
|
881
|
+
if (selected.error) throw new Error(selected.error);
|
|
882
|
+
return selected.line;
|
|
883
|
+
});
|
|
884
|
+
return `only these exact selections, from the worktree root (the runner uses the same selections after rebase):\n${lines.join("\n")}\nDo not run generic check/run-changed, --refresh, ./... or full app suites unless explicitly listed for the final verification item. ${broker ? `The shared broker exists at ${broker.cli}; for an additional focused regression use ${shq(broker.node)} ${shq(broker.cli)} exec --cwd "$PWD" -- '<focused command>'.` : ""} A missing shell command named check/pi-check is not a missing broker. Report command exit statuses; no tool-specific RESULT: PASS text is required.`;
|
|
885
|
+
}
|
|
886
|
+
const raw = c.checks.filter((x) => !/^pi-check(\s|$)/.test(x));
|
|
887
|
+
const tool = raw.length !== c.checks.length;
|
|
888
|
+
return [raw.length ? `${raw.map((x) => `(${x})`).join(" && ")} (from the repo root)` : "", tool ? "the `check` tool (it must report RESULT: PASS)" : ""].filter(Boolean).join(", and ");
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** The shell line for one check: through pi-check when it is installed. */
|
|
892
|
+
export function checkLine(c, raw, cwd) {
|
|
893
|
+
const b = checkBroker(c);
|
|
894
|
+
if (/^pi-check(\s|$)/.test(raw)) {
|
|
895
|
+
// native pi-check run: what this checkout's changes affect; pre-existing failures do not count
|
|
896
|
+
if (!b) return { error: `${raw} — pi-check is not installed (pi install <path to pi-check>), or remove it from "checks"` };
|
|
897
|
+
return { line: `${shq(b.node)} ${shq(b.cli)} ${raw.replace(/^pi-check\s*/, "").trim() || "run"} --cwd ${shq(cwd)}`, factor: 3 };
|
|
898
|
+
}
|
|
899
|
+
// through the broker a check may wait behind other worktrees' runs: allow for that
|
|
900
|
+
return b ? { line: brokerCommand(b, raw, cwd), factor: 3 } : { line: raw, factor: 1 };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function runChecks(c, cwd) {
|
|
904
|
+
for (const cmd of c.checks) {
|
|
905
|
+
const l = checkLine(c, cmd, cwd);
|
|
906
|
+
if (l.error) return `check failed: ${l.error}`;
|
|
907
|
+
const r = await run(cwd, LOGIN_SHELL, ["-lc", l.line], { timeoutMs: c.checkMinutes * 60_000 * l.factor, env: cleanEnv() });
|
|
908
|
+
if (r.code !== 0) return `check failed: ${cmd} (exit ${r.code})\n${tail(r.out, 60)}`;
|
|
909
|
+
}
|
|
910
|
+
return undefined;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function judge(c, cwd, base, reply, item = {}) {
|
|
914
|
+
if (item.check) {
|
|
915
|
+
const gaps = reply.split("\n").filter((l) => /^\s*GAP:\s*\S/.test(l));
|
|
916
|
+
if (gaps.length) return `agent: GAPS ${gaps.length}`;
|
|
917
|
+
}
|
|
918
|
+
const needs = reply.match(/^\s*NEEDS:\s*~?(\d+)\s*files?\D+?~?(\d+)\s*lines?/im);
|
|
919
|
+
if (needs) return `agent: NEEDS ${needs[1]} files, ${needs[2]} lines`;
|
|
920
|
+
const blocked = reply.match(/^\s*BLOCKED:\s*(.+)$/m);
|
|
921
|
+
if (blocked) return `agent: BLOCKED: ${blocked[1].slice(0, 300)}`;
|
|
922
|
+
const m = await measure(cwd, base, c.allowedPaths);
|
|
923
|
+
if (m.files.length === 0) return item.check ? runChecks(c, cwd) : "no changes were made";
|
|
924
|
+
if (m.outside.length) return `files outside ${c.allowedPaths.join(", ")}: ${m.outside.slice(0, 8).join(", ")}`;
|
|
925
|
+
if (m.net > c.maxNetLines) return `${m.net} net added lines (limit ${c.maxNetLines})`;
|
|
926
|
+
if (m.growing > c.maxFilesGrowing) return `${m.growing} files gain lines (limit ${c.maxFilesGrowing})`;
|
|
927
|
+
return runChecks(c, cwd);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// ---------- worktrees ----------
|
|
931
|
+
|
|
932
|
+
async function prepareWorktree(ctx, slot) {
|
|
933
|
+
const { repo, c, log } = ctx;
|
|
934
|
+
const wt = join(worktreeRoot(repo), `w${slot}`);
|
|
935
|
+
const ready = `${wt}.ready`;
|
|
936
|
+
if (!existsSync(join(wt, ".git"))) {
|
|
937
|
+
mkdirSync(worktreeRoot(repo), { recursive: true });
|
|
938
|
+
await git(repo, "worktree", "prune");
|
|
939
|
+
const add = await git(repo, "worktree", "add", "--detach", wt, ctx.branch);
|
|
940
|
+
if (add.code !== 0) throw new Error(`git worktree add failed: ${tail(add.out, 5)}`);
|
|
941
|
+
rmSync(ready, { force: true });
|
|
942
|
+
}
|
|
943
|
+
if (!(await treeIsClean(wt))) throw new Error(`worktree ${wt} has unpreserved changes; refusing to reset it`);
|
|
944
|
+
const tip = (await git(repo, "rev-parse", ctx.branch)).out.trim();
|
|
945
|
+
if ((await git(wt, "merge-base", "--is-ancestor", "HEAD", tip)).code !== 0) throw new Error(`worktree ${wt} has unintegrated commits; refusing to reset it`);
|
|
946
|
+
const checkout = await git(wt, "checkout", "--detach", tip);
|
|
947
|
+
if (checkout.code !== 0) throw new Error(checkout.out);
|
|
948
|
+
// Ignored project-local plugins are absent in fresh worktrees. Reuse the installed extension, not its state/settings.
|
|
949
|
+
const bridge = ".pi/extensions/pi-flow-check", link = join(wt, bridge);
|
|
950
|
+
const broker = checkBroker({}), packageDir = broker && realpathSync(dirname(broker.cli));
|
|
951
|
+
if (packageDir && c.checkBroker !== false && !existsSync(join(packageDir, "extension.ts"))) throw new Error(`pi-check extension missing at ${packageDir}; repair its installation before starting workers`);
|
|
952
|
+
const existing = lstatSync(link, { throwIfNoEntry: false });
|
|
953
|
+
if (existing && (!existing.isSymbolicLink() || readlinkSync(link) !== packageDir)) throw new Error(`reserved pi-flow plugin bridge ${link} belongs to something else; refusing to overwrite it`);
|
|
954
|
+
if (existing && c.checkBroker === false) rmSync(link);
|
|
955
|
+
if (packageDir && c.checkBroker !== false) {
|
|
956
|
+
await ensureDir(wt, [bridge]);
|
|
957
|
+
mkdirSync(dirname(link), { recursive: true });
|
|
958
|
+
if (!existing) symlinkSync(packageDir, link, "dir");
|
|
959
|
+
}
|
|
960
|
+
for (const rel of c.copyFromRepo ?? []) {
|
|
961
|
+
try {
|
|
962
|
+
if (existsSync(join(repo, rel))) cpSync(join(repo, rel), join(wt, rel), { recursive: true, force: true });
|
|
963
|
+
} catch {}
|
|
964
|
+
}
|
|
965
|
+
if (!existsSync(ready) && c.setup) {
|
|
966
|
+
log(`SETUP w${slot}: ${c.setup}`);
|
|
967
|
+
ctx.state.phase = `installing dependencies in worktree w${slot}`;
|
|
968
|
+
ctx.save();
|
|
969
|
+
const r = await run(wt, LOGIN_SHELL, ["-lc", c.setup], { timeoutMs: c.setupMinutes * 60_000, env: cleanEnv() });
|
|
970
|
+
if (r.code !== 0) {
|
|
971
|
+
problem(repo, `setup failed in worktree w${slot}\ncommand: ${c.setup}\n\n${tail(r.out, 60)}`);
|
|
972
|
+
throw new Error(`setup failed (${firstLine(tail(r.out, 1))}) — full output in ${DIR}/problem.txt`);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
writeFileSync(ready, "");
|
|
976
|
+
return wt;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// ---------- sessions ----------
|
|
980
|
+
|
|
981
|
+
/** Session files of terminal-mode items (.pi-queue/sessions/**), by session id. */
|
|
982
|
+
export function sessionFiles(repo, ids) {
|
|
983
|
+
const root = join(repo, DIR, "sessions");
|
|
984
|
+
const want = new Set(ids);
|
|
985
|
+
const found = {};
|
|
986
|
+
const walk = (dir, depth) => {
|
|
987
|
+
let entries = [];
|
|
988
|
+
try {
|
|
989
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
990
|
+
} catch {
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
for (const e of entries) {
|
|
994
|
+
if (e.isDirectory() && depth < 3) walk(join(dir, e.name), depth + 1);
|
|
995
|
+
else if (e.isFile() && e.name.endsWith(".jsonl")) for (const id of want) if (e.name.includes(id)) found[id] = join(dir, e.name);
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
walk(root, 0);
|
|
999
|
+
return found;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Remove (or archive) item sessions: Pi Web sessions through its API, terminal ones by deleting their files.
|
|
1003
|
+
* Pi Web never deletes a running session. Marks them in state. Returns how many went. */
|
|
1004
|
+
export async function deleteSessions(ctx, ids, mode = "delete") {
|
|
1005
|
+
const sessions = ctx.state.sessions ?? {};
|
|
1006
|
+
const webIds = ids.filter((id) => sessions[id]?.web);
|
|
1007
|
+
const cliIds = ids.filter((id) => sessions[id] && !sessions[id].web);
|
|
1008
|
+
let n = 0;
|
|
1009
|
+
for (let i = 0; ctx.web && i < webIds.length; i += 500) {
|
|
1010
|
+
const batch = webIds.slice(i, i + 500);
|
|
1011
|
+
try {
|
|
1012
|
+
if (mode === "archive") {
|
|
1013
|
+
await ctx.web.ui({ action: "set", ids: batch, archived: true });
|
|
1014
|
+
for (const id of batch) sessions[id].archived = true;
|
|
1015
|
+
n += batch.length;
|
|
1016
|
+
} else {
|
|
1017
|
+
const r = await ctx.web.del(batch);
|
|
1018
|
+
for (const id of [...(r.deleted ?? []), ...(r.missing ?? [])]) if (sessions[id]) sessions[id].deleted = true;
|
|
1019
|
+
n += (r.deleted ?? []).filter((id) => batch.includes(id)).length;
|
|
1020
|
+
}
|
|
1021
|
+
} catch (e) {
|
|
1022
|
+
ctx.log(`CLEANUP failed: ${e?.message ?? e}`);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
if (cliIds.length && mode !== "archive") {
|
|
1026
|
+
const files = sessionFiles(ctx.repo, cliIds);
|
|
1027
|
+
for (const id of cliIds) {
|
|
1028
|
+
try {
|
|
1029
|
+
if (files[id]) rmSync(files[id], { force: true });
|
|
1030
|
+
sessions[id].deleted = true;
|
|
1031
|
+
if (files[id]) n++;
|
|
1032
|
+
} catch {}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
ctx.save();
|
|
1036
|
+
return n;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// ---------- one item ----------
|
|
1040
|
+
|
|
1041
|
+
async function workItem(ctx, slot, wt, item) {
|
|
1042
|
+
const { repo, state, save, log, lock, branch } = ctx;
|
|
1043
|
+
const itemChecks = loadJson(join(repo, ".tasks", ctx.c.task, "task.json"), {}).itemChecks;
|
|
1044
|
+
let c = itemConfig({ ...ctx.c, itemChecks }, item); // keep worker, judge and rebase checks identical
|
|
1045
|
+
const started = Date.now();
|
|
1046
|
+
const solo = wt === repo; // working directly in the repo folder
|
|
1047
|
+
let base = (await git(repo, "rev-parse", branch)).out.trim();
|
|
1048
|
+
if (!(await treeIsClean(wt))) throw new Error(`worktree ${wt} has unpreserved changes`);
|
|
1049
|
+
if (solo) await git(wt, "reset", "--hard", base);
|
|
1050
|
+
else await git(wt, "checkout", "--detach", "--force", base);
|
|
1051
|
+
await git(wt, "clean", "-fd");
|
|
1052
|
+
|
|
1053
|
+
const cliId = randomUUID();
|
|
1054
|
+
const ref = { id: ctx.web ? undefined : cliId };
|
|
1055
|
+
const turn = async (prompt, minutes, onAct) => {
|
|
1056
|
+
const worker = { ...ctx, c, onWorker: (identity) => { Object.assign(live, identity); save(); } };
|
|
1057
|
+
const result = await (ctx.web ? webTurn(worker, wt, ref, name, prompt, minutes, onAct) : piTurn(worker, wt, cliId, name, prompt, minutes, onAct));
|
|
1058
|
+
if (result.quiescent === false) throw new Error(`${result.err}; worktree quarantined`);
|
|
1059
|
+
return result;
|
|
1060
|
+
};
|
|
1061
|
+
const sessionId = cliId;
|
|
1062
|
+
const name = `queue ${item.id}: ${item.title}`.slice(0, 80);
|
|
1063
|
+
const live = { id: item.id, key: item.key, title: item.title, model: c.model, thinking: c.thinking, sessionId, worktree: wt, startedAt: new Date().toISOString(), doing: "starting", tools: 0, since: Date.now() };
|
|
1064
|
+
state.running[slot] = live;
|
|
1065
|
+
save();
|
|
1066
|
+
mkdirSync(join(repo, DIR, "live"), { recursive: true });
|
|
1067
|
+
const liveLog = join(repo, DIR, "live", `${item.id}.log`);
|
|
1068
|
+
let lastSave = 0;
|
|
1069
|
+
const setDoing = (what, isTool = false) => {
|
|
1070
|
+
if (ref.id && live.sessionId !== ref.id) live.sessionId = ref.id;
|
|
1071
|
+
live.doing = what;
|
|
1072
|
+
live.since = Date.now();
|
|
1073
|
+
if (isTool) live.tools++;
|
|
1074
|
+
try {
|
|
1075
|
+
appendFileSync(liveLog, `${new Date().toISOString().slice(11, 19)} ${what}\n`);
|
|
1076
|
+
} catch {}
|
|
1077
|
+
if (Date.now() - lastSave > 2000) {
|
|
1078
|
+
lastSave = Date.now();
|
|
1079
|
+
save();
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
const onActivity = (what) => setDoing(what, what !== "thinking" && what !== "compacting context" && !what.endsWith(" failed"));
|
|
1083
|
+
|
|
1084
|
+
const prior = join(repo, DIR, "notes", `${item.id}.md`);
|
|
1085
|
+
const previous = existsSync(prior) ? `\n\nPrevious attempt and manager diagnosis (evidence, not permission to expand scope):\n${readFileSync(prior, "utf8").slice(-12000)}` : "";
|
|
1086
|
+
let t = await turn(itemPrompt(c, item) + previous, c.itemMinutes, onActivity);
|
|
1087
|
+
if (ref.id) {
|
|
1088
|
+
state.sessions = { ...(state.sessions ?? {}), [ref.id]: { item: item.id, status: "running", web: !!ctx.web } };
|
|
1089
|
+
save();
|
|
1090
|
+
}
|
|
1091
|
+
let redone = false;
|
|
1092
|
+
let bypassed = false;
|
|
1093
|
+
const raise = (why, limits) => {
|
|
1094
|
+
c = { ...c, ...limits };
|
|
1095
|
+
log(`LIMITS RAISED ${item.id} · ${why} · now ${c.maxFilesGrowing} files / ${c.maxNetLines} lines`);
|
|
1096
|
+
};
|
|
1097
|
+
const verdict = async () => {
|
|
1098
|
+
if (t.status === "stopped") return "stopped by request";
|
|
1099
|
+
if (t.status === "timeout") return `timed out after ${c.itemMinutes} min`;
|
|
1100
|
+
if (t.status === "error") return `pi exited with an error (code ${t.code}): ${firstLine(t.err || t.reply).slice(0, 200)}`;
|
|
1101
|
+
if (!bypassed && (await bypassAsked(ctx, ref))) {
|
|
1102
|
+
bypassed = true;
|
|
1103
|
+
raise("you asked for it in the session", UNLIMITED);
|
|
1104
|
+
}
|
|
1105
|
+
setDoing("runner: checking scope, size and checks");
|
|
1106
|
+
save();
|
|
1107
|
+
return judge(c, wt, base, t.reply, item);
|
|
1108
|
+
};
|
|
1109
|
+
let reason = await verdict();
|
|
1110
|
+
// stopped only because of the size limit: raise it a level and let the same agent carry on in the same session
|
|
1111
|
+
for (const level of ctx.c.escalate ?? []) {
|
|
1112
|
+
if (!reason || t.status !== "ok" || !isSizeStop(reason) || ctx.stopRequested()) break;
|
|
1113
|
+
if (level.maxNetLines <= c.maxNetLines && level.maxFilesGrowing <= c.maxFilesGrowing) continue; // already past this level
|
|
1114
|
+
const need = reason.match(/^agent: NEEDS (\d+) files, (\d+) lines/);
|
|
1115
|
+
if (need && (level.maxFilesGrowing < Number(need[1]) || level.maxNetLines < Number(need[2]))) continue; // too small for the estimate
|
|
1116
|
+
raise(firstLine(reason).slice(0, 100), level);
|
|
1117
|
+
setDoing("continuing with raised limits");
|
|
1118
|
+
t = await turn(raisePrompt(c, reason), Math.max(c.itemMinutes, level.itemMinutes ?? 90), onActivity);
|
|
1119
|
+
reason = await verdict();
|
|
1120
|
+
}
|
|
1121
|
+
for (let round = 0; reason && t.status === "ok" && round < c.fixRounds; round++) {
|
|
1122
|
+
if (reason.startsWith("agent: BLOCKED") || reason.startsWith("agent: GAPS") || reason === "no changes were made" || ctx.stopRequested()) break;
|
|
1123
|
+
setDoing(`fixing: ${firstLine(reason).slice(0, 80)}`);
|
|
1124
|
+
t = await turn(fixPrompt(c, reason), Math.max(10, Math.round(c.itemMinutes / 2)), onActivity);
|
|
1125
|
+
reason = await verdict();
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
let kind = reason ? "fail" : "pass";
|
|
1129
|
+
let splitLines = [];
|
|
1130
|
+
let fixLines = [];
|
|
1131
|
+
const sessionTags = `${c.model ? ` [model: ${c.model}]` : ""}${c.thinking ? ` [thinking: ${c.thinking}]` : ""}`;
|
|
1132
|
+
// a check found production gaps: queue fix items before it and check again after them
|
|
1133
|
+
if (reason?.startsWith("agent: GAPS") && item.check) {
|
|
1134
|
+
const round = (state.gapRound?.[item.key] ?? 0) + 1;
|
|
1135
|
+
const gaps = t.reply.split("\n").map((l) => l.match(/^\s*GAP:\s*(.+?)\s*$/)?.[1]).filter(Boolean).slice(0, 8);
|
|
1136
|
+
if (c.autoFix !== false && round <= (c.gapRounds ?? 2)) {
|
|
1137
|
+
const base0 = (state.fixes?.[item.key] ?? []).length;
|
|
1138
|
+
fixLines = gaps.map((g, i) => `- [ ] ${item.id}-fix${base0 + i + 1} — ${/Done when:/i.test(g) ? g : `${g}. Done when: ${item.id} can verify it.`}${sessionTags}`);
|
|
1139
|
+
kind = "gaps";
|
|
1140
|
+
state.gapRound = { ...(state.gapRound ?? {}), [item.key]: round };
|
|
1141
|
+
} else reason = `gaps remain after ${round - 1} fix round(s): ${gaps.map((g) => g.slice(0, 120)).join(" | ")}`;
|
|
1142
|
+
}
|
|
1143
|
+
// BLOCKED while other work is still pending (often a prerequisite that has not run yet): try once more after it
|
|
1144
|
+
const blockedOnly = reason?.startsWith("agent: BLOCKED") && !isSizeStop(reason) && !SCOPE_REASON.test(reason);
|
|
1145
|
+
if (kind === "fail" && blockedOnly && c.deferBlocked !== false && !state.deferred?.[item.key] && ctx.otherWorkPending?.(item)) kind = "deferred";
|
|
1146
|
+
if (kind === "fail" && reason && c.autoSplit !== false && !item.child && t.status === "ok" && !ctx.stopRequested() && (reason.startsWith("agent: BLOCKED") || isSizeStop(reason)) && !SCOPE_REASON.test(reason)) {
|
|
1147
|
+
setDoing("asking the agent to split this item");
|
|
1148
|
+
save();
|
|
1149
|
+
const st = await turn(splitPrompt(c, item, reason), 15, onActivity);
|
|
1150
|
+
const texts = String(st.reply)
|
|
1151
|
+
.split("\n")
|
|
1152
|
+
.map((l) => l.match(/^\s*ITEM:\s*(.+?)\s*$/)?.[1])
|
|
1153
|
+
.filter(Boolean)
|
|
1154
|
+
.slice(0, c.splitMax ?? 8);
|
|
1155
|
+
if (st.status === "ok" && texts.length >= 2) {
|
|
1156
|
+
const kid = (n) => `${item.id}${String.fromCharCode(96 + n)}`;
|
|
1157
|
+
splitLines = texts.map(
|
|
1158
|
+
(txt, i) =>
|
|
1159
|
+
`- [ ] ${kid(i + 1)} — ${txt.replace(/\[after:\s*([^\]]+)\]/i, (_, deps) => `[after: ${deps.split(/[,\s]+/).filter(Boolean).map((d) => (/^\d+$/.test(d) ? kid(Number(d)) : d)).join(", ")}]`)}${sessionTags}`,
|
|
1160
|
+
);
|
|
1161
|
+
kind = "split";
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
let sha = "";
|
|
1165
|
+
let m;
|
|
1166
|
+
for (let attempt = 0; !reason && attempt < 2; attempt++) {
|
|
1167
|
+
if (ctx.stopRequested()) { reason = "stopped by request"; kind = "fail"; break; }
|
|
1168
|
+
m = await measure(wt, base, c.allowedPaths);
|
|
1169
|
+
if (m.files.length === 0) {
|
|
1170
|
+
sha = "checked"; // a [check] item that verified without changing anything: nothing to commit
|
|
1171
|
+
break;
|
|
1172
|
+
}
|
|
1173
|
+
await git(wt, "add", "-A");
|
|
1174
|
+
await git(wt, "reset", "--soft", base);
|
|
1175
|
+
const commit = await git(wt, "commit", "-m", `${item.id}: ${item.title}`);
|
|
1176
|
+
if (commit.code !== 0) {
|
|
1177
|
+
reason = `commit failed: ${tail(commit.out, 5).replace(/\n/g, " ")}`;
|
|
1178
|
+
kind = "fail";
|
|
1179
|
+
} else {
|
|
1180
|
+
// integrate one at a time: rebase onto the branch tip, re-check if it moved, fast-forward
|
|
1181
|
+
setDoing("waiting to merge");
|
|
1182
|
+
save();
|
|
1183
|
+
const r = solo
|
|
1184
|
+
? { sha: (await git(repo, "rev-parse", "--short", "HEAD")).out.trim() } // already committed on the branch
|
|
1185
|
+
: await lock(async () => {
|
|
1186
|
+
setDoing("merging into the branch");
|
|
1187
|
+
save();
|
|
1188
|
+
const tip = (await git(repo, "rev-parse", branch)).out.trim();
|
|
1189
|
+
if (tip !== base) {
|
|
1190
|
+
const rb = await git(wt, "rebase", tip);
|
|
1191
|
+
if (rb.code !== 0) {
|
|
1192
|
+
await git(wt, "rebase", "--abort");
|
|
1193
|
+
return { reason: "conflict with an item merged meanwhile", kind: "conflict" };
|
|
1194
|
+
}
|
|
1195
|
+
const broke = await runChecks(c, wt);
|
|
1196
|
+
if (broke) return { reason: `breaks checks after merging with other items: ${firstLine(broke)}`, kind: "conflict" };
|
|
1197
|
+
}
|
|
1198
|
+
if (ctx.stopRequested()) return { reason: "stopped by request", kind: "fail" };
|
|
1199
|
+
const head = (await git(wt, "rev-parse", "HEAD")).out.trim();
|
|
1200
|
+
const ff = await git(repo, "merge", "--ff-only", head);
|
|
1201
|
+
if (ff.code !== 0) return { reason: `could not fast-forward ${branch}: ${tail(ff.out, 3).replace(/\n/g, " ")}`, kind: "fail" };
|
|
1202
|
+
return { sha: (await git(repo, "rev-parse", "--short", "HEAD")).out.trim() };
|
|
1203
|
+
});
|
|
1204
|
+
if (r.sha) sha = r.sha;
|
|
1205
|
+
else {
|
|
1206
|
+
reason = r.reason;
|
|
1207
|
+
kind = r.kind;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
// the branch moved and this change no longer fits: redo it on the latest code in the same session (once),
|
|
1211
|
+
// instead of throwing the work away and starting from scratch at the end
|
|
1212
|
+
if (reason && kind === "conflict" && attempt === 0 && c.redoOnConflict !== false && !solo && !ctx.stopRequested()) {
|
|
1213
|
+
setDoing("redoing the item on the latest code");
|
|
1214
|
+
const archive = await git(repo, "branch", `queue-failed/${item.id}-${started}-redo`, (await git(wt, "rev-parse", "HEAD")).out.trim());
|
|
1215
|
+
if (archive.code !== 0) throw new Error(`failed to preserve conflicting work: ${archive.out}`);
|
|
1216
|
+
base = (await git(repo, "rev-parse", branch)).out.trim();
|
|
1217
|
+
await git(wt, "checkout", "--detach", "--force", base);
|
|
1218
|
+
await git(wt, "clean", "-fd");
|
|
1219
|
+
redone = true;
|
|
1220
|
+
log(`REDO ${item.id} · ${firstLine(reason)} · same session, on the latest code`);
|
|
1221
|
+
t = await turn(redoPrompt(c, reason), c.itemMinutes, onActivity);
|
|
1222
|
+
reason = await verdict();
|
|
1223
|
+
kind = reason ? "fail" : "pass";
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
break;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const mins = Math.round((Date.now() - started) / 60_000);
|
|
1230
|
+
mkdirSync(join(repo, DIR, "notes"), { recursive: true });
|
|
1231
|
+
writeFileSync(
|
|
1232
|
+
join(repo, DIR, "notes", `${item.id}.md`),
|
|
1233
|
+
`# ${item.line}\n\nResult: ${reason ? `${kind.toUpperCase()} — ${reason}` : `PASS ${sha}`}\nSession: ${ref.id ?? sessionId}${ctx.web ? " (Pi Web)" : ""}\nWorktree: ${wt}\n\n## Agent's last reply\n\n${t.reply}\n${t.err ? `\n## stderr\n\n${t.err.slice(-4000)}\n` : ""}`,
|
|
1234
|
+
);
|
|
1235
|
+
|
|
1236
|
+
let kept = "";
|
|
1237
|
+
if (reason) {
|
|
1238
|
+
await lock(async () => {
|
|
1239
|
+
const staged = await git(wt, "add", "-A");
|
|
1240
|
+
if (staged.code !== 0) throw new Error(`failed to preserve work: ${staged.out}`);
|
|
1241
|
+
const hasWork = (await git(wt, "rev-parse", "HEAD")).out.trim() !== base || !(await treeIsClean(wt));
|
|
1242
|
+
if (hasWork) {
|
|
1243
|
+
const preserved = await git(wt, "commit", "--no-verify", "--allow-empty", "-m", `queue FAILED ${item.id}: ${firstLine(reason)}`);
|
|
1244
|
+
if (preserved.code !== 0) throw new Error(`failed to preserve work: ${preserved.out}`);
|
|
1245
|
+
const backup = `queue-failed/${item.id}-${started}`;
|
|
1246
|
+
const archived = await git(repo, "branch", backup, (await git(wt, "rev-parse", "HEAD")).out.trim());
|
|
1247
|
+
if (archived.code !== 0) throw new Error(`failed to preserve work: ${archived.out}`);
|
|
1248
|
+
kept = ` · work kept on ${backup}`;
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
if (solo) await git(wt, "reset", "--hard", reason ? base : "HEAD");
|
|
1253
|
+
else await git(wt, "checkout", "--detach", "--force", (await git(repo, "rev-parse", branch)).out.trim());
|
|
1254
|
+
await git(wt, "clean", "-fd");
|
|
1255
|
+
delete state.running[slot];
|
|
1256
|
+
|
|
1257
|
+
if (!reason) {
|
|
1258
|
+
state.done[item.key] = sha;
|
|
1259
|
+
delete state.failed[item.key];
|
|
1260
|
+
log(sha === "checked" ? `PASS ${item.id} · checked, nothing to change · ${mins} min · w${slot}` : `PASS ${item.id} · ${sha} · ${m.files.length} files · net ${m.net} lines · ${mins} min · w${slot}`);
|
|
1261
|
+
} else if (kind === "gaps") {
|
|
1262
|
+
state.fixes = { ...(state.fixes ?? {}), [item.key]: [...(state.fixes?.[item.key] ?? []), ...fixLines] };
|
|
1263
|
+
const ids = fixLines.flatMap((l) => parseQueue(l).map((i) => i.id));
|
|
1264
|
+
state.waitFor = { ...(state.waitFor ?? {}), [item.key]: [...new Set([...(state.waitFor?.[item.key] ?? []), ...ids])] };
|
|
1265
|
+
delete state.failed[item.key];
|
|
1266
|
+
log(`GAPS ${item.id} · found ${ids.length} production gap(s) → queued ${ids.join(", ")} · ${item.id} checks again after them · ${mins} min`);
|
|
1267
|
+
} else if (kind === "deferred") {
|
|
1268
|
+
state.deferred = { ...(state.deferred ?? {}), [item.key]: true };
|
|
1269
|
+
delete state.failed[item.key];
|
|
1270
|
+
log(`DEFERRED ${item.id} · ${firstLine(reason).slice(0, 140)} · will try again once the other pending items are done · ${mins} min`);
|
|
1271
|
+
} else if (kind === "split") {
|
|
1272
|
+
state.splits = { ...(state.splits ?? {}), [item.key]: splitLines };
|
|
1273
|
+
delete state.failed[item.key];
|
|
1274
|
+
log(`SPLIT ${item.id} · could not be done in one go (${firstLine(reason).slice(0, 80)}) · now ${splitLines.length} smaller items ${item.id}a…${item.id}${String.fromCharCode(96 + splitLines.length)}${kept}`);
|
|
1275
|
+
} else if (kind === "conflict" && !state.retried[item.key]) {
|
|
1276
|
+
state.retried[item.key] = true;
|
|
1277
|
+
log(`CONFLICT ${item.id} · ${firstLine(reason)}${kept} · will retry at the end · ${mins} min`);
|
|
1278
|
+
} else if (reason !== "stopped by request") {
|
|
1279
|
+
state.failed[item.key] = firstLine(reason);
|
|
1280
|
+
log(`FAIL ${item.id} · ${firstLine(reason)}${kept} · ${mins} min · w${slot}`);
|
|
1281
|
+
} else {
|
|
1282
|
+
log(`STOPPED ${item.id}${kept}`);
|
|
1283
|
+
}
|
|
1284
|
+
if (ref.id && state.sessions?.[ref.id]) {
|
|
1285
|
+
state.sessions[ref.id].status = !reason ? "pass" : ["split", "gaps", "deferred"].includes(kind) ? kind : reason === "stopped by request" ? "stopped" : "fail";
|
|
1286
|
+
if (!reason && ctx.c.deleteSessions === "pass") await deleteSessions(ctx, [ref.id]);
|
|
1287
|
+
}
|
|
1288
|
+
save();
|
|
1289
|
+
return { pass: !reason, kind, redone, stopped: reason === "stopped by request" };
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// ---------- the scheduler ----------
|
|
1293
|
+
|
|
1294
|
+
export async function main(repo) {
|
|
1295
|
+
await ensureDir(repo);
|
|
1296
|
+
const log = makeLog(repo);
|
|
1297
|
+
const pidFile = join(repo, DIR, "runner.pid");
|
|
1298
|
+
const release = acquireProcessLock(pidFile);
|
|
1299
|
+
const onSignal = () => writeFileSync(join(repo, DIR, "STOP"), "signal");
|
|
1300
|
+
const signals = ["SIGTERM", "SIGINT", "SIGHUP"];
|
|
1301
|
+
process.on("exit", release);
|
|
1302
|
+
for (const sig of signals) process.on(sig, onSignal);
|
|
1303
|
+
let heartbeat;
|
|
1304
|
+
try {
|
|
1305
|
+
const c = readConfig(repo);
|
|
1306
|
+
const statePath = join(repo, DIR, "state.json");
|
|
1307
|
+
const state = { done: {}, failed: {}, retried: {}, running: {}, ...loadJson(statePath, {}) };
|
|
1308
|
+
const stopRequested = () => existsSync(join(repo, DIR, "STOP"));
|
|
1309
|
+
const pauseRequested = () => existsSync(join(repo, DIR, "PAUSE"));
|
|
1310
|
+
if (stopRequested() || pauseRequested()) return log("NOT STARTED: manually stopped/paused; explicit /queue start is required");
|
|
1311
|
+
if (Object.keys(state.running).length) {
|
|
1312
|
+
problem(repo, "Interrupted workers/worktrees require reconciliation; refusing to reset or reuse them.");
|
|
1313
|
+
return log("NOT STARTED: interrupted workers remain; worktrees preserved");
|
|
1314
|
+
}
|
|
1315
|
+
state.phase = "starting";
|
|
1316
|
+
delete state.startedAt;
|
|
1317
|
+
state.task = c.task;
|
|
1318
|
+
state.runId = randomUUID();
|
|
1319
|
+
const save = () => { state.heartbeat = Date.now(); saveJson(statePath, state); };
|
|
1320
|
+
save();
|
|
1321
|
+
heartbeat = setInterval(save, 60000);
|
|
1322
|
+
rmSync(join(repo, DIR, "problem.txt"), { force: true });
|
|
1323
|
+
|
|
1324
|
+
const branch = (await git(repo, "branch", "--show-current")).out.trim();
|
|
1325
|
+
if (!branch) return log("NOT STARTED: the repo is on a detached HEAD; check out a branch first");
|
|
1326
|
+
if (!(await treeIsClean(repo))) return log("NOT STARTED: working tree is not clean (commit or stash first)");
|
|
1327
|
+
const queuePath = join(repo, c.queue);
|
|
1328
|
+
if (!c.queue) return log("NOT STARTED: no work list set (run /task to plan one, or set \"queue\" in .pi-queue/config.json)");
|
|
1329
|
+
if (!existsSync(queuePath)) return log(`NOT STARTED: queue file ${c.queue} not found`);
|
|
1330
|
+
try { dependencyGraph(allItems(readFileSync(queuePath, "utf8"), state)); }
|
|
1331
|
+
catch (e) { return log(`NOT STARTED: ${e.message}`); }
|
|
1332
|
+
|
|
1333
|
+
const ctx = { repo, c, state, save, log, lock: mutex(), branch, stopRequested, pi: await resolvePi(c), env: await loginEnv(), web: null };
|
|
1334
|
+
if (c.runIn !== "cli") {
|
|
1335
|
+
const found = await findPiWeb(c);
|
|
1336
|
+
if (found.url) {
|
|
1337
|
+
c.piWebUrl = found.url;
|
|
1338
|
+
ctx.web = webClient(c);
|
|
1339
|
+
} else if (c.runIn === "web") return log(`NOT STARTED: Pi Web not usable: ${found.reason}`);
|
|
1340
|
+
else log(`Pi Web not usable (${found.reason}); items run as background pi instead`);
|
|
1341
|
+
}
|
|
1342
|
+
const parallel = c.worktrees ? Math.max(1, Math.floor(c.parallel) || 1) : 1;
|
|
1343
|
+
log(`START · branch ${branch} · ${c.worktrees ? `up to ${parallel} parallel (worktrees${c.adaptive !== false ? ", adaptive" : ""})` : "one at a time in your repo folder"} · ${c.model || "default model"} @ ${c.thinking || "default"} · ${ctx.web ? `items run as Pi Web sessions (${c.piWebUrl})` : `items run as background pi (${ctx.pi})`}`);
|
|
1344
|
+
|
|
1345
|
+
// worktrees are made when a slot is first used; w0 now, for the baseline check
|
|
1346
|
+
let maxW = c.worktrees ? Math.max(1, Math.floor(c.parallel) || 1) : 1;
|
|
1347
|
+
const minW = Math.min(maxW, Math.max(1, Math.floor(c.parallelMin) || 1));
|
|
1348
|
+
let width = Math.min(maxW, Math.max(minW, Math.floor(c.parallelStart) || maxW));
|
|
1349
|
+
const wts = [];
|
|
1350
|
+
const prepLock = mutex();
|
|
1351
|
+
try {
|
|
1352
|
+
wts[0] = c.worktrees ? await prepareWorktree(ctx, 0) : repo;
|
|
1353
|
+
} catch (e) {
|
|
1354
|
+
return log(`NOT STARTED: ${e.message}`);
|
|
1355
|
+
}
|
|
1356
|
+
if (checkBroker(c)) log("CHECKS via pi-check · shared across worktrees, cached by file content, pre-existing Go failures do not block");
|
|
1357
|
+
state.phase = "checking the unchanged code (baseline)";
|
|
1358
|
+
save();
|
|
1359
|
+
const baseline = await runChecks(c, wts[0]);
|
|
1360
|
+
state.phase = "running items";
|
|
1361
|
+
state.width = width;
|
|
1362
|
+
save();
|
|
1363
|
+
if (baseline) {
|
|
1364
|
+
state.phase = "diagnosing the failed baseline";
|
|
1365
|
+
save();
|
|
1366
|
+
const why = await diagnose(ctx, wts[0], baseline);
|
|
1367
|
+
problem(repo, `checks fail before any change (worktree w0)\n\n${baseline}\n\n--- diagnosis ---\n${why}`);
|
|
1368
|
+
state.phase = "";
|
|
1369
|
+
save();
|
|
1370
|
+
return log(`NOT STARTED: ${firstLine(baseline)} — the checks already fail before any change. Full output and diagnosis: ${DIR}/problem.txt`);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
state.startedAt = Date.now();
|
|
1374
|
+
save();
|
|
1375
|
+
process.send?.({ type: "ready" });
|
|
1376
|
+
const inFlight = new Map(); // key -> item
|
|
1377
|
+
const nextItem = () => {
|
|
1378
|
+
let items = [];
|
|
1379
|
+
try {
|
|
1380
|
+
items = allItems(readFileSync(queuePath, "utf8"), state); // re-read: items can be added while running
|
|
1381
|
+
} catch {}
|
|
1382
|
+
const byId = new Map(items.map((i) => [i.id, i]));
|
|
1383
|
+
const finished = (i) => state.done[i.key] || state.failed[i.key];
|
|
1384
|
+
// a split parent counts as done when all its parts are done
|
|
1385
|
+
const depState = (id) => {
|
|
1386
|
+
if (id === "*") return "wildcard";
|
|
1387
|
+
const i = byId.get(id);
|
|
1388
|
+
if (i) return state.done[i.key] ? "done" : state.failed[i.key] ? "failed" : "open";
|
|
1389
|
+
const kids = items.filter((k) => k.child && k.id.startsWith(id) && /^[a-z]$/.test(k.id.slice(id.length)));
|
|
1390
|
+
if (kids.length) return kids.every((k) => state.done[k.key]) ? "done" : kids.some((k) => state.failed[k.key]) ? "failed" : "open";
|
|
1391
|
+
return "unknown"; // a typo or an id from elsewhere: ignored
|
|
1392
|
+
};
|
|
1393
|
+
const open = items.filter((i) => !finished(i) && !inFlight.has(i.key));
|
|
1394
|
+
const parked = (i) => state.retried[i.key] || state.deferred?.[i.key];
|
|
1395
|
+
const eligible = new Set(open.filter((i) => !parked(i) || inFlight.size === 0).map((i) => i.key));
|
|
1396
|
+
const ordered = orderQueue(items, state).filter((i) => eligible.has(i.key));
|
|
1397
|
+
for (const i of ordered) {
|
|
1398
|
+
let wait = false;
|
|
1399
|
+
for (const d of i.after ?? []) {
|
|
1400
|
+
const st = depState(d);
|
|
1401
|
+
if (st === "failed" || st === "unknown") {
|
|
1402
|
+
state.failed[i.key] = `depends on ${d}, which ${st === "unknown" ? "is unknown" : "failed"}`;
|
|
1403
|
+
log(`FAIL ${i.id} · depends on ${d}, which failed · not started`);
|
|
1404
|
+
save();
|
|
1405
|
+
wait = true;
|
|
1406
|
+
break;
|
|
1407
|
+
}
|
|
1408
|
+
if (st === "open") wait = true;
|
|
1409
|
+
if (st === "wildcard" && (items.some((o) => o !== i && !finished(o) && !(o.after ?? []).includes("*")) || inFlight.size)) wait = true;
|
|
1410
|
+
}
|
|
1411
|
+
if (wait) continue;
|
|
1412
|
+
if (i.areas?.length && [...inFlight.values()].some((r) => r.areas?.length && areasOverlap(i.areas, r.areas))) continue;
|
|
1413
|
+
return i;
|
|
1414
|
+
}
|
|
1415
|
+
return undefined;
|
|
1416
|
+
};
|
|
1417
|
+
|
|
1418
|
+
ctx.otherWorkPending = (item) => {
|
|
1419
|
+
try {
|
|
1420
|
+
const items = allItems(readFileSync(queuePath, "utf8"), state);
|
|
1421
|
+
return items.some((i) => i.key !== item.key && !state.done[i.key] && !state.failed[i.key] && !state.deferred?.[i.key]) || [...inFlight.keys()].some((k) => k !== item.key);
|
|
1422
|
+
} catch {
|
|
1423
|
+
return false;
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
const controlDir = join(repo, DIR, "control");
|
|
1427
|
+
const readControl = () => {
|
|
1428
|
+
if (!existsSync(controlDir)) return;
|
|
1429
|
+
const files = readdirSync(controlDir).filter((f) => f.endsWith(".json")).sort();
|
|
1430
|
+
if (!files.length) return;
|
|
1431
|
+
const list = files.filter((f) => !(state.controlReceipts ?? []).includes(f)).map((f) => loadJson(join(controlDir, f), {}));
|
|
1432
|
+
const items = (() => {
|
|
1433
|
+
try {
|
|
1434
|
+
return allItems(readFileSync(queuePath, "utf8"), state);
|
|
1435
|
+
} catch {
|
|
1436
|
+
return [];
|
|
1437
|
+
}
|
|
1438
|
+
})();
|
|
1439
|
+
for (const ctl of list) {
|
|
1440
|
+
if (ctl.task !== c.task) { log("CONTROL ignored: stale task"); continue; }
|
|
1441
|
+
if (ctl.by === "auto" && (stopRequested() || pauseRequested() || ctl.action !== "retry" || !ctl.ids?.length || !ctl.ids.every((id) => items.some((i) => i.id === id && state.failed[i.key] === ctl.failures?.[id])))) continue;
|
|
1442
|
+
if (ctl?.action === "parallel") {
|
|
1443
|
+
const n = Math.max(1, Math.floor(Number(ctl.value)) || 1);
|
|
1444
|
+
if (!c.worktrees) {
|
|
1445
|
+
log("CONTROL parallel ignored: this run works in the repo folder (worktrees: false)");
|
|
1446
|
+
continue;
|
|
1447
|
+
}
|
|
1448
|
+
while (slots < n) free.push(slots++);
|
|
1449
|
+
maxW = n; // your number is the ceiling; adaptive narrowing still applies below it
|
|
1450
|
+
setWidth(n, "asked by you");
|
|
1451
|
+
try {
|
|
1452
|
+
const cf = join(repo, DIR, "config.json");
|
|
1453
|
+
writeFileSync(cf, `${JSON.stringify({ ...loadJson(cf, {}), parallel: n }, null, 2)}\n`);
|
|
1454
|
+
} catch {}
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1457
|
+
const changed = applyControl(state, items, ctl ?? {});
|
|
1458
|
+
if (changed.length && ["retry", "rerun", "recheck", "next"].includes(ctl.action)) { failsInARow = 0; state.failureStreak = 0; halted = ""; }
|
|
1459
|
+
log(`CONTROL ${ctl?.action} ${(ctl?.ids ?? []).join(",") || (ctl?.action === "add" ? "" : "all")} · ${changed.length ? changed.join(", ") : "nothing matched"}${ctl?.by ? ` · by ${ctl.by}` : ""}`);
|
|
1460
|
+
}
|
|
1461
|
+
state.controlReceipts = [...(state.controlReceipts ?? []), ...files].slice(-1000);
|
|
1462
|
+
save();
|
|
1463
|
+
for (const file of files) rmSync(join(controlDir, file), { force: true });
|
|
1464
|
+
};
|
|
1465
|
+
// a passed [check] runs again when an item it covers changes after it
|
|
1466
|
+
const recheckAfter = (passed) => {
|
|
1467
|
+
if (c.recheck === false || passed.check) return;
|
|
1468
|
+
let items = [];
|
|
1469
|
+
try {
|
|
1470
|
+
items = allItems(readFileSync(queuePath, "utf8"), state);
|
|
1471
|
+
} catch {}
|
|
1472
|
+
for (const chk of items) {
|
|
1473
|
+
if (!chk.check || !state.done[chk.key] || chk.key === passed.key) continue;
|
|
1474
|
+
const covers = (chk.after ?? []).includes(passed.id) || (chk.after ?? []).includes("*") || (chk.areas?.length && passed.areas?.length && areasOverlap(chk.areas, passed.areas));
|
|
1475
|
+
if (!covers) continue;
|
|
1476
|
+
delete state.done[chk.key];
|
|
1477
|
+
log(`RECHECK ${chk.id} · ${passed.id} changed what it checks · queued again`);
|
|
1478
|
+
}
|
|
1479
|
+
save();
|
|
1480
|
+
};
|
|
1481
|
+
|
|
1482
|
+
let failsInARow = state.failureStreak ?? 0;
|
|
1483
|
+
let cleanStreak = 0;
|
|
1484
|
+
let halted = failsInARow >= c.stopAfterFailuresInARow ? `${failsInARow} failures in a row` : "";
|
|
1485
|
+
const free = Array.from({ length: maxW }, (_, i) => i);
|
|
1486
|
+
let slots = maxW; // worktree slots created so far
|
|
1487
|
+
const running = new Map();
|
|
1488
|
+
const setWidth = (w, why) => {
|
|
1489
|
+
if (w === width) return;
|
|
1490
|
+
log(`WIDTH ${width} → ${w} · ${why}`);
|
|
1491
|
+
width = w;
|
|
1492
|
+
state.width = w;
|
|
1493
|
+
save();
|
|
1494
|
+
};
|
|
1495
|
+
try {
|
|
1496
|
+
while (true) {
|
|
1497
|
+
readControl();
|
|
1498
|
+
while (!halted && running.size < width && free.length && !stopRequested() && !pauseRequested()) {
|
|
1499
|
+
free.sort((a, b) => a - b);
|
|
1500
|
+
if (free[0] >= maxW) break; // slots above a lowered ceiling stay idle
|
|
1501
|
+
const item = nextItem();
|
|
1502
|
+
if (!item) break;
|
|
1503
|
+
free.sort((a, b) => a - b);
|
|
1504
|
+
const slot = free.shift();
|
|
1505
|
+
inFlight.set(item.key, item);
|
|
1506
|
+
state.running[slot] = { id: item.id, key: item.key, doing: "preparing worktree", startedAt: new Date().toISOString() };
|
|
1507
|
+
save();
|
|
1508
|
+
const p = (async () => {
|
|
1509
|
+
if (!wts[slot]) {
|
|
1510
|
+
wts[slot] = await prepLock(() => prepareWorktree(ctx, slot));
|
|
1511
|
+
}
|
|
1512
|
+
return workItem(ctx, slot, wts[slot], item);
|
|
1513
|
+
})()
|
|
1514
|
+
.catch((e) => {
|
|
1515
|
+
state.failed[item.key] = `runner error: ${e?.message ?? e}`;
|
|
1516
|
+
state.running[slot] = { ...(state.running[slot] ?? {}), id: item.id, key: item.key, worktree: wts[slot], quarantined: true };
|
|
1517
|
+
save();
|
|
1518
|
+
log(`ERROR ${item.id} · ${e?.message ?? e} · slot quarantined; work preserved`);
|
|
1519
|
+
return { pass: false, kind: "fail" };
|
|
1520
|
+
})
|
|
1521
|
+
.then((r) => ({ r, slot, item }));
|
|
1522
|
+
running.set(slot, p);
|
|
1523
|
+
}
|
|
1524
|
+
if (running.size === 0) {
|
|
1525
|
+
readControl();
|
|
1526
|
+
if (!halted && !stopRequested() && !pauseRequested() && free.some((slot) => slot < maxW) && nextItem()) continue; // the manager queued more work
|
|
1527
|
+
break;
|
|
1528
|
+
}
|
|
1529
|
+
// wake every few seconds to apply control requests while items run
|
|
1530
|
+
let wake;
|
|
1531
|
+
const done = await Promise.race([...running.values(), new Promise((res) => { wake = setTimeout(() => res(null), 5000); })]);
|
|
1532
|
+
clearTimeout(wake);
|
|
1533
|
+
if (!done) continue;
|
|
1534
|
+
const { r, slot, item } = done;
|
|
1535
|
+
running.delete(slot);
|
|
1536
|
+
if (!state.running[slot]?.quarantined) free.push(slot);
|
|
1537
|
+
inFlight.delete(item.key);
|
|
1538
|
+
if (r.pass) {
|
|
1539
|
+
failsInARow = 0;
|
|
1540
|
+
recheckAfter(item);
|
|
1541
|
+
} else if (r.kind === "fail" && !r.stopped) failsInARow++;
|
|
1542
|
+
state.failureStreak = failsInARow;
|
|
1543
|
+
save();
|
|
1544
|
+
if (c.adaptive !== false && c.worktrees) {
|
|
1545
|
+
if (r.redone || r.kind === "conflict") {
|
|
1546
|
+
cleanStreak = 0;
|
|
1547
|
+
setWidth(Math.max(minW, width - 1), `${item.id} conflicted with other work`);
|
|
1548
|
+
} else if (r.pass && ++cleanStreak >= 3) {
|
|
1549
|
+
cleanStreak = 0;
|
|
1550
|
+
setWidth(Math.min(maxW, width + 1), "3 clean passes in a row");
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
if (!halted && failsInARow >= c.stopAfterFailuresInARow) {
|
|
1554
|
+
halted = `${failsInARow} failures in a row`;
|
|
1555
|
+
log(`STOPPING: ${halted} (running items finish first)`);
|
|
1556
|
+
}
|
|
1557
|
+
if (stopRequested() && !halted) halted = "stop requested";
|
|
1558
|
+
if (pauseRequested() && !halted) halted = "paused after running items finished";
|
|
1559
|
+
}
|
|
1560
|
+
} catch (e) {
|
|
1561
|
+
onSignal();
|
|
1562
|
+
await Promise.all(running.values());
|
|
1563
|
+
throw e;
|
|
1564
|
+
}
|
|
1565
|
+
if (pauseRequested() && !halted) halted = "paused after running items finished";
|
|
1566
|
+
state.phase = halted || (Object.keys(state.running).length ? "blocked: quarantined workers" : "finished");
|
|
1567
|
+
save();
|
|
1568
|
+
const left = allItems(readFileSync(queuePath, "utf8"), state).filter((i) => !state.done[i.key] && !state.failed[i.key]).length;
|
|
1569
|
+
log(`END · passed ${Object.keys(state.done).length} · failed ${Object.keys(state.failed).length} · still open ${left}${halted ? ` · ${halted}` : ""}`);
|
|
1570
|
+
if (c.deleteSessions && c.deleteSessions !== "pass" && !halted) {
|
|
1571
|
+
const ids = Object.entries(state.sessions ?? {})
|
|
1572
|
+
.filter(([, x]) => !x.deleted && !x.archived && (x.status === "pass" || x.status === "split"))
|
|
1573
|
+
.map(([id]) => id);
|
|
1574
|
+
const mode = c.deleteSessions === "archive" ? "archive" : "delete";
|
|
1575
|
+
const n = await deleteSessions(ctx, ids, mode);
|
|
1576
|
+
if (n) log(`CLEANUP · ${mode === "archive" ? "archived" : "removed"} ${n} finished item sessions (sessions of failed items are kept; /task cleanup removes the rest)`);
|
|
1577
|
+
}
|
|
1578
|
+
} finally {
|
|
1579
|
+
clearInterval(heartbeat);
|
|
1580
|
+
for (const sig of signals) process.off(sig, onSignal);
|
|
1581
|
+
process.off("exit", release);
|
|
1582
|
+
release();
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
export { piTurn, webTurn, checksText, prepareWorktree };
|
|
1587
|
+
|
|
1588
|
+
const isMain = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
|
|
1589
|
+
if (isMain) {
|
|
1590
|
+
const repo = process.argv[2] || process.cwd();
|
|
1591
|
+
main(repo).catch((e) => {
|
|
1592
|
+
try {
|
|
1593
|
+
makeLog(repo)(`CRASH ${e?.stack ?? e}`);
|
|
1594
|
+
} catch {}
|
|
1595
|
+
process.exit(1);
|
|
1596
|
+
});
|
|
1597
|
+
}
|