From d3d7f2047facf80a6927aefba44d1913427508fa Mon Sep 17 00:00:00 2001 From: elhoim Date: Mon, 3 Aug 2026 07:01:02 +0000 Subject: [PATCH 1/5] fix(pulse): stop hypotheses blocking the event loop execFileSync with a 120s timeout inside an HTTP handler blocks Bun's single thread; /healthz, the dashboard and hook validation all stall behind it, risking a supervisor SIGKILL. Same shape this release already applied to modules/bunker.ts. modules/upgrades.ts changes only because graduateHypothesis becomes async and its caller must await it. --- .../LIFEOS/PULSE/modules/hypotheses.ts | 36 ++++++++++++++----- .../install/LIFEOS/PULSE/modules/upgrades.ts | 2 +- 2 files changed, 28 insertions(+), 10 deletions(-) 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 Date: Mon, 3 Aug 2026 07:01:02 +0000 Subject: [PATCH 2/5] fix(pulse): drain stderr and bound the atlas insights spawn stderr was piped and never read with no timeout, so >64KB deadlocks the request and wedged children accumulate on every uncached poll. Matches the modules/work.ts fix from this same release. --- LifeOS/install/LIFEOS/PULSE/modules/atlas.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) 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 { From 44815ef4d4731096188eee9bbc2d545509788fcd Mon Sep 17 00:00:00 2001 From: elhoim Date: Mon, 3 Aug 2026 07:01:02 +0000 Subject: [PATCH 3/5] perf(pulse): parse the cron expression once per scan, not once per minute mostRecentOccurrence re-parsed on every one of up to 10,080 minute-steps; the parse, not the field test, was the whole cost. matchesCron's signature is unchanged. --- LifeOS/install/LIFEOS/PULSE/lib.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/LifeOS/install/LIFEOS/PULSE/lib.ts b/LifeOS/install/LIFEOS/PULSE/lib.ts index aa2f570356..af31df78d9 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 } From c7dbe940837acbc5660ad1707b15e80b4881acfc Mon Sep 17 00:00:00 2001 From: elhoim Date: Mon, 3 Aug 2026 07:01:02 +0000 Subject: [PATCH 4/5] fix(pulse): fail loud on an output target this build no longer handles telegram was removed from OutputTarget with no default branch, so a config upgraded from an older version keeps those jobs, sends nothing, and still reports the dispatch as a success. --- LifeOS/install/LIFEOS/PULSE/lib.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/LifeOS/install/LIFEOS/PULSE/lib.ts b/LifeOS/install/LIFEOS/PULSE/lib.ts index af31df78d9..24e788f3d0 100644 --- a/LifeOS/install/LIFEOS/PULSE/lib.ts +++ b/LifeOS/install/LIFEOS/PULSE/lib.ts @@ -478,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) }) From 8d92c6b4d7bcb8c68a4ed71fbb1cee8da653c481 Mon Sep 17 00:00:00 2001 From: elhoim Date: Mon, 3 Aug 2026 07:01:02 +0000 Subject: [PATCH 5/5] fix(pulse): stop the menubar rendering "NaNd" for a down sidecar The clamped value was used for tsMs but the raw NaN still reached agoFrom. --- LifeOS/install/LIFEOS/PULSE/modules/menubar.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/LifeOS/install/LIFEOS/PULSE/modules/menubar.ts b/LifeOS/install/LIFEOS/PULSE/modules/menubar.ts index 713b5e8299..0a8025edd4 100644 --- a/LifeOS/install/LIFEOS/PULSE/modules/menubar.ts +++ b/LifeOS/install/LIFEOS/PULSE/modules/menubar.ts @@ -343,12 +343,15 @@ async function buildPayload(): 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, })