mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-06 12:52:26 +00:00
fleet update
This commit is contained in:
1 parent
0ffd92a8d2
commit
2d695e7acd
20 files changed
+546
-38
No files matched your search
@@ -12,8 +12,10 @@ export const HEALTH_DEFAULTS = {
|
||||
diskFreeGb: 5,
|
||||
memoryAvailablePct: 15,
|
||||
swapUsedPct: 50,
|
||||
// Occupancy alone is not tight: Linux keeps swapped pages. swapUsedPct counts
|
||||
// only when available memory is also low. swapFullPct counts on its own.
|
||||
// Occupancy alone is not tight, and a full swap file is not tight while
|
||||
// plenty of RAM is free. swapUsedPct counts only when available memory is
|
||||
// under twice memoryAvailablePct. swapFullPct is kept so older configs load;
|
||||
// it is not a hold by itself. Live swap-in (pswpin) is pressure on its own.
|
||||
swapFullPct: 90,
|
||||
cpuTempC: 90,
|
||||
batteryPct: 20,
|
||||
@@ -189,6 +191,35 @@ export function parseDf(text) {
|
||||
return { mount: parts.slice(5).join(' '), totalBytes: total, usedBytes: used, freeBytes: free, freePct };
|
||||
}
|
||||
|
||||
/** Linux /proc/vmstat counters. pswpin is pages read back from swap. */
|
||||
export function parseLinuxVmstat(text) {
|
||||
const out = {};
|
||||
for (const line of String(text || '').split('\n')) {
|
||||
const m = /^([A-Za-z0-9_]+)\s+(\d+)$/.exec(line.trim());
|
||||
if (m) out[m[1]] = Number(m[2]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Pages swapped in between two /proc/vmstat snapshots. Null when either side lacks pswpin. */
|
||||
export function swapInPages(before, after) {
|
||||
if (before?.pswpin == null || after?.pswpin == null) return null;
|
||||
return Math.max(0, after.pswpin - before.pswpin);
|
||||
}
|
||||
|
||||
/** Top non-trivial CPU lines from `ps -ax -o pid=,pcpu=,comm=`. */
|
||||
export function parseBusyPs(text) {
|
||||
const rows = [];
|
||||
for (const line of String(text || '').split('\n')) {
|
||||
const m = /^\s*(\d+)\s+(\d+(?:\.\d+)?)\s+(\S+)/.exec(line);
|
||||
if (!m) continue;
|
||||
const cpu = Number(m[2]);
|
||||
if (cpu < 5) continue;
|
||||
rows.push({ pid: Number(m[1]), cpu, comm: m[3] });
|
||||
}
|
||||
return rows.sort((a, b) => b.cpu - a.cpu).slice(0, 3);
|
||||
}
|
||||
|
||||
export function parseVmStat(text) {
|
||||
const page = /page size of (\d+) bytes/i.exec(text || '');
|
||||
const pages = {};
|
||||
@@ -521,20 +552,24 @@ function readWindowsBattery() {
|
||||
|
||||
function linuxSample(sysRoot, opt) {
|
||||
if (opt.statTexts || opt.procSamples) {
|
||||
const vm = Array.isArray(opt.vmstat) ? opt.vmstat.map((text) => parseLinuxVmstat(text)) : null;
|
||||
return {
|
||||
usagePct: opt.statTexts ? cpuUsagePct(parseCpuStat(opt.statTexts[0]), parseCpuStat(opt.statTexts[1])) : null,
|
||||
before: opt.procSamples ? opt.procSamples[0] : [],
|
||||
after: opt.procSamples ? opt.procSamples[1] : [],
|
||||
elapsedMs: opt.elapsedMs ?? 1000,
|
||||
swapInPages: vm ? swapInPages(vm[0], vm[1]) : null,
|
||||
};
|
||||
}
|
||||
if (opt.sampleMs === 0) return { usagePct: null, before: [], after: [], elapsedMs: 0 };
|
||||
if (opt.sampleMs === 0) return { usagePct: null, before: [], after: [], elapsedMs: 0, swapInPages: null };
|
||||
const stat1 = parseCpuStat(readText(path.join(sysRoot, 'proc/stat')) || '');
|
||||
const vm1 = parseLinuxVmstat(readText(path.join(sysRoot, 'proc/vmstat')) || '');
|
||||
const pages = pageSize();
|
||||
const before = readProcSnapshot(sysRoot, pages);
|
||||
const t0 = Date.now();
|
||||
pause(opt.sampleMs ?? 200);
|
||||
const stat2 = parseCpuStat(readText(path.join(sysRoot, 'proc/stat')) || '');
|
||||
const vm2 = parseLinuxVmstat(readText(path.join(sysRoot, 'proc/vmstat')) || '');
|
||||
const after = readProcSnapshot(sysRoot, pages);
|
||||
let usagePct = cpuUsagePct(stat1, stat2);
|
||||
if (usagePct == null) {
|
||||
@@ -542,7 +577,7 @@ function linuxSample(sysRoot, opt) {
|
||||
pause(opt.sampleMs ?? 200);
|
||||
usagePct = cpuUsagePct(a, cpuTimes());
|
||||
}
|
||||
return { usagePct, before, after, elapsedMs: Math.max(1, Date.now() - t0) };
|
||||
return { usagePct, before, after, elapsedMs: Math.max(1, Date.now() - t0), swapInPages: swapInPages(vm1, vm2) };
|
||||
}
|
||||
|
||||
function readLoad(platform, sysRoot) {
|
||||
@@ -606,13 +641,14 @@ function judge(snap, limits) {
|
||||
if (mem?.swapTotalBytes > 0 && mem.swapUsedBytes != null) {
|
||||
const pct = (100 * mem.swapUsedBytes) / mem.swapTotalBytes;
|
||||
const availablePct = mem.totalBytes > 0 && mem.availableBytes != null ? (100 * mem.availableBytes) / mem.totalBytes : null;
|
||||
const memoryLow = availablePct != null && availablePct < limits.memoryAvailablePct;
|
||||
const nearFull = pct >= limits.swapFullPct;
|
||||
// Occupancy alone is normal on Linux. Count it when memory is also low, or when swap is near full.
|
||||
if (nearFull || (memoryLow && pct >= limits.swapUsedPct)) {
|
||||
const words = nearFull
|
||||
? `swap ${Math.round(pct)}% used, near full (at or over ${limits.swapFullPct}%)`
|
||||
: `swap ${Math.round(pct)}% used while available memory is under ${limits.memoryAvailablePct}%`;
|
||||
const softLimit = limits.memoryAvailablePct * 2;
|
||||
const memorySoft = availablePct != null && availablePct < softLimit;
|
||||
const swappingIn = Number(mem.swapInPages) > 0;
|
||||
// A full swap file with plenty of free RAM is parked pages, not pressure.
|
||||
if (swappingIn || (memorySoft && pct >= limits.swapUsedPct)) {
|
||||
const words = swappingIn
|
||||
? `swap is being read in (${mem.swapInPages} pages in the sample)`
|
||||
: `swap ${Math.round(pct)}% used while available memory is under ${softLimit}%`;
|
||||
crossed.push({ id: 'swap', words });
|
||||
}
|
||||
}
|
||||
@@ -649,12 +685,45 @@ function judge(snap, limits) {
|
||||
if (snap.cpu?.load1 != null && snap.cpu.cores) {
|
||||
const per = snap.cpu.load1 / snap.cpu.cores;
|
||||
if (per >= limits.loadPerCore) {
|
||||
crossed.push({ id: 'load', words: `load ${snap.cpu.load1} on ${snap.cpu.cores} cores (${Math.round(per * 10) / 10} per core), at or over ${limits.loadPerCore}` });
|
||||
const fleet = fleetCpuPct(snap.areas);
|
||||
const words = `load ${snap.cpu.load1} on ${snap.cpu.cores} cores (${Math.round(per * 10) / 10} per core)`;
|
||||
if (fleet != null && fleet >= 10) {
|
||||
const who = (snap.areas || []).filter((a) => a.cpuPct >= 10).map((a) => `${a.id} ${a.cpuPct}%`).join(', ');
|
||||
crossed.push({ id: 'load', words: `${words}, and fleet areas are using CPU${who ? ` (${who})` : ''}` });
|
||||
} else {
|
||||
const outside = describeBusy(snap.busy);
|
||||
const sample = snap.cpu.usagePct == null ? '' : ` A short CPU sample is ${snap.cpu.usagePct}%.`;
|
||||
const fleetWords = fleet == null ? 'fleet area CPU was not measured' : 'fleet areas are idle';
|
||||
snap.notes.push({ id: 'load', words: `${words} is above ${limits.loadPerCore} per core, but ${fleetWords}.${sample}${outside} This does not hold fleet work.` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return crossed;
|
||||
}
|
||||
|
||||
function fleetCpuPct(areas) {
|
||||
const known = (areas || []).filter((a) => a.found && a.cpuPct != null);
|
||||
if (!known.length) return null;
|
||||
return known.reduce((sum, a) => sum + a.cpuPct, 0);
|
||||
}
|
||||
|
||||
function describeBusy(busy) {
|
||||
const rows = (busy || []).filter((row) => row?.comm && Number(row.cpu) >= 1).slice(0, 3);
|
||||
if (!rows.length) return '';
|
||||
return ` Busy outside the fleet: ${rows.map((row) => `${row.comm} ${row.cpu}%`).join(', ')}.`;
|
||||
}
|
||||
|
||||
function collectBusy(snap, limits, opt) {
|
||||
if (snap.cpu?.load1 == null || !snap.cpu.cores) return [];
|
||||
if (snap.cpu.load1 / snap.cpu.cores < limits.loadPerCore) return [];
|
||||
const fleet = fleetCpuPct(snap.areas);
|
||||
if (fleet != null && fleet >= 10) return [];
|
||||
if (Array.isArray(opt.busy)) return opt.busy;
|
||||
if (opt.psBusy == null && (opt.sampleMs === 0 || opt.skipBusy)) return [];
|
||||
const text = opt.psBusy != null ? opt.psBusy : sh('ps', ['-ax', '-o', 'pid=,pcpu=,comm='], 2000);
|
||||
return parseBusyPs(text);
|
||||
}
|
||||
|
||||
function verdictOf(crossed, battery) {
|
||||
const onBattery = battery?.present && battery.state === 'discharging';
|
||||
if (!crossed.length && onBattery) {
|
||||
@@ -690,6 +759,7 @@ export function formatHealth(snap) {
|
||||
const left = battery.minutesRemaining == null ? 'time unavailable' : `${battery.minutesRemaining} min left`;
|
||||
lines.push(`battery ${battery.percent ?? '?'}% ${battery.state} ${left}`);
|
||||
}
|
||||
for (const note of snap.notes || []) lines.push(`note ${note.words}`);
|
||||
if (!snap.areas?.length) lines.push('areas none');
|
||||
for (const area of snap.areas || []) {
|
||||
if (!area.found) lines.push(`area ${area.id} window not found`);
|
||||
@@ -742,6 +812,9 @@ export function fleetHealth(c = {}, opt = {}) {
|
||||
areas = readPsAreas(panes, opt);
|
||||
}
|
||||
}
|
||||
if (memory?.status === 'ok') {
|
||||
memory.swapInPages = opt.swapInPages != null ? opt.swapInPages : (sampled?.swapInPages ?? null);
|
||||
}
|
||||
const snap = {
|
||||
at: opt.at || new Date().toISOString(),
|
||||
platform,
|
||||
@@ -753,7 +826,9 @@ export function fleetHealth(c = {}, opt = {}) {
|
||||
battery,
|
||||
areas,
|
||||
thresholds: limits,
|
||||
notes: [],
|
||||
};
|
||||
snap.busy = collectBusy(snap, limits, opt);
|
||||
snap.crossed = judge(snap, limits);
|
||||
snap.verdict = verdictOf(snap.crossed, snap.battery);
|
||||
return snap;
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { KIT, area as getArea, die, pidAlive, tryGit, git, mainRef, writeAtomic, today, landerIsUp, matchesAny, startLander, runTimedSync } from '../lib.mjs';
|
||||
import { KIT, area as getArea, die, pidAlive, tryGit, git, mainRef, writeAtomic, today, landerIsUp, matchesAny, startLander, runTimedSync, readRun } from '../lib.mjs';
|
||||
import { send as mailboxSendFile, read as mailboxReadDir, archive as mailboxArchiveDir } from '../../adapters/mailbox.mjs';
|
||||
import { appendWakeLog, priorWakes } from './wake-log.mjs';
|
||||
import { readHoldMap, readProgressMap, STALL_MIN } from './progress.mjs';
|
||||
import { tmuxCommand, tmuxSocketFacts } from './tmux.mjs';
|
||||
|
||||
const STATUSES = ['open', 'in-progress', 'blocked', 'done', 'deferred'];
|
||||
@@ -549,17 +550,39 @@ function senderOf(file) {
|
||||
* That is the only recovery. It runs when someone looks at status, reads mail,
|
||||
* hears, or tells. A filename already nudged is not nudged again. There is no timer.
|
||||
*/
|
||||
export function fleetStatus(c, { recoverAfterMs = RECOVER_AFTER_MS, idleAfterMs = IDLE_AFTER_MS } = {}) {
|
||||
export function fleetStatus(c, { recoverAfterMs = RECOVER_AFTER_MS, idleAfterMs = IDLE_AFTER_MS, views = null } = {}) {
|
||||
const state = readNudgeState(c);
|
||||
let stateDirty = false;
|
||||
const recovered = [];
|
||||
const configured = Number(c.progress?.stallMin);
|
||||
const stallMin = Number.isFinite(configured) && configured >= 0 ? configured : STALL_MIN;
|
||||
const progress = readProgressMap(c);
|
||||
const holds = readHoldMap(c);
|
||||
const areas = c.areas.map((a) => {
|
||||
const view = paneView(c, a.id, idleAfterMs);
|
||||
const view = views?.[a.id] || paneView(c, a.id, idleAfterMs);
|
||||
const unread = unreadFiles(c, a.id);
|
||||
const mark = progress[a.id] || null;
|
||||
const ageMin = mark?.at ? (Date.now() - Date.parse(mark.at)) / 60000 : null;
|
||||
const row = {
|
||||
id: a.id, role: a.role, harness: a.harness,
|
||||
window: view.window, unread: unread.length, activity: view.activity,
|
||||
progress: mark && Number.isFinite(ageMin) ? { at: mark.at, line: mark.line, ageMin: Math.round(ageMin) } : null,
|
||||
hold: holds[a.id] || null,
|
||||
stalled: false,
|
||||
stallReason: null,
|
||||
};
|
||||
const quiet = view.window === 'alive' && unread.length === 0;
|
||||
if (quiet && mark && Number.isFinite(ageMin) && ageMin >= stallMin) {
|
||||
row.stalled = true;
|
||||
row.stallReason = `no progress for ${Math.round(ageMin)} min and no unread mail`;
|
||||
} else if (quiet && !mark) {
|
||||
const started = Date.parse(readRun(c, a.id)?.startedAt || '');
|
||||
const upMin = Number.isFinite(started) ? (Date.now() - started) / 60000 : null;
|
||||
if (upMin != null && upMin >= stallMin) {
|
||||
row.stalled = true;
|
||||
row.stallReason = `no checkpoint for ${Math.round(upMin)} min and no unread mail`;
|
||||
}
|
||||
}
|
||||
const oldest = unread[0];
|
||||
const age = oldest ? Date.now() - oldest.mtimeMs : 0;
|
||||
const already = (state[a.id] || []).includes(oldest?.file);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Per-area checkpoint and hold records. Status and the check-in read them.
|
||||
// There is no timer. An area writes a line when work advances, and a hold
|
||||
// when something is blocked, naming who clears it and what is still allowed.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { area as getArea, die, writeAtomic } from '../lib.mjs';
|
||||
|
||||
export const STALL_MIN = 20;
|
||||
|
||||
function safeId(id) {
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(id || '')) die(`bad area "${id ?? ''}"`);
|
||||
return id;
|
||||
}
|
||||
|
||||
function fileIn(c, kind, id) {
|
||||
return path.join(c.stateDir, kind, `${safeId(id)}.json`);
|
||||
}
|
||||
|
||||
function readMap(c, kind) {
|
||||
const dir = path.join(c.stateDir, kind);
|
||||
const out = {};
|
||||
if (!fs.existsSync(dir)) return out;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (!name.endsWith('.json')) continue;
|
||||
try { out[name.slice(0, -5)] = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); }
|
||||
catch { /* a torn write is skipped; the next checkpoint replaces it */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function readProgressMap(c) {
|
||||
return readMap(c, 'progress');
|
||||
}
|
||||
|
||||
export function readHoldMap(c) {
|
||||
return readMap(c, 'holds');
|
||||
}
|
||||
|
||||
export function writeProgress(c, area, line) {
|
||||
const id = safeId(area);
|
||||
getArea(c, id);
|
||||
const text = String(line || '').trim();
|
||||
if (!text) die('progress needs one line saying what advanced');
|
||||
const rec = { at: new Date().toISOString(), area: id, line: text.slice(0, 400) };
|
||||
writeAtomic(fileIn(c, 'progress', id), JSON.stringify(rec) + '\n');
|
||||
return rec;
|
||||
}
|
||||
|
||||
export function writeHold(c, area, { owner, unblock, next } = {}) {
|
||||
const id = safeId(area);
|
||||
getArea(c, id);
|
||||
const rec = {
|
||||
at: new Date().toISOString(),
|
||||
area: id,
|
||||
owner: String(owner || '').trim(),
|
||||
unblock: String(unblock || '').trim(),
|
||||
next: String(next || '').trim(),
|
||||
};
|
||||
if (!rec.owner || !rec.unblock || !rec.next) die('a hold needs --owner, --unblock, and --next');
|
||||
writeAtomic(fileIn(c, 'holds', id), JSON.stringify(rec) + '\n');
|
||||
return rec;
|
||||
}
|
||||
|
||||
export function clearHold(c, area) {
|
||||
const id = safeId(area);
|
||||
getArea(c, id);
|
||||
const file = fileIn(c, 'holds', id);
|
||||
if (fs.existsSync(file)) fs.unlinkSync(file);
|
||||
return { area: id, cleared: true };
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Startup readiness. One mailbox round trip, the Codex approval path, health,
|
||||
// durable deps, and the last checkpoints. No model call and no daemon.
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { send, read, archive } from '../../adapters/mailbox.mjs';
|
||||
import { fleetHealth } from './health.mjs';
|
||||
import { readHoldMap, readProgressMap } from './progress.mjs';
|
||||
|
||||
const USAGE_RE = /five-hour|usage limit|%\s*remaining|rate limit/i;
|
||||
|
||||
export function codexConfigCandidates(repo, env = process.env, home = os.homedir()) {
|
||||
const root = env.CODEX_HOME || path.join(home, '.codex');
|
||||
return [path.join(root, 'config.toml'), path.join(repo, '.codex', 'config.toml')];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the agent-fleet approval mode out of a Codex config.toml.
|
||||
* `approve` is the always-allow value. `auto` still requires approval for
|
||||
* tools that are not marked read-only, and on-request auto-review then runs.
|
||||
*/
|
||||
export function parseCodexMcpApproval(text, server = 'agent-fleet') {
|
||||
let section = '';
|
||||
const result = { server: false, defaultMode: null, tools: {} };
|
||||
for (const raw of String(text || '').split('\n')) {
|
||||
const line = raw.replace(/\s+#.*$/, '').trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const table = /^\[([^\]]+)\]$/.exec(line);
|
||||
if (table) { section = table[1].trim(); continue; }
|
||||
const kv = /^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/.exec(line);
|
||||
if (!kv) continue;
|
||||
const val = kv[2].trim().replace(/^['"]|['"]$/g, '');
|
||||
if (section === `mcp_servers.${server}` && kv[1] === 'default_tools_approval_mode') {
|
||||
result.server = true;
|
||||
result.defaultMode = val;
|
||||
}
|
||||
const tool = new RegExp(`^mcp_servers\\.${server}\\.tools\\.(.+)$`).exec(section);
|
||||
if (tool && kv[1] === 'approval_mode') {
|
||||
result.server = true;
|
||||
result.tools[tool[1]] = val;
|
||||
}
|
||||
}
|
||||
const sendMode = result.tools.mailbox_send;
|
||||
const tellMode = result.tools.tell;
|
||||
const mailOk = result.defaultMode === 'approve' || (sendMode === 'approve' && tellMode === 'approve');
|
||||
return { ...result, mailOk };
|
||||
}
|
||||
|
||||
export function readCodexApproval(repo, opt = {}) {
|
||||
const files = [];
|
||||
for (const file of codexConfigCandidates(repo, opt.env, opt.home)) {
|
||||
if (!file || files.some((row) => row.file === file)) continue;
|
||||
if (!fs.existsSync(file)) {
|
||||
files.push({ file, found: false });
|
||||
continue;
|
||||
}
|
||||
const parsed = parseCodexMcpApproval(fs.readFileSync(file, 'utf8'));
|
||||
files.push({ file, found: true, ...parsed });
|
||||
}
|
||||
const hit = files.find((row) => row.mailOk) || files.find((row) => row.found && row.server) || null;
|
||||
return {
|
||||
files,
|
||||
mailOk: Boolean(hit?.mailOk),
|
||||
mode: hit?.defaultMode ?? null,
|
||||
file: hit?.file ?? null,
|
||||
note: 'fleet up runs codex with no --profile and does not set CODEX_HOME. The session reads the same config.toml as any other Codex process in that environment. The server name is agent-fleet. Shell fleet tell is a sandboxed command; default_tools_approval_mode does not apply to it.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Prove send, read, and archive on an isolated mailbox. Does not wake a pane. */
|
||||
export function mailboxRoundTrip(dir) {
|
||||
const probe = path.join(dir, 'probe');
|
||||
fs.rmSync(probe, { recursive: true, force: true });
|
||||
const body = `ready ${Date.now()} ${process.pid}`;
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const stamp = path.join(dir, `.write-${process.pid}`);
|
||||
fs.writeFileSync(stamp, 'ok');
|
||||
fs.unlinkSync(stamp);
|
||||
const file = send(probe, 'ready', 'ready', body, 'round trip');
|
||||
const msgs = read(probe, 'ready');
|
||||
const hit = msgs.find((m) => m.text.includes(body));
|
||||
if (!hit) return { ok: false, error: 'read did not see the message just written' };
|
||||
archive(probe, 'ready', hit.file);
|
||||
const left = read(probe, 'ready');
|
||||
if (left.length) return { ok: false, error: 'archive left the message unread' };
|
||||
return { ok: true, file: path.basename(file) };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||
} finally {
|
||||
fs.rmSync(probe, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function depsReady(c) {
|
||||
const root = path.join(c.stateDir, 'deps');
|
||||
const manifest = path.join(root, 'manifest.txt');
|
||||
const missing = [];
|
||||
if (fs.existsSync(manifest)) {
|
||||
for (const line of fs.readFileSync(manifest, 'utf8').split('\n')) {
|
||||
const rel = line.trim();
|
||||
if (!rel || rel.startsWith('#')) continue;
|
||||
const file = path.resolve(root, rel);
|
||||
if (file !== root && !file.startsWith(root + path.sep)) { missing.push(rel); continue; }
|
||||
if (!fs.existsSync(file)) missing.push(rel);
|
||||
}
|
||||
}
|
||||
const names = ['package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'Cargo.lock', 'go.sum'];
|
||||
const repoManifests = names.filter((name) => fs.existsSync(path.join(c.repo, name)));
|
||||
return {
|
||||
repoManifests,
|
||||
manifest: fs.existsSync(manifest),
|
||||
missing,
|
||||
ok: missing.length === 0,
|
||||
note: 'Keep runners, lockfiles, pinned archives, and evidence in the worktree or stateDir/deps. /tmp and the npm cache are disposable.',
|
||||
};
|
||||
}
|
||||
|
||||
export function usageSignal(c) {
|
||||
const file = path.join(c.stateDir, 'wake-log.jsonl');
|
||||
if (!fs.existsSync(file)) return { seen: false, line: null };
|
||||
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).slice(-80);
|
||||
const hit = [...lines].reverse().find((line) => USAGE_RE.test(line)) || null;
|
||||
return { seen: Boolean(hit), line: hit ? hit.slice(0, 240) : null };
|
||||
}
|
||||
|
||||
export function fleetReady(c, opt = {}) {
|
||||
const mailbox = mailboxRoundTrip(path.join(c.stateDir, 'ready-mailbox'));
|
||||
const approval = readCodexApproval(c.repo, opt);
|
||||
const health = opt.health || fleetHealth(c, { sampleMs: 0, ...(opt.healthOpt || {}) });
|
||||
const deps = depsReady(c);
|
||||
const checkpoints = Object.values(readProgressMap(c));
|
||||
const holds = Object.values(readHoldMap(c));
|
||||
const usage = usageSignal(c);
|
||||
return {
|
||||
ok: mailbox.ok && deps.ok,
|
||||
mailbox,
|
||||
approval,
|
||||
health: { verdict: health.verdict, notes: health.notes || [] },
|
||||
deps,
|
||||
checkpoints,
|
||||
holds,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatReady(report) {
|
||||
const lines = [];
|
||||
lines.push(report.mailbox.ok ? 'mailbox round trip ok' : `mailbox round trip failed: ${report.mailbox.error}`);
|
||||
if (report.approval.mailOk) lines.push(`approval ${report.approval.mode || 'per-tool approve'} in ${report.approval.file}`);
|
||||
else {
|
||||
const seen = (report.approval.files || []).filter((row) => row.found && row.server);
|
||||
const where = seen.length ? seen.map((row) => `${row.file} mode=${row.defaultMode || 'unset'}`).join('; ') : 'no [mcp_servers.agent-fleet] table found';
|
||||
lines.push(`approval want default_tools_approval_mode = "approve" (or per-tool approval_mode = "approve" on mailbox_send and tell). ${where}. auto still asks, and on-request auto-review can reject the call. Offer to write approve only after the user says yes, then reload Codex.`);
|
||||
}
|
||||
lines.push(`health ${report.health.verdict}`);
|
||||
for (const note of report.health.notes || []) lines.push(`health note ${note.words}`);
|
||||
const manifests = report.deps.repoManifests.length ? report.deps.repoManifests.join(', ') : 'none in the repo root';
|
||||
lines.push(`deps repo manifests: ${manifests}${report.deps.manifest ? '; stateDir/deps/manifest.txt present' : ''}`);
|
||||
if (report.deps.missing.length) lines.push(`deps missing ${report.deps.missing.join(', ')}`);
|
||||
lines.push(`deps note ${report.deps.note}`);
|
||||
if (!report.checkpoints.length) lines.push('checkpoints none yet');
|
||||
for (const row of report.checkpoints) lines.push(`checkpoint ${row.area} ${row.at} ${row.line}`);
|
||||
if (!report.holds.length) lines.push('holds none');
|
||||
for (const row of report.holds) lines.push(`hold ${row.area} owner ${row.owner}; unblock ${row.unblock}; next ${row.next}`);
|
||||
if (report.usage.seen) lines.push('usage a recent wake line mentioned a usage limit. Checkpoint and hand off; resume from the checkpoints after the window resets.');
|
||||
lines.push('resume continue from the checkpoints. A live window is not progress.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -39,6 +39,11 @@
|
||||
// fleet status areas, run state (running / exited / failed / timeout / stopped), lander up or down
|
||||
// fleet wakes recent wake attempts, and plain words for a likely failure
|
||||
// fleet health [--json] one snapshot: CPU, memory, disk, temps, battery, per-area use
|
||||
// fleet ready [--json] mailbox round trip, Codex approval path, health, deps, checkpoints
|
||||
// fleet resume same as fleet up, after a reboot or a usage-limit stop
|
||||
// fleet progress <area> <line> record what just advanced. Status uses this, not the pane
|
||||
// fleet hold <area> --owner <who> --unblock <cond> --next <task>
|
||||
// record a hold. --clear removes it
|
||||
// fleet prompt <id> print the composed system prompt for an area
|
||||
// fleet crontab PRINT the crontab lines for scheduled jobs (never installs)
|
||||
// fleet run-job <name> explicit manual headless one-shot; cron uses scheduled-job
|
||||
@@ -56,6 +61,8 @@ import { KIT, loadConfig, loadAdapter, area as getArea, dispatcher, sessionName,
|
||||
import { commitWork, setupCommit, tellDispatch, hearInterpreter, fleetStatus } from './core/ops.mjs';
|
||||
import { readWakeLog } from './core/wake-log.mjs';
|
||||
import { fleetHealth, formatHealth } from './core/health.mjs';
|
||||
import { fleetReady, formatReady } from './core/ready.mjs';
|
||||
import { writeProgress, writeHold, clearHold } from './core/progress.mjs';
|
||||
import { guardSchedule } from './core/schedule-guard.mjs';
|
||||
import { tmuxCommand, rememberTmuxServer } from './core/tmux.mjs';
|
||||
import { fleetUpdate, recordInstalled, isTemplatePath } from './core/update.mjs';
|
||||
@@ -243,6 +250,7 @@ async function supervise(id) {
|
||||
|
||||
async function up() {
|
||||
const fresh = has('--fresh'), bg = has('--bg'), dry = has('--dry-run');
|
||||
if (!dry) console.log(formatReady(fleetReady(c)));
|
||||
const sessions = readSessions();
|
||||
const areas = pick();
|
||||
const order = [...areas.filter((a) => a.role !== 'dispatcher'), ...areas.filter((a) => a.role === 'dispatcher')];
|
||||
@@ -312,6 +320,9 @@ async function status() {
|
||||
const row = report.areas.find((r) => r.id === a.id);
|
||||
const live = row ? ` window=${row.window} unread=${row.unread} ${row.activity}` : '';
|
||||
console.log(`${a.id.padEnd(18)} ${a.role.padEnd(10)} ${a.harness.padEnd(7)} ${`${a.tier}/${a.model ?? '-'}`.padEnd(16)} ctx=${String(a.context ?? '-').padEnd(5)} rc=${a.remoteControl ? 'on ' : 'off'} budget=${budgetMin > 0 ? `${budgetMin}m` : 'unlimited'} ${fs.existsSync(a.worktree) ? '' : 'NO-WORKTREE '}${runMark(a, sessions)}${live}`);
|
||||
if (row?.stalled) console.log(` ${a.id} stalled: ${row.stallReason}`);
|
||||
if (row?.hold) console.log(` ${a.id} hold: owner ${row.hold.owner}; unblock ${row.hold.unblock}; next ${row.hold.next}`);
|
||||
else if (row?.progress) console.log(` ${a.id} progress: ${row.progress.line} (${row.progress.ageMin} min ago)`);
|
||||
}
|
||||
if (report.interpreter.unread) console.log(`interpreter unread=${report.interpreter.unread}`);
|
||||
for (const id of report.recovered) console.log(`nudged ${id} again`);
|
||||
@@ -323,6 +334,34 @@ function healthCmd() {
|
||||
else console.log(formatHealth(report));
|
||||
}
|
||||
|
||||
function readyCmd() {
|
||||
const report = fleetReady(c);
|
||||
if (argv.includes('--json')) console.log(JSON.stringify(report, null, 2));
|
||||
else console.log(formatReady(report));
|
||||
if (!report.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
function progressCmd() {
|
||||
const area = argv[1];
|
||||
const words = [];
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
if (argv[i] === '--config') { i++; continue; }
|
||||
words.push(argv[i]);
|
||||
}
|
||||
const rec = writeProgress(c, area, words.join(' '));
|
||||
console.log(`${rec.area} ${rec.at} ${rec.line}`);
|
||||
}
|
||||
|
||||
function holdCmd() {
|
||||
const area = argv[1];
|
||||
if (argv.includes('--clear')) {
|
||||
console.log(`cleared hold ${clearHold(c, area).area}`);
|
||||
return;
|
||||
}
|
||||
const rec = writeHold(c, area, { owner: flag(argv, '--owner'), unblock: flag(argv, '--unblock'), next: flag(argv, '--next') });
|
||||
console.log(`hold ${rec.area}: owner ${rec.owner}; unblock ${rec.unblock}; next ${rec.next}`);
|
||||
}
|
||||
|
||||
function wakesCmd() {
|
||||
const report = readWakeLog(c);
|
||||
if (!report.entries.length) {
|
||||
@@ -648,6 +687,7 @@ const run = { up, down, status, supervise: () => supervise(argv[1]), worktrees,
|
||||
'scheduled-job': async () => { process.exitCode = (await guardSchedule(c, argv[1])).status; },
|
||||
'run-job': () => runJob(argv[1]), 'sched-env': () => schedEnv(argv[1]), lander: () => lander(argv[1] ?? 'status'),
|
||||
commit: commitCmd, 'setup-commit': setupCommitCmd, update: updateCmd, tell: tellCmd, hear: hearCmd, wakes: wakesCmd, health: healthCmd,
|
||||
ready: readyCmd, resume: up, progress: progressCmd, hold: holdCmd,
|
||||
doctor: () => tool('doctor'), landq: () => tool('landq'), backlog: () => tool('backlog'),
|
||||
mailbox: () => tool('mailbox') }[cmd];
|
||||
if (!run) die(`unknown command "${cmd}" (fleet help)`);
|
||||
|
||||
@@ -119,6 +119,7 @@ export function loadConfig(argv = process.argv, start = process.cwd()) {
|
||||
c.clock = { quietHours: ['22:00', '08:00'], ...(c.clock || {}) };
|
||||
c.doctor = { staleHours: 72, unownedDays: 14, ...(c.doctor || {}) };
|
||||
c.health = healthThresholds(c.health);
|
||||
c.progress = { stallMin: 20, ...(c.progress || {}) };
|
||||
c.modules = { producer: false, reviewer: false, usageGovernor: false, ...(c.modules || {}) };
|
||||
c.schedules ??= [];
|
||||
c.areas = (c.areas || []).map((a) => resolveArea(c, a));
|
||||
|
||||
+11
-2
@@ -12,6 +12,8 @@ import {
|
||||
import { fleetUpdate } from './core/update.mjs';
|
||||
import { readWakeLog } from './core/wake-log.mjs';
|
||||
import { fleetHealth } from './core/health.mjs';
|
||||
import { fleetReady } from './core/ready.mjs';
|
||||
import { writeProgress, writeHold, clearHold } from './core/progress.mjs';
|
||||
|
||||
export const SERVER = { name: 'agent-fleet', version: '0.2.0' };
|
||||
const INSTRUCTIONS = [
|
||||
@@ -20,7 +22,8 @@ const INSTRUCTIONS = [
|
||||
'mailbox_read at the start of a turn. Mail is always delivered; the prompt says when to act on it. Sandboxed agents commit with the commit tool (message plus paths, or all), not git commit. submit on that tool also queues the tip. Then go back to work. At your next natural checkpoint, land_status can report that submission. Waiting is optional. Never infer the lander or the land result from silence. landq_submit queues the committed tip (a dirty worktree warns and still queues HEAD) and starts a drain. Do not push main.',
|
||||
'A scarce machine is a prompt convention: say so in .task and wait if a peer already has it. There is no slot lock.',
|
||||
'Sessions, worktrees, and cron stay on the fleet CLI. This server does not start them.',
|
||||
'setup_commit commits only the fleet setup files on the main checkout. A sandboxed agent uses it instead of git commit. update refreshes vendored runtime and the registered skill to this kit, leaves an edited prompt in place (the new template is a .kit file beside it), and commits only the paths it changed. It does not change fleet.config.json, product files, mail, or the landing queue. If that commit cannot write the index, it falls back to setup_commit. Mail to an open tmux window wakes it with a short line, not the body. mailbox_read and hear take those messages out of unread. fleet_status reports each window alive or dead, the unread count, and a working or idle guess. An idle area with mail that has been waiting is nudged once by that check. mailbox_read, hear, and tell run that same check. A second look does not nudge again. wake_log says whether the wake line was still in the composer, a later Enter submitted it, a waiter typed it after the pane was busy, the line left the composer but the mail is still unread, or the pane was not there. The missing-pane line names the windows and the tmux socket. Tell the user that case. Do not ask them to open the log. health is one snapshot of CPU, memory, swap, disk, temperature, battery, and each area process tree. Missing sensors are unavailable. Swap occupancy alone is not tight. The config health block is guidance. A lower cpuTempC is a per-repo edit, offered to the user, not a new default. A change to ~/.codex/config.toml needs a Codex reload. Say the verdict in plain words. Do not scrape the tmux pane.',
|
||||
'Send mail with mailbox_send or tell. A shell fleet tell or mailbox send is a sandboxed command, and the MCP approval mode does not apply to it. progress writes the checkpoint that status uses to tell progress from a live window. hold records the owner, the exact unblock condition, and the next allowed task.',
|
||||
'setup_commit commits only the fleet setup files on the main checkout. A sandboxed agent uses it instead of git commit. update refreshes vendored runtime and the registered skill to this kit, leaves an edited prompt in place (the new template is a .kit file beside it), and commits only the paths it changed. It does not change fleet.config.json, product files, mail, or the landing queue. If that commit cannot write the index, it falls back to setup_commit. Mail to an open tmux window wakes it with a short line, not the body. mailbox_read and hear take those messages out of unread. fleet_status reports each window alive or dead, the unread count, a working or idle guess, a stall when nothing has advanced, and any hold. An idle area with mail that has been waiting is nudged once by that check. mailbox_read, hear, and tell run that same check. A second look does not nudge again. wake_log says whether the wake line was still in the composer, a later Enter submitted it, a waiter typed it after the pane was busy, the line left the composer but the mail is still unread, or the pane was not there. The missing-pane line names the windows and the tmux socket. Tell the user that case. Do not ask them to open the log. health is one snapshot of CPU, memory, swap, disk, temperature, battery, and each area process tree. Missing sensors are unavailable. Near-full swap with plenty of available memory is not tight. Load from other programs is a note, not a hold. When health holds heavy work, keep doing light work. The config health block is guidance. A lower cpuTempC is a per-repo edit, offered to the user, not a new default. A change to ~/.codex/config.toml needs a Codex reload. Say the verdict in plain words. Do not scrape the tmux pane.',
|
||||
].join(' ');
|
||||
|
||||
const cwdProp = { type: 'string', description: 'Directory to resolve the fleet from. Defaults to this server\'s cwd.' };
|
||||
@@ -42,7 +45,10 @@ export const TOOLS = [
|
||||
{ name: 'hear', description: 'Read unread mailbox messages for the interpreter and move them out of unread.', inputSchema: { type: 'object', properties: { cwd: cwdProp } } },
|
||||
{ name: 'fleet_status', description: 'Each area: tmux window alive or dead, unread mail count, and a working or idle guess. Idle with unread mail that has been waiting is nudged once.', inputSchema: { type: 'object', properties: { cwd: cwdProp } } },
|
||||
{ name: 'wake_log', description: 'Recent wake attempts from the fleet state dir. Each line says the pane state, what was typed or skipped, and a plain-words flag when a wake likely failed. No mail bodies.', inputSchema: { type: 'object', properties: { limit: { type: 'number' }, cwd: cwdProp } } },
|
||||
{ name: 'health', description: 'One snapshot of CPU, memory, swap, disk free, temperature, battery, and per-area CPU and memory. Missing sensors are unavailable. The verdict names which suggested limits were crossed.', inputSchema: { type: 'object', properties: { cwd: cwdProp } } },
|
||||
{ name: 'health', description: 'One snapshot of CPU, memory, swap, disk free, temperature, battery, and per-area CPU and memory. Missing sensors are unavailable. The verdict names which suggested limits were crossed. Near-full swap with plenty of free RAM is not tight. Load outside the fleet is a note.', inputSchema: { type: 'object', properties: { cwd: cwdProp } } },
|
||||
{ name: 'ready', description: 'Startup check: one mailbox round trip, whether Codex approval for agent-fleet is approve, health, durable deps, checkpoints, and holds. Does not call a model.', inputSchema: { type: 'object', properties: { cwd: cwdProp } } },
|
||||
{ name: 'progress', description: 'Record one line of real progress for an area. Status uses this to tell progress from a live window.', inputSchema: { type: 'object', properties: { area: { type: 'string' }, line: { type: 'string' }, cwd: cwdProp }, required: ['area', 'line'] } },
|
||||
{ name: 'hold', description: 'Record a hold: owner, the exact unblock condition, and the next allowed task. clear removes it.', inputSchema: { type: 'object', properties: { area: { type: 'string' }, owner: { type: 'string' }, unblock: { type: 'string' }, next: { type: 'string' }, clear: { type: 'boolean' }, cwd: cwdProp }, required: ['area'] } },
|
||||
];
|
||||
|
||||
function configFor(args) {
|
||||
@@ -72,6 +78,9 @@ export function dispatchTool(name, args = {}) {
|
||||
case 'fleet_status': return fleetStatus(c);
|
||||
case 'wake_log': return readWakeLog(c, { limit: args.limit });
|
||||
case 'health': return fleetHealth(c);
|
||||
case 'ready': return fleetReady(c);
|
||||
case 'progress': return writeProgress(c, args.area, args.line);
|
||||
case 'hold': return args.clear ? clearHold(c, args.area) : writeHold(c, args.area, { owner: args.owner, unblock: args.unblock, next: args.next });
|
||||
default: throw new FleetError(`unknown tool ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user