Skip to content
Closed
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
27 changes: 23 additions & 4 deletions LifeOS/install/LIFEOS/PULSE/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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) })
Expand Down
20 changes: 18 additions & 2 deletions LifeOS/install/LIFEOS/PULSE/modules/atlas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,26 @@ export function health() {

// ── Metrics + inference ───────────────────────────────────────────────

const METRICS_TIMEOUT_MS = 30_000;

async function metrics(): Promise<Record<string, unknown> | 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 {
Expand Down
36 changes: 27 additions & 9 deletions LifeOS/install/LIFEOS/PULSE/modules/hypotheses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]) {
Expand Down Expand Up @@ -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<string, string | undefined>;
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) };
}
}

Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -342,7 +360,7 @@ export async function handleRequest(req: Request, pathname: string): Promise<Res
// written; PromoteFixture just moves a data fixture into the corpus. A
// still-red fixture stays pending as a task.
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) {
const result = archiveHypothesis(slug, "graduated", note);
if (!result.ok) return jsonResponse({ error: result.reason }, 409);
Expand Down
7 changes: 5 additions & 2 deletions LifeOS/install/LIFEOS/PULSE/modules/menubar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,12 +343,15 @@ async function buildPayload(): Promise<MenuBarPayload> {
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,
})
Expand Down
2 changes: 1 addition & 1 deletion LifeOS/install/LIFEOS/PULSE/modules/upgrades.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export async function handleRequest(req: Request, pathname: string): Promise<Res

if (id.startsWith(HYP_PREFIX)) {
const slug = id.slice(HYP_PREFIX.length);
const res = verb === "accept" ? graduateHypothesis(slug, note) : rejectHypothesis(slug, note);
const res = verb === "accept" ? await graduateHypothesis(slug, note) : rejectHypothesis(slug, note);
return jsonResponse(res, res.ok ? 200 : res.reason === "not_found" ? 404 : 409);
}
const res = setStatus(id, verb === "accept" ? "accepted" : "rejected", { note });
Expand Down