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
JavaScript
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