diff --git a/LifeOS/install/LIFEOS/PULSE/lib.ts b/LifeOS/install/LIFEOS/PULSE/lib.ts index aa2f570356..24e788f3d0 100644 --- a/LifeOS/install/LIFEOS/PULSE/lib.ts +++ b/LifeOS/install/LIFEOS/PULSE/lib.ts @@ -327,29 +327,40 @@ export function validateCron(expression: string): string | null { } } -export function matchesCron(expression: string, date: Date): boolean { - let fields: CronField[] +function parseCronOrThrow(expression: string): CronField[] { try { - fields = parseCron(expression) + return parseCron(expression) } catch (err) { throw new Error(`Invalid cron "${expression}": ${err instanceof Error ? err.message : String(err)}`) } +} +function matchesFields(fields: CronField[], date: Date): boolean { const actuals = [date.getMinutes(), date.getHours(), date.getDate(), date.getMonth() + 1, date.getDay()] return fields.every((f, i) => f.type === "any" || f.values.includes(actuals[i])) } +export function matchesCron(expression: string, date: Date): boolean { + return matchesFields(parseCronOrThrow(expression), date) +} + /** * Most recent minute at/before `now` matching the schedule, bounded by * `lookbackMs`. Minute-resolution backward scan — cron fields are cheap to * test and the bound keeps the worst case (~10k iterations at 7 days) trivial. + * + * The expression is parsed ONCE, outside the loop: going through matchesCron + * re-parsed it on every one of the up-to-10,080 minute-steps, and the parse — + * not the field test — was the whole cost of a scan (~53ms per unmatched + * sparse job, paid every minute on the thread that serves the dashboard). */ export function mostRecentOccurrence(schedule: string, now: Date, lookbackMs: number): number | null { + const fields = parseCronOrThrow(schedule) const nowMinute = Math.floor(now.getTime() / 60_000) * 60_000 const floorMs = now.getTime() - lookbackMs for (let t = nowMinute; t >= floorMs; t -= 60_000) { - if (matchesCron(schedule, new Date(t))) return t + if (matchesFields(fields, new Date(t))) return t } return null } @@ -467,6 +478,14 @@ async function dispatchSingle(output: string, target: OutputTarget, jobName: str case "log": break + + // Fail loud on a target this build no longer handles. `output` is cast + // unchecked at config load, so a config carrying an older target (e.g. + // `telegram`, since removed from OutputTarget) fell through the switch, + // sent nothing, and still reported the dispatch as a success. + default: + log("error", `Dispatch skipped for ${jobName}: unknown output target "${String(target)}" — valid targets are voice, ntfy, email, log`) + break } } catch (err) { log("error", `Dispatch to ${target} failed for ${jobName}`, { error: String(err) }) diff --git a/LifeOS/install/LIFEOS/PULSE/modules/atlas.ts b/LifeOS/install/LIFEOS/PULSE/modules/atlas.ts index 475d6c6b31..e9d694fac1 100644 --- a/LifeOS/install/LIFEOS/PULSE/modules/atlas.ts +++ b/LifeOS/install/LIFEOS/PULSE/modules/atlas.ts @@ -44,10 +44,26 @@ export function health() { // ── Metrics + inference ─────────────────────────────────────────────── +const METRICS_TIMEOUT_MS = 30_000; + async function metrics(): Promise | null> { const proc = Bun.spawn(["bun", ATLAS_CLI, "insights"], { stdout: "pipe", stderr: "pipe" }); - const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); - if (code !== 0 || !out.trim()) return null; + // Drain stdout AND stderr concurrently, under a kill timer (same class as + // modules/work.ts): stderr was piped but never read, so a chatty `atlas + // insights` failure fills the OS pipe buffer, the child blocks on the write, + // and with no timeout the request hung forever while wedged children piled + // up on every uncached poll. + const timer = setTimeout(() => { try { proc.kill(); } catch { /* already gone */ } }, METRICS_TIMEOUT_MS); + const [out, err, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + clearTimeout(timer); + if (code !== 0 || !out.trim()) { + console.log(`[${MODULE_NAME}] metrics failed (exit ${code}): ${err.slice(0, 200)}`); + return null; + } try { return JSON.parse(out); } catch { diff --git a/LifeOS/install/LIFEOS/PULSE/modules/hypotheses.ts b/LifeOS/install/LIFEOS/PULSE/modules/hypotheses.ts index 71684044d0..5d5a9ea0d8 100644 --- a/LifeOS/install/LIFEOS/PULSE/modules/hypotheses.ts +++ b/LifeOS/install/LIFEOS/PULSE/modules/hypotheses.ts @@ -33,7 +33,6 @@ for (const __k of ["LIFEOS_DIR", "LIFEOS_CONFIG_DIR", "PROJECTS_DIR"]) { import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync, mkdirSync } from "fs"; import { join } from "path"; -import { execFileSync } from "child_process"; // Normalize env path vars that Claude Code injects without shell expansion (LifeOS#1404) for (const k of ["LIFEOS_DIR", "LIFEOS_CONFIG_DIR", "PROJECTS_DIR"]) { @@ -171,19 +170,38 @@ function loadHypothesis(filename: string): Hypothesis | null { // fixture stays pending (the fix hasn't been done yet). The HTTP response // carries the truth. -function runPromoteFixture(pendingSlug: string): { promoted: boolean; detail: string } { +// ASYNC, never sync (same class as modules/bunker.ts): execFileSync parked +// Pulse's single-threaded event loop for the whole PromoteFixture run — up to +// the full 120s timeout — so /healthz, the dashboard and hook validation all +// stalled behind one graduate POST, long enough for the supervisor to SIGKILL +// the daemon. The kill timer bounds the child exactly as the old `timeout` +// option did; draining stdout AND stderr concurrently avoids the held-pipe +// deadlock stdio: "pipe" would otherwise reintroduce. +const PROMOTE_TIMEOUT_MS = 120_000; + +async function runPromoteFixture(pendingSlug: string): Promise<{ promoted: boolean; detail: string }> { const env = { ...process.env } as Record; delete env.ANTHROPIC_API_KEY; delete env.ANTHROPIC_AUTH_TOKEN; delete env.CLAUDECODE; try { - const out = execFileSync("bun", [join(LIFEOS_DIR, "TOOLS", "PromoteFixture.ts"), pendingSlug], { - encoding: "utf-8", timeout: 120_000, stdio: ["ignore", "pipe", "pipe"], env: env as NodeJS.ProcessEnv, + const proc = Bun.spawn(["bun", join(LIFEOS_DIR, "TOOLS", "PromoteFixture.ts"), pendingSlug], { + stdin: "ignore", stdout: "pipe", stderr: "pipe", env, }); + const timer = setTimeout(() => { try { proc.kill(); } catch { /* already gone */ } }, PROMOTE_TIMEOUT_MS); + const [out, errTxt, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + clearTimeout(timer); + if (code !== 0) { + const detail = (out + errTxt).trim().split("\n").slice(0, 3).join(" · "); + return { promoted: false, detail: detail || `PromoteFixture exited ${code}` }; + } return { promoted: true, detail: out.trim().split("\n")[0] ?? "promoted" }; } catch (e: any) { - const detail = ((e.stdout || "") + (e.stderr || "")).toString().trim().split("\n").slice(0, 3).join(" · "); - return { promoted: false, detail: detail || String(e.message ?? e) }; + return { promoted: false, detail: String(e?.message ?? e) }; } } @@ -257,12 +275,12 @@ function graduateToFrame(slug: string, target_frame: string, claim: string): voi // ── Exported actions (consumed by modules/upgrades.ts — the unified queue) ── -export function graduateHypothesis(slug: string, note?: string): { ok: boolean; detail?: string; reason?: string } { +export async function graduateHypothesis(slug: string, note?: string): Promise<{ ok: boolean; detail?: string; reason?: string }> { const items = listPending(); const h = items.find(x => x.slug === slug); if (!h) return { ok: false, reason: "not_found" }; if (h.has_patch && h.pending_slug && h.enforcement_surface !== "context") { - const promo = runPromoteFixture(h.pending_slug); + const promo = await runPromoteFixture(h.pending_slug); if (!promo.promoted) return { ok: false, reason: "patch_still_red", detail: promo.detail }; const result = archiveHypothesis(slug, "graduated", note); return result.ok ? { ok: true, detail: promo.detail } : { ok: false, reason: result.reason }; @@ -342,7 +360,7 @@ export async function handleRequest(req: Request, pathname: string): Promise { if (h.status === "down" || h.status === "flapping") { // Stamped from the sidecar's own last state write, never Date.now() — // a poll-time stamp re-badges every 5s and the unseen count never clears. - const ts = h.stateUpdatedAt ? Date.parse(h.stateUpdatedAt) : startOfTodayMs() + // Clamp once, then use the clamped value for BOTH fields: the raw + // Date.parse NaN used to reach agoFrom and render "NaNd". + const parsed = h.stateUpdatedAt ? Date.parse(h.stateUpdatedAt) : startOfTodayMs() + const ts = Number.isFinite(parsed) ? parsed : startOfTodayMs() feed.push({ subsystem: "hermes", glyph: "⚠", title: `Hermes sidecar ${h.status} — ${h.summary}`, - tsMs: Number.isFinite(ts) ? ts : startOfTodayMs(), + tsMs: ts, ago: agoFrom(ts), actionable: true, }) diff --git a/LifeOS/install/LIFEOS/PULSE/modules/upgrades.ts b/LifeOS/install/LIFEOS/PULSE/modules/upgrades.ts index 6458a5912c..fd4a462fc7 100644 --- a/LifeOS/install/LIFEOS/PULSE/modules/upgrades.ts +++ b/LifeOS/install/LIFEOS/PULSE/modules/upgrades.ts @@ -146,7 +146,7 @@ export async function handleRequest(req: Request, pathname: string): Promise