UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

97 lines 4.32 kB
import { errorMessage } from './error-message.js'; /** How often the clock fires. The finest cadence any job asks for; everything else is a multiple. */ export const DAEMON_TICK_MS = 30_000; /** Ticks between a job's turns, defaulted and floored. */ function cadence(job) { return Math.max(1, job.every ?? 1); } /** * Start the clock and return the handle that stops it. * * A job that throws costs its own turn and nothing else: the others in the same tick still run, * and the failure is logged once with the job's name, because a sweep failing silently is * indistinguishable from one that is not scheduled at all. * * Overlapping ticks join the one already running rather than being dropped, so awaiting `tick()` * means the tick finished — which is what lets a test drive this deterministically. The timer is * unref'd: background work is never the reason the process stays up. */ export function startDaemonTick(opts) { let stopped = false; /** * Which tick is running now. Advanced by {@link elapsedWhileBusy} as well as by one, so it * stays a count of how many times the interval has *come round* rather than how many turns * happened to run (#1607). */ let tickNow = -1; /** * Interval firings that arrived while a turn was in flight. Time passed for them, so they count * towards every cadence — the turn they would each have had is folded into the next one. */ let elapsedWhileBusy = 0; let inflight; /** * The tick each job last took a turn on, seeded so tick 0 is already due for a job that wants * the start-up turn and one whole cadence away for a job that sits it out. */ const lastTurn = opts.jobs.map(job => (job.onStart === false ? 0 : -cadence(job))); const runTick = async (n) => { for (const [index, job] of opts.jobs.entries()) { if (stopped) return; // Ticks *since this job's own last turn*, rather than `n % every`: when the clock jumps // over the tick a job's cadence would have landed on, the job is due at the next turn // instead of waiting a whole further cadence for the modulo to come round again. if (n - lastTurn[index] < cadence(job)) continue; // Claimed before the run, not after: a missed turn is skipped rather than queued, and a job // that throws has still had its turn. lastTurn[index] = n; try { await job.run(); } catch (err) { opts.log(`[framework] ${job.name} failed this tick: ${errorMessage(err)}`); } } }; const tick = () => { if (stopped) return Promise.resolve(); // A caller that arrives mid-turn joins it without moving the clock: `tick()` means "run a // turn now" — the daemon's shutdown and the tests drive it, and neither is elapsed time. // Only the interval is, and it says so through `elapsedWhileBusy`. if (inflight) return inflight; tickNow += 1 + elapsedWhileBusy; elapsedWhileBusy = 0; inflight = runTick(tickNow).finally(() => { inflight = undefined; }); return inflight; }; void tick(); const timer = setInterval(() => { // The clock moved whether or not a turn can start. Counting a firing that lands on a busy // daemon is the whole point: without it a long job — a data sync failing slowly against an // unreachable remote — stretched every `every: N` cadence by its own duration, because a // cadence was measured in turns that ran rather than in time that passed (#1607). if (inflight) { elapsedWhileBusy++; return; } void tick(); }, opts.intervalMs ?? DAEMON_TICK_MS); timer.unref?.(); return { tick, stop: async () => { stopped = true; clearInterval(timer); // The turn in flight stops at the next job boundary, but the job it is inside runs to the // end — so wait it out rather than leaving a sweep mid-commit. await inflight?.catch(() => { }); }, }; } //# sourceMappingURL=daemon-tick.js.map