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.

150 lines 8.45 kB
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { TICKETS_DIR } from './tickets.js'; import { withDataBranch } from './data-branch.js'; // The `.lock.md` claim on a ticket (#1420, replacing #1327's PENDING placeholders). // // A ticket is assigned to a single agent — for planning today, and in Rom's design for its whole // life, implementation included — and the guard cannot be the daemon's memory: a hands-off web // run's local process ends at the hand-off (#1253), another machine's daemon never shared this // one's memory at all, and the #1313 PR-diff claims only start once a PR exists. So the claim is // made where every agent already looks — a `tickets/<STEM>.lock.md` sibling holding // `CLAIMED: <AGENT_ID>`, the file the ticketing format defines (#1420), so the stock prompts skip // a locked ticket with no cooperation from anyone who has not heard of this module. // // Since #1582 the tickets live on the data branch, and a lock is one more data write through the // same funnel: `withDataBranch` syncs, commits the batch, and pushes the branch itself. That // retires this module's own commit/push machinery — locks reach every machine the way all data // does, and the old "the checkout must be on the default branch" dance is gone with the reason // for it. // // There is no timed release (#1420 dropped #1327's 6-hour staleness rule): a coordinator agent // can legitimately hold a ticket for days, and a lock released under a live agent re-opens the // exact double-work window it exists to close. The lock lifts when the ticket's work lands and // the tickets sync retires the files, when a human releases it ({@link releaseTicketLock}), or // when the daemon frees a claim it minted for an agent that settled with nothing to hand off // (the `heldBy` release, #1583) — every other dead agent is still the user's to notice, with the // dashboard button as the tool. /** The first line of a lock file: the claim, naming the agent that holds it. */ const TICKET_LOCK_PREFIX = 'CLAIMED:'; /** A ticket filename's lock sibling, e.g. `a.md` → `a.lock.md`, without the directory. */ export function ticketLockName(ticket) { return `${ticket.replace(/\.md$/, '')}.lock.md`; } /** What a lock file holds: the claim line and nothing else, matching the ticketing format. */ export function ticketLockContent(agentId) { return `${TICKET_LOCK_PREFIX} ${agentId}\n`; } /** The agent a lock file names, or undefined for content that is not a claim line. */ export function ticketLockHolder(md) { const line = md.trimStart(); if (!line.startsWith(TICKET_LOCK_PREFIX)) return undefined; const holder = line.slice(TICKET_LOCK_PREFIX.length).split('\n', 1)[0].trim(); return holder || undefined; } /** The commit a batch of locks lands as. Names the count so the history reads as what happened. */ export function lockMessage(count) { return `[The Framework] lock ${count} ticket${count === 1 ? '' : 's'} for concurrent agents`; } /** The commit a manual release lands as, naming the ticket freed. */ export function releaseMessage(ticket) { return `[The Framework] release the lock on ${ticket}`; } /** The commit an abandoned claim's release lands as (#1583), naming why the daemon freed it. */ export function abandonedReleaseMessage(ticket) { return `${releaseMessage(ticket)} — its agent ended with nothing to hand off`; } /** * Claim `assignments`' tickets for their agents: one `.lock.md` per ticket, written on the data * branch in one funneled cycle (#1582) — the funnel commits the batch and pushes the branch. The * cycle re-runs the checks against origin's state when a push loses a race, so a ticket whose * lock (or, for a `plan` batch, whose plan — see {@link TicketLockPhase}) appeared meanwhile is * skipped, not overwritten: an existing file is someone's claim or someone's work, and either * outranks this batch. * * Resolves the subset actually locked. A batch that could not land at all resolves `[]`; a batch * that committed but could not *push* is kept and resolved as locked — the commit still guards * every agent forked from this machine, which is the common case, and the sweep should not stand * a healthy local fan-out down over a network blip. The push is what closes the cross-machine * window (#1320), so its failure is logged rather than swallowed. * * Never throws: this runs on a background tick with nothing to catch it. */ export async function acquireTicketLocks(cwd, assignments, deps = {}, phase = 'plan') { // Creating parents, because `tickets/` itself is not a given: retiring the last ticket removes // the directory (git keeps no empty dirs), and the branch is born without it. const write = deps.write ?? (async (path, content) => { await mkdir(dirname(path), { recursive: true }); await writeFile(path, content, 'utf8'); }); const read = deps.read ?? (path => readFile(path, 'utf8')); const funnel = deps.funnel ?? withDataBranch; const log = deps.log ?? (() => { }); let locked = []; const result = await funnel(cwd, () => lockMessage(locked.length), async (dataDir) => { locked = []; for (const assignment of assignments) { const stem = assignment.ticket.replace(/\.md$/, ''); const lock = `${TICKETS_DIR}/${stem}.lock.md`; const plan = `${TICKETS_DIR}/${stem}.plan.md`; // Any existing lock file outranks this batch — except one naming THIS agent, which is // the batch's own claim seen again on a re-run (the funnel re-runs the op when a push // loses a race, and the first attempt's commit survives the re-sync): still locked. const existing = await read(join(dataDir, lock)).then(md => ({ md }), () => undefined); if (existing) { if (ticketLockHolder(existing.md) === assignment.agentId) locked.push(assignment); continue; } if (phase === 'plan' && (await read(join(dataDir, plan)).then(() => true, () => false))) continue; await write(join(dataDir, lock), ticketLockContent(assignment.agentId)); locked.push(assignment); } }, { log }); if (!result.ok && !result.committed) { // Said even for an empty batch: a cycle that failed before any lock landed is the sweep's // real stand-down reason, and swallowing it left "no claims" indistinguishable from "lost // every race". log(`[framework] ticket locks: the batch could not be committed (${result.error})`); return []; } if (!result.ok) log(`[framework] ticket locks: the lock commit could not be pushed, so agents on other machines cannot see these ${locked.length} claim(s)`); return locked; } /** * Free one ticket's `.lock.md` by hand (#1420): the dashboard's answer to a dead agent, now that * no timer releases locks. One funneled data-branch cycle (#1582); a release that cannot land * reports `error` and changes nothing — the funnel restores the checkout, so the committed state * keeps telling the truth about the claim. */ export async function releaseTicketLock(cwd, ticket, deps = {}, opts = {}) { const read = deps.read ?? (path => readFile(path, 'utf8')); const remove = deps.remove ?? (path => rm(path)); const funnel = deps.funnel ?? withDataBranch; const log = deps.log ?? (() => { }); const lock = `${TICKETS_DIR}/${ticketLockName(ticket)}`; let outcome = 'released'; const result = await funnel(cwd, () => (opts.heldBy !== undefined ? abandonedReleaseMessage(ticket) : releaseMessage(ticket)), async (dataDir) => { outcome = 'released'; const md = await read(join(dataDir, lock)).catch(() => undefined); if (md === undefined) { outcome = 'no-lock'; return; } if (opts.heldBy !== undefined && ticketLockHolder(md) !== opts.heldBy) { outcome = 'not-holder'; return; } await remove(join(dataDir, lock)); }, { log }); if (!result.ok && !result.committed) return 'error'; if (!result.ok) log(`[framework] ticket locks: the release of ${ticket} could not be pushed`); return outcome; } //# sourceMappingURL=ticket-locks.js.map