Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/cli/src/generated/signals/block-archive.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// @generated by kern v3.5.7 — DO NOT EDIT. Source: src/kern/signals/block-archive.kern
// @generated by kern v3.5.8 — DO NOT EDIT. Source: src/kern/signals/block-archive.kern

import { appendFileSync, mkdirSync } from 'node:fs';

Expand Down Expand Up @@ -48,3 +48,14 @@ export function appendBlockWithCap(prev: OutputBlock[], block: OutputBlock, arch
archiveBlocks(archivePath, next.slice(0, overflow));
return next.slice(overflow);
}

/**
* Next <Static> remount epoch. Only a true transcript reset advances it; append and cap-spill leave it untouched (a spill must NOT repaint the sealed Static region).
*/
// @kern-source: block-archive:53
export function nextStaticEpoch(currentEpoch: number, cause: 'append'|'spill'|'reset'): number {
if (cause === 'reset') {
return currentEpoch + 1;
}
return currentEpoch;
}
333 changes: 333 additions & 0 deletions packages/cli/src/generated/signals/runs-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,333 @@
// @generated by kern v3.5.8 — DO NOT EDIT. Source: src/kern/signals/runs-store.kern

import { readFileSync, writeFileSync } from 'node:fs';

import { readdir, stat, unlink } from 'node:fs/promises';

import { join } from 'node:path';

import { agonPath } from '@kernlang/agon-core';

/**
* Keep every run file newer than this regardless of count (7 days).
*/
// @kern-source: runs-store:25
export const RUN_KEEP_AGE_MS: number = 7 * 24 * 60 * 60 * 1000;

/**
* Beyond the age window, keep at most this many newest run files.
*/
// @kern-source: runs-store:28
export const RUN_SOFT_CAP: number = 2000;

/**
* Absolute ceiling on total run files kept, even within the age window.
*/
// @kern-source: runs-store:31
export const RUN_HARD_CAP: number = 5000;

/**
* Never delete a run file younger than this — it may belong to an in-flight run (10 minutes). Long-running orchestrations (forge/goal/conquer) can keep a run's record 'open' for many minutes, so a generous floor is the primary in-flight protection; activeRunId matching is defense in depth.
*/
// @kern-source: runs-store:34
export const RUN_PROTECT_MIN_AGE_MS: number = 10 * 60 * 1000;

/**
* Skip a prune if one completed within this window (1h).
*/
// @kern-source: runs-store:37
export const RUN_PRUNE_COOLDOWN_MS: number = 60 * 60 * 1000;

/**
* Delete this many files then yield to the event loop, so prune never monopolizes the loop.
*/
// @kern-source: runs-store:40
export const RUN_PRUNE_CHUNK: number = 200;

/**
* Filename inside the runs dir holding the last-prune timestamp (also a poor-man's lock).
*/
// @kern-source: runs-store:43
export const PRUNE_STAMP_NAME: string = '.prune-stamp';

// @kern-source: runs-store:48
export interface RunsSnapshot {
count: number;
hydratedAt: number;
}

// @kern-source: runs-store:54
export interface PruneResult {
deleted: number;
skipped: boolean;
reason: string;
}

// @kern-source: runs-store:60
export interface PruneOptions {
activeRunIds?: string[];
force?: boolean;
}

// @kern-source: runs-store:68
function runsDirPath(): string {
return agonPath('runs');
}

// @kern-source: runs-store:70
function pruneStampPath(): string {
return join(agonPath('runs'), PRUNE_STAMP_NAME);
}

/**
* List *.json run record files with mtimes via node:fs/promises (never blocks the Ink event loop, even on a 40k-entry dir). Returns [] if the dir is missing. Skips the .prune-stamp and any entry that cannot be stat'd.
*/
// @kern-source: runs-store:72
async function listRunJsonFiles(): Promise<{ name: string; mtimeMs: number }[]> {
const dir = runsDirPath();
let names: string[];
try {
names = await readdir(dir);
} catch {
return [];
}
const out: { name: string; mtimeMs: number }[] = [];
for (const entry of names) {
if (!entry.endsWith('.json')) continue;
try {
const st = await stat(join(dir, entry));
if (st.isFile()) out.push({ name: entry, mtimeMs: st.mtimeMs });
} catch {
// entry vanished mid-scan or is unreadable — ignore
}
}
return out;
}

