@stacknyu/pi-flow 0.0.0-stage → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +173 -2
- package/extension.ts +881 -0
- package/package.json +44 -3
- package/runner.mjs +1569 -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/supervisor.mjs
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Owns queue supervision outside the runner. Healthy ticks never prompt a model.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
+
import { DIR, acquireProcessLock, allItems, enqueueControl, ensureDir, findPiWeb, loadJson, piTurn, pidAlive, readConfig, resolvePi, saveJson, webClient, webTurn } from "./runner.mjs";
|
|
9
|
+
|
|
10
|
+
export const HEALTH_TICK_MS = 60000;
|
|
11
|
+
const REVIEW_COOLDOWN_MS = 600000;
|
|
12
|
+
const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
13
|
+
const text = (path) => { try { return readFileSync(path, "utf8"); } catch { return ""; } };
|
|
14
|
+
|
|
15
|
+
export function approvedTask(repo, c) {
|
|
16
|
+
if (!c.task || basename(c.task) !== c.task || [".", ".."].includes(c.task)) return undefined;
|
|
17
|
+
const folder = join(repo, ".tasks", c.task);
|
|
18
|
+
const task = loadJson(join(folder, "task.json"), {});
|
|
19
|
+
const files = ["task.json", "brief.md", "rules.md", "questions.md", "items.md"];
|
|
20
|
+
if (task.status !== "queued" || !task.queuedAt || files.some((f) => !existsSync(join(folder, f)))) return undefined;
|
|
21
|
+
if (/^\s*-\s\[\s\]/m.test(text(join(folder, "questions.md")))) return undefined;
|
|
22
|
+
if (JSON.stringify(task.allowedPaths) !== JSON.stringify(c.allowedPaths) || JSON.stringify(task.checks) !== JSON.stringify(c.checks)) return undefined;
|
|
23
|
+
if (c.queue !== `.tasks/${c.task}/items.md`) return undefined;
|
|
24
|
+
return hash([files.map((f) => text(join(folder, f))), c.allowedPaths, c.checks, c.model, c.thinking, c.queue, c.runIn, c.piCommand, c.checkBroker]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function assessHealth(state, items, { running, stopped, paused }) {
|
|
28
|
+
if (stopped || paused) return "manual";
|
|
29
|
+
if (Object.values(state.running ?? {}).some((r) => r.quarantined) || (!running && Object.keys(state.running ?? {}).length)) return "blocked";
|
|
30
|
+
if (running) return "healthy"; // quiet checks and provider retries are not evidence of a hang
|
|
31
|
+
if (!state.startedAt || ["starting", "diagnosing the failed baseline", ""].includes(state.phase)) return "blocked";
|
|
32
|
+
if (items.every((i) => state.done?.[i.key])) return "complete";
|
|
33
|
+
if (items.some((i) => state.failed?.[i.key])) return "failed";
|
|
34
|
+
return state.phase === "running items" ? "crashed" : "blocked";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The manager proposes; this deterministic boundary grants only bounded existing-item recovery. */
|
|
38
|
+
export function validateProposal(proposal, state, items, manager, health) {
|
|
39
|
+
if (proposal?.action === "wait") return undefined;
|
|
40
|
+
if (typeof proposal?.reason !== "string" || !proposal.reason.trim()) throw new Error("manager must explain its diagnosis");
|
|
41
|
+
if (proposal.action === "restart" && health === "crashed" && (manager.restarts ?? 0) < 2) return { action: "restart" };
|
|
42
|
+
if (proposal.action !== "retry" || health !== "failed" || !Array.isArray(proposal.ids) || !proposal.ids.length) throw new Error("manager action is not permitted");
|
|
43
|
+
const ids = [...new Set(proposal.ids)];
|
|
44
|
+
for (const id of ids) {
|
|
45
|
+
const item = items.find((i) => i.id === id);
|
|
46
|
+
const failure = item && state.failed?.[item.key];
|
|
47
|
+
if (!failure || (manager.attempts?.[id] ?? 0) >= 2) throw new Error(`${id}: not failed or recovery budget exhausted`);
|
|
48
|
+
if (/BLOCKED|credentials?|missing contract|decision|outside.*(scope|allowed)|runner error/i.test(failure)) throw new Error(`${id}: needs human attention, not an automatic retry`);
|
|
49
|
+
}
|
|
50
|
+
return { action: "retry", ids, reason: proposal.reason.slice(0, 3000) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function managerPrompt(repo, c, state, items, health, manager) {
|
|
54
|
+
const folder = join(repo, ".tasks", c.task);
|
|
55
|
+
const failures = items.filter((i) => state.failed?.[i.key]);
|
|
56
|
+
return `You are the automatic manager of approved task ${c.task}. The questions were answered and the queue started. You have NO tools and cannot edit code, run commands, weaken checks, change models, or expand scope. Workers implement repairs; the supervisor validates your proposal.
|
|
57
|
+
|
|
58
|
+
Diagnose the supplied evidence. Healthy/complete/manual/blocked states require wait. For failed work, propose retry only when a fresh worker can fix the original item within its original scope, using your specific diagnosis. Never retry unchanged missing credentials, decisions, contracts or safety blockers. Two recovery attempts per item, and two clean runner restarts per task, are the maximum. Group related failures. Do not propose new items.
|
|
59
|
+
|
|
60
|
+
End with ONE line: CONTROL: {"action":"wait","reason":"..."}, CONTROL: {"action":"retry","ids":["A01"],"reason":"specific diagnosis and repair guidance"}, or CONTROL: {"action":"restart","reason":"..."} (only a crashed runner with no remaining workers).
|
|
61
|
+
|
|
62
|
+
Health: ${health}; phase: ${state.phase}; passed: ${Object.keys(state.done ?? {}).length}; recovery attempts: ${JSON.stringify(manager.attempts ?? {})}; restarts: ${manager.restarts ?? 0}.
|
|
63
|
+
Allowed paths: ${JSON.stringify(c.allowedPaths)}
|
|
64
|
+
Checks (must not be weakened): ${JSON.stringify(c.checks)}
|
|
65
|
+
Brief: ${text(join(folder, "brief.md")).slice(0, 4000)}
|
|
66
|
+
Rules: ${text(join(folder, "rules.md")).slice(0, 4000)}
|
|
67
|
+
Answers: ${text(join(folder, "questions.md")).slice(0, 4000)}
|
|
68
|
+
Failures (${failures.length}; first 8):
|
|
69
|
+
${failures.slice(0, 8).map((i) => `${i.line}\nFailure: ${state.failed[i.key]}\n${text(join(repo, DIR, "notes", `${i.id}.md`)).slice(-6000)}`).join("\n\n")}
|
|
70
|
+
Problem: ${text(join(repo, DIR, "problem.txt")).slice(-4000)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function supervise(repo, { tickMs = HEALTH_TICK_MS, cooldownMs = REVIEW_COOLDOWN_MS } = {}) {
|
|
74
|
+
await ensureDir(repo);
|
|
75
|
+
const release = acquireProcessLock(join(repo, DIR, "supervisor.pid"));
|
|
76
|
+
const log = (message) => appendFileSync(join(repo, DIR, "log.md"), `- ${new Date().toISOString()} MANAGER ${message}\n`);
|
|
77
|
+
const stopped = () => existsSync(join(repo, DIR, "STOP"));
|
|
78
|
+
const paused = () => existsSync(join(repo, DIR, "PAUSE"));
|
|
79
|
+
const runnerAlive = () => pidAlive(Number(text(join(repo, DIR, "runner.pid"))));
|
|
80
|
+
const onSignal = () => writeFileSync(join(repo, DIR, "STOP"), "supervisor signal");
|
|
81
|
+
const signals = ["SIGTERM", "SIGINT", "SIGHUP"];
|
|
82
|
+
for (const sig of signals) process.on(sig, onSignal);
|
|
83
|
+
process.on("exit", release);
|
|
84
|
+
let child, childExited = true;
|
|
85
|
+
const launch = () => {
|
|
86
|
+
if (stopped() || paused() || runnerAlive()) return;
|
|
87
|
+
childExited = false;
|
|
88
|
+
child = spawn(process.execPath, [join(dirname(fileURLToPath(import.meta.url)), "runner.mjs"), repo], { cwd: repo, stdio: ["ignore", "inherit", "inherit", "ipc"] });
|
|
89
|
+
child.once("exit", () => { childExited = true; });
|
|
90
|
+
child.on("error", (e) => { childExited = true; log(`runner launch failed: ${e.message}`); });
|
|
91
|
+
};
|
|
92
|
+
const wait = () => new Promise((resolve) => {
|
|
93
|
+
const done = () => { clearTimeout(timer); child?.off("exit", done); child?.off("message", done); resolve(); };
|
|
94
|
+
const timer = setTimeout(done, tickMs);
|
|
95
|
+
child?.once("exit", done);
|
|
96
|
+
child?.once("message", done);
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
const c = readConfig(repo), approval = approvedTask(repo, c);
|
|
100
|
+
launch();
|
|
101
|
+
if (c.manager === false || !approval) { while (!childExited || runnerAlive()) await wait(); return; }
|
|
102
|
+
const file = join(repo, DIR, "manager.json");
|
|
103
|
+
let manager = loadJson(file, {});
|
|
104
|
+
if (manager.task !== c.task) manager = { task: c.task, approval, attempts: {}, restarts: 0 };
|
|
105
|
+
if (manager.approval !== approval) { log("approval changed; automatic management disabled"); return; }
|
|
106
|
+
let web = null;
|
|
107
|
+
if (c.runIn !== "cli") {
|
|
108
|
+
const found = await findPiWeb(c);
|
|
109
|
+
if (found.url) web = webClient({ ...c, piWebUrl: found.url });
|
|
110
|
+
else if (c.runIn === "web") { log("Pi Web unavailable; manager not started, no CLI fallback"); return; }
|
|
111
|
+
}
|
|
112
|
+
if (manager.web !== undefined && manager.web !== !!web) { log("manager backend unavailable/changed; no automatic fallback"); return; }
|
|
113
|
+
manager.web = !!web;
|
|
114
|
+
const ref = { id: manager.id || (web ? undefined : randomUUID()) };
|
|
115
|
+
const save = () => { manager.id = ref.id; saveJson(file, manager); };
|
|
116
|
+
save();
|
|
117
|
+
const ctx = { repo, c, web, pi: await resolvePi(c), env: process.env, manager: true, stopRequested: () => stopped() || paused(), onWorker: (identity) => {
|
|
118
|
+
if (identity.sessionId) ref.id = identity.sessionId;
|
|
119
|
+
manager.pid = identity.pid;
|
|
120
|
+
save();
|
|
121
|
+
if (web && ref.id) web.ui({ action: "set", ids: [ref.id], pinned: true }).catch(() => {});
|
|
122
|
+
} };
|
|
123
|
+
while (true) {
|
|
124
|
+
if (readConfig(repo).manager === false || approvedTask(repo, readConfig(repo)) !== approval) { log("task/config changed; supervision stopped"); break; }
|
|
125
|
+
const state = loadJson(join(repo, DIR, "state.json"), {});
|
|
126
|
+
const items = allItems(text(join(repo, c.queue)), state);
|
|
127
|
+
const running = runnerAlive() || !childExited;
|
|
128
|
+
const health = assessHealth(state, items, { running, stopped: stopped(), paused: paused() });
|
|
129
|
+
if (health === "manual") break;
|
|
130
|
+
const ready = state.startedAt && !["starting", "diagnosing the failed baseline"].includes(state.phase);
|
|
131
|
+
const incident = hash([health, state.failed, manager.attempts, manager.restarts]);
|
|
132
|
+
const initialize = !manager.initialized && ready && health !== "blocked";
|
|
133
|
+
const actionable = ready && !running && ["failed", "crashed", "blocked"].includes(health);
|
|
134
|
+
if (initialize || (actionable && manager.incident !== incident && Date.now() - (manager.lastReviewAt ?? 0) >= cooldownMs)) {
|
|
135
|
+
if (pidAlive(manager.pid)) { log("previous manager turn still active; intervention deferred"); break; }
|
|
136
|
+
if (web && ref.id && (await web.running()).runningSessionIds?.includes(ref.id)) { log("manager session already busy; intervention deferred"); break; }
|
|
137
|
+
manager.initialized = true;
|
|
138
|
+
manager.incident = incident;
|
|
139
|
+
manager.lastReviewAt = health === "healthy" ? 0 : Date.now();
|
|
140
|
+
save();
|
|
141
|
+
const prompt = managerPrompt(repo, c, state, items, health, manager);
|
|
142
|
+
const result = await (web ? webTurn(ctx, repo, ref, `manager · ${c.task}`, prompt, 10) : piTurn(ctx, repo, ref.id, `manager · ${c.task}`, prompt, 10));
|
|
143
|
+
manager.reply = (result.reply || result.err).slice(-12000);
|
|
144
|
+
manager.pid = undefined;
|
|
145
|
+
save();
|
|
146
|
+
const latest = loadJson(join(repo, DIR, "state.json"), {});
|
|
147
|
+
if (stopped() || paused() || readConfig(repo).manager === false || approvedTask(repo, readConfig(repo)) !== approval || latest.runId !== state.runId || JSON.stringify(latest.failed) !== JSON.stringify(state.failed)) continue;
|
|
148
|
+
if (result.status !== "ok" || result.quiescent === false) { log(`review failed: ${result.err || result.status}`); if (!running) break; continue; }
|
|
149
|
+
try {
|
|
150
|
+
const line = result.reply.split("\n").filter((l) => l.startsWith("CONTROL:")).at(-1);
|
|
151
|
+
const action = validateProposal(JSON.parse(line?.slice(8) ?? "null"), latest, items, manager, health);
|
|
152
|
+
if (action) {
|
|
153
|
+
if (runnerAlive() || Object.keys(latest.running ?? {}).length) throw new Error("runner/worker ownership changed");
|
|
154
|
+
if (action.action === "retry") {
|
|
155
|
+
const failures = {};
|
|
156
|
+
mkdirSync(join(repo, DIR, "notes"), { recursive: true });
|
|
157
|
+
for (const id of action.ids) {
|
|
158
|
+
manager.attempts[id] = (manager.attempts[id] ?? 0) + 1;
|
|
159
|
+
const item = items.find((i) => i.id === id);
|
|
160
|
+
failures[id] = latest.failed[item.key];
|
|
161
|
+
appendFileSync(join(repo, DIR, "notes", `${id}.md`), `\n\n## Manager diagnosis\n${action.reason}\n`);
|
|
162
|
+
}
|
|
163
|
+
save(); // persist the budget before dispatch
|
|
164
|
+
enqueueControl(repo, { task: c.task, by: "auto", action: "retry", ids: action.ids, failures });
|
|
165
|
+
} else { manager.restarts++; save(); }
|
|
166
|
+
log(`${action.action} ${action.ids?.join(", ") ?? "runner"}`);
|
|
167
|
+
launch();
|
|
168
|
+
await wait();
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
} catch (e) { log(`proposal not applied: ${e.message}`); }
|
|
172
|
+
}
|
|
173
|
+
if (!running && (health === "complete" || manager.incident === incident || !ready)) break;
|
|
174
|
+
await wait();
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
// Keep ownership until the child drains, including manual pause/stop and startup failures.
|
|
178
|
+
while (!childExited) await wait();
|
|
179
|
+
for (const sig of signals) process.off(sig, onSignal);
|
|
180
|
+
process.off("exit", release);
|
|
181
|
+
release();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) supervise(process.argv[2] || process.cwd()).catch((e) => { console.error(e); process.exitCode = 1; });
|