@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
248 lines (247 loc) • 10.7 kB
JavaScript
import { readFileSync } from "node:fs";
import path from "node:path";
import { z } from "zod";
import { atomicInPrivateDir, bridgeRoot } from "./protocol.js";
const paneSchema = z.object({
server: z.string().min(1).max(200),
workspace: z.string().max(200).optional(),
pane: z.string().min(1).max(200),
agent: z.string().max(60).optional(),
label: z.string().max(200).optional(),
}).strict();
const jobSchema = z.object({
/** The worker's process group, once the process table has shown it. */
pgid: z.number().int().positive().optional(),
/** When the group's leader started (epoch ms), recorded with the pgid, so a
* group id the system later reuses for another program is never signalled. */
leaderStartedAt: z.number().int().nonnegative().optional(),
pane: paneSchema.optional(),
session: z.string().min(1).max(200).optional(),
agent: z.string().max(60).optional(),
label: z.string().max(200).optional(),
/** The Harness/executable the Hook started, for display and a reuse check. */
command: z.string().min(1).max(500),
/** Epoch ms. */
startedAt: z.number().int().nonnegative(),
}).strict();
const stateSchema = z.object({ version: z.literal(1), jobs: z.array(jobSchema).max(1024) }).strict();
/** The identity of a job: its pane while one exists, else its session, else its group. */
export const paneKey = (pane) => `${pane.server}\0${pane.pane}`;
export function jobKey(job) {
if (job.pane)
return `pane:${paneKey(job.pane)}`;
if (job.session)
return `session:${job.session}`;
if (job.pgid !== undefined)
return `pgid:${job.pgid}`;
return `command:${job.command}`;
}
/** The registered jobs by their process group, for attribution. */
export function jobPgids(jobs) {
const byPgid = new Map();
for (const job of jobs)
if (job.pgid !== undefined && !byPgid.has(job.pgid))
byPgid.set(job.pgid, job);
return byPgid;
}
/**
* Fill each job's process group from the pane it owns: a job registered before
* its terminal started has no pgid until the process table shows the pane's
* shell (or an already-detached process) in a group. Never invents a group.
*/
export function resolveJobGroups(jobs, rows, owners) {
const byPane = new Map();
for (const row of rows) {
const pane = owners.get(row.pid);
if (pane && row.pgid > 1 && !byPane.has(paneKey(pane)))
byPane.set(paneKey(pane), row.pgid);
}
const leaders = new Map(rows.filter(row => row.pid === row.pgid && row.startedAt !== undefined).map(row => [row.pid, Math.round(row.startedAt)]));
return jobs.map(job => {
if (job.pgid !== undefined || !job.pane)
return job;
const pgid = byPane.get(paneKey(job.pane));
if (pgid === undefined)
return job;
const leaderStartedAt = leaders.get(pgid);
return { ...job, pgid, ...(leaderStartedAt !== undefined ? { leaderStartedAt } : {}) };
});
}
/** How long a job's process group is left alone after its pane was requested,
* so a launch that is still coming up is never signalled. */
export const CLEANUP_LEASE_MS = 5 * 60_000;
/** `ps` reports elapsed time in whole seconds, read at slightly different moments. */
export const LEADER_START_TOLERANCE_MS = 3_000;
/** How long a finished or reused record is kept before it is dropped. */
export const CLEANUP_RETAIN_MS = 24 * 60 * 60_000;
/** Whether a process in a job's group still looks like the command the Hook started. */
function commandMatches(job, row) {
return path.basename(row.command) === path.basename(job.command) || row.args.includes(job.command);
}
/**
* Decide which registered process groups may be ended. A job is ended only when
* every one of these holds: it has a resolved group above 1, its owning pane is
* not in `live`, its group still has processes, none of them is the Hook itself,
* the lease has passed, and a process still matches the command the Hook
* started. Anything else is kept (with a reason) or, when the group is gone or
* the record is old, forgotten. A caller that could not read the live panes
* passes `panesKnown: false`, so a failed snapshot never triggers a kill.
*/
export function planCleanup(jobs, rows, live, options = {}) {
const now = options.now ?? Date.now(), leaseMs = options.leaseMs ?? CLEANUP_LEASE_MS, retainMs = options.retainMs ?? CLEANUP_RETAIN_MS;
const self = options.selfPid ?? process.pid;
const panesKnown = options.panesKnown ?? true;
const byPgid = new Map();
for (const row of rows) {
const list = byPgid.get(row.pgid);
if (list)
list.push(row);
else
byPgid.set(row.pgid, [row]);
}
// The Hook's own process group, whatever its pid: never a target.
const ownGroup = rows.find(row => row.pid === self)?.pgid;
const kill = [], keep = [], forget = [];
for (const job of jobs) {
const kept = (reason) => keep.push({ job, reason });
if (!panesKnown) {
kept("unknown");
continue;
}
if (job.pgid === undefined) {
// No group recorded: nothing may be signalled. A record whose pane has
// been gone past the lease cannot resolve any more, so it is dropped;
// a fresh one is kept while its launch comes up.
if (job.pane && !live.has(paneKey(job.pane)) && now - job.startedAt > leaseMs)
forget.push(job);
else
kept("unresolved");
continue;
}
if (job.pgid <= 1 || job.pgid === ownGroup) {
kept("reserved");
continue;
}
// Only a job pinned to a pane is ever ended: a session-only record has no
// pane to prove gone, so it is never signalled.
if (!job.pane) {
kept("no-pane");
continue;
}
if (!options.servers?.has(job.pane.server)) {
kept("server-unknown");
continue;
}
if (live.has(paneKey(job.pane))) {
kept("pane-alive");
continue;
}
const group = byPgid.get(job.pgid);
if (!group || group.length === 0) {
forget.push(job);
continue;
}
if (group.some(row => row.pid === self)) {
kept("self");
continue;
}
if (now - job.startedAt < leaseMs) {
kept("lease");
continue;
}
if (!group.some(row => commandMatches(job, row))) {
// The group id was reused by an unrelated program: the worker's group is
// gone and this record no longer describes anything safe to end.
if (now - job.startedAt > retainMs)
forget.push(job);
else
kept("reused");
continue;
}
// A group id is only reused once its group is empty. While the leader
// lives it must be the leader recorded with the pgid; a leader with no
// recorded start can't be told apart from a newcomer, so it is left alone.
// With the leader gone the group still holds its id, so it is the original.
const leader = group.find(row => row.pid === job.pgid);
if (leader && (job.leaderStartedAt === undefined || leader.startedAt === undefined
|| Math.abs(leader.startedAt - job.leaderStartedAt) > LEADER_START_TOLERANCE_MS)) {
if (now - job.startedAt > retainMs)
forget.push(job);
else
kept("leader-unverified");
continue;
}
kill.push(job);
}
return { kill, keep, forget };
}
/** The registry file: one JSON object, written atomically like the Hook's other state. */
const defaultFile = () => path.join(bridgeRoot(), "jobs.json");
/**
* The on-disk job registry. The Hook is the only writer, so the file is read
* whole; a damaged file reads as no jobs rather than failing a report.
*/
export class JobRegistry {
file;
now;
constructor(file = defaultFile(), now = Date.now) {
this.file = file;
this.now = now;
}
async list() {
try {
const parsed = stateSchema.safeParse(JSON.parse(readFileSync(this.file, "utf8")));
return parsed.success ? parsed.data.jobs : [];
}
catch {
return [];
}
}
async replace(jobs) {
await atomicInPrivateDir(this.file, JSON.stringify({ version: 1, jobs }, null, 2) + "\n");
}
/** Record (or refresh) one worker's job, keyed by its pane. */
async register(job) {
const entry = { ...job, startedAt: job.startedAt ?? this.now() };
const key = jobKey(entry);
const jobs = await this.list();
await this.replace([...jobs.filter(existing => jobKey(existing) !== key), entry].slice(-1024));
return entry;
}
/** Resolve process groups against the current table and persist any change. */
async reconcile(rows, owners) {
const jobs = await this.list();
const resolved = resolveJobGroups(jobs, rows, owners);
if (JSON.stringify(resolved) !== JSON.stringify(jobs))
await this.replace(resolved);
return resolved;
}
/**
* End every registered process group that is safe to end (`planCleanup`) and
* drop the records that are gone. `live` is the set of pane keys the caller
* still sees; a caller whose snapshot failed passes `panesKnown: false`, and
* nothing is ended.
*/
async cleanup(rows, live, options = {}) {
const jobs = options.owners ? await this.reconcile(rows, options.owners) : await this.list();
const plan = planCleanup(jobs, rows, live, { ...options, ...(options.now === undefined ? { now: this.now() } : {}) });
const signal = options.kill ?? ((pgid, value) => { process.kill(-pgid, value); });
const killed = [];
for (const job of plan.kill) {
try {
signal(job.pgid, "SIGTERM");
killed.push(job.pgid);
}
catch { /* Already gone between the plan and the signal. */ }
}
const done = new Set([...plan.forget, ...plan.kill].map(jobKey));
const remaining = jobs.filter(job => !done.has(jobKey(job)));
if (remaining.length !== jobs.length)
await this.replace(remaining);
return {
killed,
kept: plan.keep.map(({ job, reason }) => ({ ...(job.pgid !== undefined ? { pgid: job.pgid } : {}), reason })),
forgotten: plan.forget.flatMap(job => (job.pgid !== undefined ? [job.pgid] : [])),
};
}
}