/**
* Count *.json run record files WITHOUT stat'ing each one. hydrate()/scheduleRefresh only need the count, so on a 40k-file dir this avoids ~40k stat syscalls (one readdir is enough). Returns 0 if the dir is missing. The .prune-stamp has no .json suffix so it's excluded automatically.
*/
// @kern-source: runs-store:95
async function countRunJsonFiles(): Promise<number> {
const dir = runsDirPath();
let names: string[];
try {
names = await readdir(dir);
} catch {
return 0;
}
let count = 0;
for (const entry of names) {
if (entry.endsWith('.json')) count++;
}
return count;
}

/**
* Read the last-prune timestamp from .prune-stamp. Returns 0 if absent/unreadable.
*/
// @kern-source: runs-store:112
function readPruneStamp(): number {
try {
const raw = readFileSync(pruneStampPath(), 'utf-8').trim();
const n = Number(raw);
return Number.isFinite(n) ? n : 0;
} catch {
return 0;
}
}

/**
* Persist the last-prune timestamp (best effort).
*/
// @kern-source: runs-store:124
function writePruneStamp(ts: number): void {
try {
writeFileSync(pruneStampPath(), String(ts));
} catch {
// best effort — a missing stamp just means the next prune isn't rate-limited
}
}

/**
* Pure prune-policy core: given the current run files + clock + protected ids, return the file names to delete (oldest-first). Keep everything < 7 days; beyond that keep newest RUN_SOFT_CAP; hard cap RUN_HARD_CAP total. Never returns an active-id file or one younger than RUN_PROTECT_MIN_AGE_MS. Exported so the policy is unit-testable without touching the fs.
*/
// @kern-source: runs-store:134
export function computeRunPruneTargets(files: { name: string; mtimeMs: number }[], now: number, activeRunIds: string[]): string[] {
// Exact id match, NOT substring: a run id must equal the file's basename
// (`${id}.json`) or be a prefix followed by a non-alphanumeric delimiter
// (e.g. `${id}-meta.json`). Substring matching would over-protect — id "1"
// would shield "1234.json" — and (worse) could be tricked into UNDER-deleting.
//
// Structural guarantee (why id matching is only defense in depth): deletion
// is oldest-first under a 7-day age window + newest-N caps, and an active
// run's record is necessarily recent, so it's effectively unreachable by the
// policy regardless of this check. The RUN_PROTECT_MIN_AGE_MS floor already
// shields every fresh file; activeRunId matching just hardens the edge.
const isActive = (fileName: string): boolean =>
activeRunIds.some((id) => {
if (!id) return false;
if (fileName === `${id}.json`) return true;
if (!fileName.startsWith(id)) return false;
const next = fileName.charAt(id.length);
// Prefix must end at a delimiter, never mid-token (so "1" never matches "12.json").
return next !== '' && !/[A-Za-z0-9]/.test(next);
});

// Files we are even ALLOWED to delete: not active, and old enough that they
// can't belong to an in-flight run.
const deletable = files
.filter((f) => !isActive(f.name) && now - f.mtimeMs >= RUN_PROTECT_MIN_AGE_MS)
.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first

const cutoff = now - RUN_KEEP_AGE_MS;
const deletableOld = deletable.filter((f) => f.mtimeMs < cutoff); // older than 7d

const removalSet = new Map<string, true>();

// 1) Soft cap: keep only the newest RUN_SOFT_CAP files overall. The overflow
// must come from OLD (>7d) deletable files first — files inside the 7d
// window are kept regardless of the soft cap.
const softOverflow = Math.max(0, files.length - RUN_SOFT_CAP);
const softRemoveCount = Math.min(softOverflow, deletableOld.length);
for (let k = 0; k < softRemoveCount; k++) {
removalSet.set(deletableOld[k].name, true);
}

// 2) Hard cap: total kept must end ≤ RUN_HARD_CAP. Remove additional oldest
// deletable files (young or old) until the total fits.
const mustRemoveForHardCap = Math.max(0, files.length - RUN_HARD_CAP);
let i = 0;
while (removalSet.size < mustRemoveForHardCap && i < deletable.length) {
removalSet.set(deletable[i].name, true);
i++;
}

// Return oldest-first, matching the deletable ordering.
return deletable.filter((f) => removalSet.has(f.name)).map((f) => f.name);
}

