@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
347 lines (346 loc) • 19.7 kB
JavaScript
import { randomUUID } from "node:crypto";
import { stat } from "node:fs/promises";
import { localNames, stableComputerName } from "./computer-names.js";
import path from "node:path";
import { BridgeError } from "./protocol.js";
import { scheduleProblems } from "./schedule-problems.js";
import { getProjectSourcePath } from "../project-config.js";
import { getProjectDirs } from "../shared.js";
import { computerMatches, latestScheduleBatch, nextRun, readScheduleDocument, readScheduleRuns, reusableLaunches, runningStatuses, scheduleNotifications, scheduleSessionRoute, readScheduleDocumentSync, withScheduleLock, writeScheduleDocumentSync, writeScheduleRuns, } from "./schedule-format.js";
export * from "./schedule-format.js";
export * from "./schedule-launch.js";
export * from "./schedule-watch.js";
export class Scheduler {
now;
store;
launch;
runsFile;
computer;
/** Every name this computer answers to; a schedule names one of them. */
names;
locateProject;
push;
log;
revisionWaitMs;
serial = Promise.resolve();
ticking = false;
dispatching = new Set();
/** Runs this process launched; any other open run was left by an earlier Hook. */
own = new Set();
recovered = false;
/** When the scheduler last looked for due work; a health read shows it. */
lastTickAt;
constructor(options) {
this.revisionWaitMs = options.revisionWaitMs ?? 30_000;
this.now = options.now;
this.store = options.store;
this.launch = options.launch;
this.runsFile = options.runsFile;
if (typeof options.computer === "function")
this.computer = options.computer;
else {
const computer = options.computer;
this.computer = () => computer ?? stableComputerName();
}
const aliases = options.aliases ?? [];
this.names = options.computer === undefined ? () => [...localNames(), ...aliases] : () => [this.computer(), ...aliases];
this.locateProject = options.locateProject ?? (async () => undefined);
this.push = options.push;
this.log = options.log ?? (message => console.error(message));
}
projectDirectories() { return getProjectDirs(this.store); }
projectDirectory(project) {
const found = this.projectDirectories().find(directory => path.basename(directory).toLowerCase() === project.toLowerCase());
if (!found)
throw new BridgeError(404, `Unknown project "${project}".`);
return found;
}
async exclusive(operation) {
const previous = this.serial;
let release;
this.serial = new Promise(resolve => { release = resolve; });
await previous;
try {
return await operation();
}
finally {
release();
}
}
owns(schedule, names = this.names()) {
return names.some(name => computerMatches(schedule.computer, name));
}
async statuses() {
const runs = await readScheduleRuns(this.runsFile), result = [], computer = this.computer(), names = this.names();
for (const directory of this.projectDirectories()) {
let schedules;
try {
schedules = (await readScheduleDocument(directory)).schedules;
}
catch {
continue;
}
const project = path.basename(directory);
for (const schedule of schedules) {
const matching = runs.filter(run => (run.scheduleProject ?? run.project) === project && run.scheduleId === schedule.id);
const { lastRun: last, lastRuns } = latestScheduleBatch(matching);
const running = matching.some(run => runningStatuses.has(run.status));
const due = this.owns(schedule, names) ? nextRun(schedule, last) : null;
const owned = this.owns(schedule, names);
const problems = await scheduleProblems(schedule, project, { store: this.store, owned, locate: this.locateProject });
result.push({ ...schedule, project, lastRuns, owned, ...(problems.length ? { problems } : {}), nextRun: due?.toISOString() ?? null,
lastRun: last ? { startedAt: last.startedAt, ...(last.finishedAt ? { finishedAt: last.finishedAt } : {}), status: last.status,
...(last.batchId ? { batchId: last.batchId, scheduleProject: last.scheduleProject } : {}),
...(last.reason ? { reason: last.reason } : {}),
...(last.blockedStartupPrompt ? { blockedStartupPrompt: last.blockedStartupPrompt } : {}),
...(last.blockNotified !== undefined ? { blockNotified: last.blockNotified } : {}), launch: last.launch } : null, running });
}
}
return { computer, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, schedules: result };
}
async history(filters = {}) {
const limit = Math.min(500, Math.max(1, filters.limit ?? 50));
return (await readScheduleRuns(this.runsFile)).filter(run => (!filters.project || (run.scheduleProject ?? run.project).toLowerCase() === filters.project.toLowerCase())
&& (!filters.id || run.scheduleId === filters.id)).slice(-limit).reverse();
}
async launchNow(projectName, id, onlyIfDue = false, expectedUpdatedAt) {
const key = `${projectName.toLowerCase()}/${id}`;
if (this.dispatching.has(key))
throw new BridgeError(409, "This schedule is already launching.");
this.dispatching.add(key);
try {
if (expectedUpdatedAt !== undefined)
await this.awaitRevision(projectName, id, expectedUpdatedAt);
return await this.prepareAndLaunch(projectName, id, onlyIfDue, expectedUpdatedAt);
}
finally {
this.dispatching.delete(key);
}
}
/** An edit made on another computer reaches this one with the store's sync. A Run now right after it waits for the
* schedule to arrive (or catch up) rather than failing; a copy newer than the app's, or none after the wait, is
* left to `prepareAndLaunch`'s revision check. */
async awaitRevision(projectName, id, expectedUpdatedAt) {
const expected = Date.parse(expectedUpdatedAt), deadline = Date.now() + this.revisionWaitMs;
for (;;) {
const found = await readScheduleDocument(this.projectDirectory(projectName)).then(file => file.schedules.find(item => item.id === id), () => undefined);
if (found && Date.parse(found.updatedAt) >= expected)
return;
if (Date.now() >= deadline)
return;
await new Promise(resolve => setTimeout(resolve, Math.min(1_000, Math.max(10, this.revisionWaitMs / 10))));
}
}
async prepareAndLaunch(projectName, id, onlyIfDue, expectedUpdatedAt) {
const prepared = await this.exclusive(async () => {
const projectDir = this.projectDirectory(projectName), project = path.basename(projectDir);
const file = await readScheduleDocument(projectDir), schedule = file.schedules.find(item => item.id === id);
if (!schedule)
throw new BridgeError(404, `Schedule "${id}" was not found in ${project}.`);
if (expectedUpdatedAt !== undefined && expectedUpdatedAt !== schedule.updatedAt) {
throw new BridgeError(409, "This computer has a different schedule revision. Wait for store sync to finish, refresh the schedule, and try Run now again.");
}
if (!this.owns(schedule))
throw new BridgeError(409, `${schedule.computer} owns this schedule; run it from that computer.`);
const runs = await readScheduleRuns(this.runsFile);
const matching = runs.filter(run => (run.scheduleProject ?? run.project) === project && run.scheduleId === id);
if (matching.some(run => runningStatuses.has(run.status))) {
throw new BridgeError(409, `Schedule "${schedule.name}" is already running.`);
}
// Recheck a tick's snapshot after a concurrent Run now or schedule edit.
const due = nextRun(schedule, matching.at(-1));
if (onlyIfDue && (!schedule.enabled || !due || due > this.now()))
return undefined;
const targets = [];
for (const name of schedule.projects ?? [project]) {
const targetDir = this.projectDirectory(name), target = path.basename(targetDir);
const source = getProjectSourcePath(this.store, target) ?? await this.locateProject(target);
if (!source && schedule.projects)
throw new BridgeError(409, `No source directory for project "${target}" on this computer.`);
const cwd = source ?? targetDir;
if (!await stat(cwd).then(info => info.isDirectory()).catch(() => false)) {
throw new BridgeError(409, `Source directory for project "${target}" is unavailable: ${cwd}`);
}
targets.push({ project: target, projectDir: targetDir, cwd });
}
const batchId = randomUUID(), startedAt = this.now().toISOString();
const children = targets.map(target => ({ ...target,
...(schedule.session === "reuse" ? { reuse: reusableLaunches(matching.filter(run => run.project === target.project)) } : {}), run: {
id: randomUUID(), scheduleId: id, project: target.project, scheduleProject: project, batchId,
startedAt, status: "launched", launch: { mode: "headless" },
} }));
// One atomic replacement reserves every target before any external launch.
await writeScheduleRuns(this.runsFile, [...runs, ...children.map(child => child.run)]);
for (const child of children)
this.own.add(child.run.id);
if (schedule.every === "once" && schedule.enabled) {
try {
await withScheduleLock(projectDir, () => {
const current = readScheduleDocumentSync(projectDir);
const schedules = current.document.schedules ?? [];
const index = schedules.findIndex(item => item.id === id);
if (index < 0)
return; // A concurrent removal must stay removed.
const occurrence = schedules[index];
// Consume only the revision that fired, never an edited or rescheduled row.
if (occurrence.every !== "once" || occurrence.once !== schedule.once || occurrence.updatedAt !== schedule.updatedAt)
return;
schedules[index] = { ...schedules[index], enabled: false,
updatedAt: new Date(Math.max(this.now().getTime(), Date.parse(schedules[index].updatedAt) + 1)).toISOString() };
writeScheduleDocumentSync(projectDir, schedules, current.document);
});
}
catch (error) {
// The durable batch already consumes the occurrence. A YAML write
// failure must not strand pending children in this running process.
this.log(`[schedule] Could not disable consumed once schedule ${project}/${id}: ${String(error)}`);
}
}
return { schedule, children };
});
if (!prepared)
return [];
// Independent attempts: one target's startup failure never cancels siblings.
return Promise.all(prepared.children.map(async (child) => {
try {
const launched = await this.launch({ schedule: prepared.schedule, project: child.project, projectDir: child.projectDir,
cwd: child.cwd, runId: child.run.id, ...(child.reuse ? { reuse: child.reuse } : {}),
blockedStartup: promptText => this.recordBlockedStartup(child.run.id, prepared.schedule, promptText) });
// A busy reused session keeps its turn; this firing is recorded and nothing is sent.
if (launched.skipped) {
return await this.updateRun(child.run.id, { status: "skipped", finishedAt: this.now().toISOString(), reason: launched.skipped, launch: launched.launch });
}
const running = await this.updateRun(child.run.id, { status: "running", launch: launched.launch });
const notified = this.notifyRun(running, prepared.schedule, "scheduleStarted");
if (launched.completion)
void launched.completion.then(async (result) => {
await this.finishRun(child.run.id, prepared.schedule, result.status, result.reason, notified);
}).catch(async (error) => {
await this.finishRun(child.run.id, prepared.schedule, "failed", error instanceof Error ? error.message : "The scheduled agent failed.", notified);
}).catch(error => this.log(`[schedule] Could not finish run ${child.run.id}: ${String(error)}`));
await notified;
return running;
}
catch (error) {
const reason = error instanceof Error ? error.message : "The scheduled agent could not start.";
return this.finishRun(child.run.id, prepared.schedule, "failed", reason);
}
}));
}
async updateRun(id, patch) {
return this.exclusive(async () => {
const runs = await readScheduleRuns(this.runsFile), index = runs.findIndex(run => run.id === id);
if (index < 0)
throw new Error("The schedule run record disappeared.");
runs[index] = { ...runs[index], ...patch };
await writeScheduleRuns(this.runsFile, runs);
return runs[index];
});
}
async finishRun(id, schedule, status, reason, previousNotification) {
const run = await this.updateRun(id, { status, finishedAt: this.now().toISOString(), ...(reason ? { reason } : {}) });
await previousNotification;
if (run.blockedStartupPrompt && run.blockNotified)
return run;
await this.notifyRun(run, schedule, status === "failed" ? "scheduleFailed" : "scheduleFinished");
return run;
}
async recordBlockedStartup(id, schedule, promptText) {
try {
const prompt = promptText.slice(0, 4000);
const run = await this.updateRun(id, { status: "blocked", blockedStartupPrompt: prompt });
const delivered = await this.notifyRun(run, schedule, "scheduleBlocked", `Blocked at startup: ${prompt}`);
if (delivered)
await this.updateRun(id, { blockNotified: true });
}
catch (error) {
this.log(`[schedule] blocked-at-startup record for run ${id} failed: ${error instanceof Error ? error.message : "unknown error"}`);
}
}
async notifyRun(run, schedule, kind, reason) {
const preference = kind === "scheduleStarted" ? "start" : kind === "scheduleFinished" ? "finish" : "failure";
if (!scheduleNotifications(schedule).has(preference))
return false;
const route = scheduleSessionRoute(schedule, run.launch);
const text = reason ?? run.reason;
const value = { kind, scheduleId: schedule.id, project: run.project, name: schedule.name,
computer: schedule.computer, runId: run.id,
status: kind === "scheduleStarted" ? "running" : kind === "scheduleFinished" ? (run.status === "needs-you" ? "needs-you" : "finished")
: kind === "scheduleBlocked" ? "blocked" : "failed",
...(text ? { reason: text } : {}), ...(route ? { route } : {}) };
let result;
try {
result = this.push ? await this.push.notify(value) : { notified: false, reason: "no push config" };
}
catch {
result = { notified: false, reason: "push delivery failed" };
}
try {
await this.updateRun(run.id, { notified: result.notified, ...(result.reason ? { notifyReason: result.reason } : { notifyReason: undefined }) });
}
catch (error) {
this.log(`[schedule] ${kind} notification result for run ${run.id} could not be recorded: ${error instanceof Error ? error.message : "unknown error"}`);
}
if (!result.notified)
this.log(`[schedule] ${kind} notification for run ${run.id}: ${result.reason ?? "not delivered"}`);
return result.notified;
}
/**
* Settles the runs an earlier Hook process left open (a restart or crash
* mid-run). Each is followed to its real end where the launcher can, and
* otherwise failed with the reason, since an open run blocks its schedule
* for good. Runs once, on the first tick, with a launcher that can resume.
*/
async recoverOpenRuns() {
const resume = this.launch.resume;
if (this.recovered || !resume)
return;
this.recovered = true;
const open = await this.exclusive(async () => {
const pending = (await readScheduleRuns(this.runsFile)).filter(run => runningStatuses.has(run.status) && !this.own.has(run.id));
for (const run of pending)
this.own.add(run.id);
return pending;
});
for (const run of open) {
let schedule;
try {
schedule = (await readScheduleDocument(this.projectDirectory(run.scheduleProject ?? run.project))).schedules.find(item => item.id === run.scheduleId);
}
catch {
schedule = undefined;
}
if (!schedule) {
await this.updateRun(run.id, { status: "failed", finishedAt: this.now().toISOString(),
reason: "Phren Hook restarted during this run, and its schedule no longer exists." })
.catch(error => this.log(`[schedule] Could not close run ${run.id}: ${String(error)}`));
continue;
}
const known = schedule;
this.log(`[schedule] Following run ${run.id} of "${known.name}", left ${run.status} by an earlier Hook process.`);
void resume(run, known)
.catch((error) => ({ status: "failed", reason: error instanceof Error ? error.message : "The scheduled agent failed." }))
.then(result => this.finishRun(run.id, known, result.status, result.reason))
.catch(error => this.log(`[schedule] Could not finish run ${run.id}: ${String(error)}`));
}
}
async tick() {
if (this.ticking)
return;
this.ticking = true;
this.lastTickAt = this.now();
try {
await this.recoverOpenRuns().catch(error => this.log(`[schedule] Recovering open runs failed: ${String(error)}`));
const status = await this.statuses(), now = this.now();
for (const schedule of status.schedules) {
if (!schedule.enabled || schedule.running || !schedule.nextRun || new Date(schedule.nextRun) > now)
continue;
await this.launchNow(schedule.project, schedule.id, true).catch(() => { });
}
}
finally {
this.ticking = false;
}
}
close() { this.launch.close?.(); }
}