@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
231 lines (230 loc) • 12.2 kB
JavaScript
import { randomUUID } from "node:crypto";
import { appendFile, lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { archiveFinishedFanouts, containedFanoutRoot, fanoutChildID, manifestSchema, storeRoot } from "./fanouts.js";
import { atomic, BridgeError, targetSchema } from "./protocol.js";
import { childAgent } from "./transcripts.js";
import { countTick } from "./metrics.js";
export const fanoutMessageSchema = z.object({
target: targetSchema,
child: z.string().regex(/^[a-f0-9]{32}$/),
text: z.string().min(1).max(32768).refine(value => value.trim().length > 0 && !/[\x00-\x08\x0b-\x1f\x7f]/.test(value)),
}).strict();
const receiptSchema = z.object({
id: z.string().uuid(), text: z.string().max(32768),
status: z.enum(["queued", "running", "completed", "failed"]),
createdAt: z.string(),
});
/** Durable per-job messages. A running receipt is never replayed after a Hook restart. */
export class FanoutMessages {
env;
deps;
timer;
ticking = false;
constructor(env, deps) {
this.env = env;
this.deps = deps;
}
start() {
this.timer = setInterval(() => { countTick("fanout-messages"); void this.tick().catch(() => { }); }, 1000);
this.timer.unref();
void this.tick().catch(() => { });
}
close() { clearInterval(this.timer); }
async jobs() {
const root = await containedFanoutRoot(this.env);
if (!root)
return [];
const jobs = [];
for (const name of (await readdir(root)).slice(0, 1024)) {
const directory = path.join(root, name);
try {
if (!(await lstat(directory)).isDirectory() || await realpath(directory) !== directory)
continue;
const file = path.join(directory, "manifest.json"), info = await lstat(file);
if (!info.isFile() || info.isSymbolicLink() || info.size > 65536)
continue;
const manifest = manifestSchema.parse(JSON.parse(await readFile(file, "utf8")));
if (manifest.id === name)
jobs.push({ directory, manifest });
}
catch { /* A torn or foreign job cannot receive input. */ }
}
return jobs;
}
async selected(target, child) {
await this.deps.validate(target);
const relation = childAgent(await this.deps.tree(target), child);
if (!relation?.fanout?.resumable)
throw new BridgeError(404, "That resumable worker is not part of this conversation.");
const root = await containedFanoutRoot(this.env);
const job = (await this.jobs()).find(job => root && fanoutChildID(root, job.manifest) === child);
if (!job || !job.manifest.session || !["codex", "opencode", "claude"].includes(job.manifest.provider)) {
throw new BridgeError(404, "That worker does not belong to this store.");
}
return job;
}
async send(input) {
const data = fanoutMessageSchema.parse(input);
const job = await this.selected(data.target, data.child);
const directory = await this.messageDirectory(job);
const message = { id: randomUUID(), text: data.text, status: "queued", createdAt: new Date().toISOString() };
const pending = (await this.records(job)).filter(row => row.message.status === "queued");
if (pending.length >= 32)
throw new BridgeError(429, "This worker already has 32 queued messages.");
const file = path.join(directory, `${message.createdAt.replace(/[:.]/g, "-")}-${message.id}.queued.json`);
await atomic(file, JSON.stringify(message));
await this.drain(job);
const fresh = (await this.records(job)).find(row => row.message.id === message.id)?.message;
return { ok: true, message: fresh ?? message };
}
/** Archive every finished worker of one live parent now, instead of after
* the sweep's 24 hours. The sweep's own checks apply: a job without an exit
* stamp, with a message lock or with queued messages stays put. */
async archiveFinished(input) {
const { target } = z.object({ target: targetSchema }).strict().parse(input);
await this.deps.validate(target);
const { moved } = await archiveFinishedFanouts(this.env, { olderThanMs: 0, parent: { session: target.session, provider: target.source } });
return { ok: true, archived: moved.length };
}
async list(targetValue, childValue) {
const target = targetSchema.parse(targetValue), child = fanoutMessageSchema.shape.child.parse(childValue);
const job = await this.selected(target, child);
return { messages: (await this.records(job)).map(row => row.message) };
}
async messageDirectory(job) {
const directory = path.join(job.directory, "messages");
await mkdir(directory, { recursive: true, mode: 0o700 });
if (await realpath(directory) !== directory)
throw new BridgeError(400, "Invalid worker message directory.");
return directory;
}
async records(job) {
const directory = path.join(job.directory, "messages");
const info = await lstat(directory).catch(() => undefined);
if (!info)
return [];
if (!info.isDirectory() || info.isSymbolicLink() || await realpath(directory) !== directory) {
throw new BridgeError(400, "Invalid worker message directory.");
}
const rows = [];
for (const name of (await readdir(directory)).sort().slice(-512)) {
if (!/^[0-9TZ-]+-[a-f0-9-]+\.(queued|sent)\.json$/.test(name))
continue;
const file = path.join(directory, name), info = await lstat(file);
if (!info.isFile() || info.isSymbolicLink() || info.size > 262144)
continue;
try {
rows.push({ file, message: receiptSchema.parse(JSON.parse(await readFile(file, "utf8"))) });
}
catch { /* Ignore torn receipts. */ }
}
return rows;
}
async tick() {
if (this.ticking)
return;
this.ticking = true;
try {
for (const job of await this.jobs()) {
if (["queued", "running"].includes(job.manifest.status))
continue;
if ((await this.records(job).catch(() => [])).some(row => row.message.status === "queued"))
await this.drain(job).catch(() => { });
}
}
finally {
this.ticking = false;
}
}
async drain(job) {
const lock = path.join(job.directory, "message-lock");
try {
await mkdir(lock, { mode: 0o700 });
}
catch {
return;
}
let launched = false;
let pending;
let started;
try {
// Re-read under the job lock. The original launcher owns the manifest until it finishes.
const fresh = (await this.jobs()).find(candidate => candidate.directory === job.directory);
if (!fresh || ["queued", "running"].includes(fresh.manifest.status))
return;
if (!fresh.manifest.session || !["codex", "opencode", "claude"].includes(fresh.manifest.provider))
return;
const next = (await this.records(fresh)).find(row => row.message.status === "queued");
if (!next)
return;
pending = next;
if (!(await lstat(fresh.manifest.worktree)).isDirectory())
throw new BridgeError(409, "The worker's original worktree is unavailable.");
// Refuse redirected output files before the launcher opens them for writing.
for (const name of [fresh.manifest.eventLog, "events.jsonl", "stderr.log", "final.txt", "exit.txt", "blocked.json", "prompt.txt"]) {
const info = await lstat(path.join(fresh.directory, name)).catch(() => undefined);
if (info && (!info.isFile() || info.isSymbolicLink()))
throw new BridgeError(400, "Invalid worker output file.");
}
const round = path.join(fresh.directory, "rounds");
await mkdir(round, { recursive: true, mode: 0o700 });
if (await realpath(round) !== round)
throw new BridgeError(400, "Invalid worker round directory.");
const roundDir = path.join(round, next.message.id);
await mkdir(roundDir, { mode: 0o700 });
await writeFile(path.join(roundDir, "prompt.txt"), next.message.text, { mode: 0o600 });
for (const name of ["stderr.log", "final.txt", "exit.txt", "blocked.json", "prompt.txt"]) {
await rename(path.join(fresh.directory, name), path.join(roundDir, `previous-${name}`)).catch(error => {
if (error.code !== "ENOENT")
throw error;
});
}
await writeFile(path.join(fresh.directory, "prompt.txt"), next.message.text, { mode: 0o600 });
const sentFile = next.file.replace(/\.queued\.json$/, ".sent.json");
const message = { ...next.message, status: "running" };
await atomic(next.file, JSON.stringify(message));
await rename(next.file, sentFile);
pending = { file: sentFile, message };
const now = new Date().toISOString();
const manifest = { ...fresh.manifest, status: "running", updatedAt: now, startedAt: now,
resumes: fresh.manifest.session, finishedAt: undefined, exitCode: undefined };
await atomic(path.join(fresh.directory, "manifest.json"), JSON.stringify(manifest));
started = manifest;
await appendFile(path.join(fresh.directory, manifest.eventLog), JSON.stringify({
type: "phren/fanout-message", timestamp: now, id: message.id, text: message.text,
}) + "\n", { mode: 0o600 });
const run = this.deps.run ?? (async (job, text) => {
const { launch } = await import("../fanout/launcher.js");
return launch({ store: storeRoot(this.env), provider: job.manifest.provider, label: job.manifest.taskLabel,
reason: job.manifest.reason ?? "Continue worker", model: job.manifest.model ?? "", worktree: job.manifest.worktree,
resume: job.manifest.session, prompt: text }, { job: job.directory, manifest: job.manifest });
});
launched = true;
void run({ ...fresh, manifest }, message.text).then(async (code) => {
await atomic(sentFile, JSON.stringify({ ...message, status: code === 0 ? "completed" : "failed" }));
}).catch(async () => {
await atomic(sentFile, JSON.stringify({ ...message, status: "failed" }));
await atomic(path.join(fresh.directory, "manifest.json"), JSON.stringify({ ...manifest, status: "failed", finishedAt: new Date().toISOString() }));
}).finally(async () => { await rm(lock, { recursive: true, force: true }); }).catch(() => { });
}
catch (error) {
if (!pending)
throw error;
// Once accepted, report a failed receipt instead of leaving a hidden queued
// message behind an HTTP error that would invite the person to resend it.
await atomic(pending.file, JSON.stringify({ ...pending.message, status: "failed" }));
if (pending.file.endsWith(".queued.json"))
await rename(pending.file, pending.file.replace(/\.queued\.json$/, ".sent.json"));
if (started)
await atomic(path.join(job.directory, "manifest.json"), JSON.stringify({
...started, status: "failed", finishedAt: new Date().toISOString(),
}));
}
finally {
if (!launched)
await rm(lock, { recursive: true, force: true });
}
}
}