/**
* Owns ~/.agon/runs metadata so the render path never scans the fs. Caches a snapshot, refreshes on demand (debounced), and auto-prunes off the critical path.
*/
// @kern-source: runs-store:192
export class RunsStore {
private snap: RunsSnapshot;
private refreshTimer: ReturnType<typeof setTimeout>|null;
private pruning: boolean;

constructor() {
this.snap = { count: 0, hydratedAt: 0 };
this.refreshTimer = null;
this.pruning = false;
}

snapshot(): RunsSnapshot {
return this.snap;
}

runCount(): number {
return this.snap.count;
}

async hydrate(): Promise<RunsSnapshot> {
// Yield to the macrotask queue first so a startup hydrate never lands in
// the same tick as first paint.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
// Count-only path: hydrate just needs the file count, so skip the per-file
// stat() that listRunJsonFiles does (mtime is only needed by the prune).
const count = await countRunJsonFiles();
this.snap = { count, hydratedAt: Date.now() };
return this.snap;
}

scheduleRefresh(delayMs?: number): void {
const delay = typeof delayMs === 'number' && delayMs >= 0 ? delayMs : 750;
if (this.refreshTimer) clearTimeout(this.refreshTimer);
const timer = setTimeout(() => {
this.refreshTimer = null;
// Fire and forget — errors are non-critical (stale count at worst).
void this.hydrate().catch(() => { /* snapshot stays as-is */ });
}, delay);
this.refreshTimer = timer;
// Never keep the process alive just to refresh a count.
if (typeof (timer as any).unref === 'function') (timer as any).unref();
}

async maybePrune(opts?: PruneOptions): Promise<PruneResult> {
// In-process overlap guard FIRST, before any stamp/cooldown logic. Two
// same-process callers (e.g. the boot effect racing a scheduleRefresh) both
// read the stamp in the same tick; claiming `this.pruning` here — not after
// the cooldown check — is what makes the second caller bail deterministically.
if (this.pruning) {
return { deleted: 0, skipped: true, reason: 'in-progress' };
}
this.pruning = true;
try {
const force = opts?.force === true;
const now = Date.now();
const lastPrune = readPruneStamp();

// Cooldown + soft cross-process lock: a recent stamp means a prune ran (or
// is running) within the cooldown window — skip. The stamp doubles as the
// lock because maybePrune() claims it (writes "now") before any deletion.
if (!force && lastPrune > 0 && now - lastPrune < RUN_PRUNE_COOLDOWN_MS) {
return { deleted: 0, skipped: true, reason: 'cooldown' };
}

// Claim the cross-process lock by stamping "now" BEFORE scanning/deleting.
// This is deliberate (do not move it after the delete): the stamp is the
// lock claim a concurrent PROCESS reads to back off, and the cooldown
// limits SCAN cost — so even a no-op scan should start the cooldown.
// Crash-mid-prune costs at most a 1h delay before the next attempt; that's
// strictly cheaper than letting N processes scan a 40k-file dir in lockstep.
writePruneStamp(now);

const files = await listRunJsonFiles();
const activeIds = Array.isArray(opts?.activeRunIds) ? opts!.activeRunIds! : [];
const toDelete = computeRunPruneTargets(files, now, activeIds);

if (toDelete.length === 0) {
// Nothing to remove — refresh the snapshot and bail quietly.
this.snap = { count: files.length, hydratedAt: now };
return { deleted: 0, skipped: true, reason: 'within-policy' };
}

const dir = runsDirPath();
let deleted = 0;
for (let idx = 0; idx < toDelete.length; idx++) {
try {
await unlink(join(dir, toDelete[idx]));
deleted++;
} catch {
// file vanished or unremovable — skip
}
// Yield every RUN_PRUNE_CHUNK deletions so we never block the loop.
if ((idx + 1) % RUN_PRUNE_CHUNK === 0) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
}

// Re-stamp completion time and refresh the cached snapshot. Count-only:
// we just need the post-prune total, not mtimes.
const finishedAt = Date.now();
writePruneStamp(finishedAt);
const remaining = await countRunJsonFiles();
this.snap = { count: remaining, hydratedAt: finishedAt };

if (deleted > 0 && process.env.AGON_DEBUG) {
// Diagnostic only under AGON_DEBUG — a background prune must never write into the live TUI.
console.error(`[agon] runs prune: removed ${deleted} old run record(s), kept ${remaining}`);
}
return { deleted, skipped: false, reason: 'pruned' };
} finally {
this.pruning = false;
}
}
}

/**
* Process-wide RunsStore singleton. Import this from render + telemetry; never construct another.
*/
// @kern-source: runs-store:319
export const runsStore = new RunsStore();
Loading