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.
208 lines • 11.3 kB
JavaScript
import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { nodeGitRunner } from './project.js';
import { cachedOpenPrFilePatches } from './dashboard/gh.js';
import { TICKETS_DIR } from './tickets.js';
// The PENDING lock for concurrent spike agents (#1327).
//
// A batch of Spike & plan agents must not double-work a ticket, 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 — the ticket's own
// sibling files. Before an agent starts, its `.spike.md` and `.plan.md` are created as
// placeholders reading `PENDING:<AGENT_ID>`, committed in one batch and pushed to the default
// branch. A file that exists is a spike the stock prompt skips, so the lock needs no cooperation
// from anyone who has not heard of it.
//
// The daemon writes and pushes the locks, never the agent (#1320): a cloud session has no push
// access, and a lock that only existed inside the run it protects would protect nothing.
/** The first line of a placeholder sibling: the claim, naming the agent that holds it. */
export const SPIKE_LOCK_PREFIX = 'PENDING:';
/**
* How old a PENDING lock must be before it is presumed dead and released. Generous against the
* slowest live path — spiking and planning can take hours, on top of a cloud queue — because a
* lock released under a live agent re-opens the double-work window the lock exists to close,
* while a dead agent's ticket only waits one interval longer. This is a bug-recovery mechanism
* that ideally never fires (#1364 review), so erring long costs nearly nothing.
*/
export const SPIKE_LOCK_STALE_MS = 6 * 60 * 60 * 1000;
/** Whether a sibling's content is a PENDING placeholder rather than a real spike or plan. */
export function isSpikeLock(md) {
return md.trimStart().startsWith(SPIKE_LOCK_PREFIX);
}
/** What a lock file holds: the claim line and nothing else, so the check above stays trivial. */
export function spikeLockContent(agentId) {
return `${SPIKE_LOCK_PREFIX}${agentId}\n`;
}
/** 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 spike agents`;
}
/** The commit a release lands as. */
export function releaseMessage(count) {
return `[The Framework] release ${count} stale spike lock${count === 1 ? '' : 's'}`;
}
const defaultPrTouches = async (cwd, file) => {
const { value } = await cachedOpenPrFilePatches(cwd, file);
return value === undefined ? undefined : value.length > 0;
};
/**
* Push the just-made lock commit to origin's default branch — the branch other machines fork
* from, the only place a lock closes the cross-machine window (#1320, #1364 review). Pushed only
* when the checkout is *on* that branch: `HEAD:main` from anywhere else would carry the branch's
* own commits onto main, and the old `HEAD:<current branch>` published whatever branch the
* checkout happened to be on. Resolves false when the push was skipped for that reason; a failed
* push still throws, so each caller keeps its own log line.
*/
async function pushLockCommit(git, cwd) {
const current = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd)).trim();
// `origin/main` in almost every checkout; asked rather than assumed, with the assumption as
// the fallback for a clone whose origin/HEAD was never set.
const head = await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], cwd).then(out => out.trim(), () => 'origin/main');
const target = head.replace(/^origin\//, '');
if (current !== target)
return false;
await git(['push', 'origin', `HEAD:${target}`], cwd);
return true;
}
/** A ticket filename's lock paths, relative to the repo root. */
function lockPaths(ticket) {
const stem = ticket.replace(/\.md$/, '');
return {
spike: `${TICKETS_DIR}/${stem}.spike.md`,
plan: `${TICKETS_DIR}/${stem}.plan.md`,
};
}
/**
* Claim `assignments`' tickets for their agents: write both placeholder siblings per ticket,
* commit the whole batch in one pathspec-scoped commit, and push it to origin's default branch
* ({@link pushLockCommit}). Resolves the subset actually locked — a ticket whose sibling appeared since the candidates
* were enumerated is skipped, not overwritten: an existing file is someone's claim or someone's
* work, and either outranks this batch.
*
* A batch whose commit failed is rolled back (the written files removed) and resolves `[]`:
* uncommitted placeholders in the user's checkout would be noise git blames on nobody. A batch
* whose *push* failed is kept and resolved as locked — the commit still guards every run forked
* from this checkout, 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 acquireSpikeLocks(cwd, assignments, deps = {}) {
const git = deps.git ?? nodeGitRunner();
const write = deps.write ?? ((path, content) => writeFile(path, content, 'utf8'));
const read = deps.read ?? (path => readFile(path, 'utf8'));
const remove = deps.remove ?? (path => rm(path));
const log = deps.log ?? (() => { });
const locked = [];
const files = [];
try {
for (const assignment of assignments) {
const { spike, plan } = lockPaths(assignment.ticket);
// Existence via a read, so one seam serves both operations. Either sibling present — real
// or placeholder — means the ticket is not this batch's to claim.
const taken = await Promise.all([read(join(cwd, spike)).then(() => true, () => false), read(join(cwd, plan)).then(() => true, () => false)]);
if (taken.some(Boolean))
continue;
await write(join(cwd, spike), spikeLockContent(assignment.agentId));
await write(join(cwd, plan), spikeLockContent(assignment.agentId));
locked.push(assignment);
files.push(spike, plan);
}
if (!locked.length)
return [];
await git(['add', '--', ...files], cwd);
await git(['commit', '-m', lockMessage(locked.length), '--', ...files], cwd);
}
catch {
// The claim is the *commit*: files that never reached one claim nothing, so they are removed
// rather than left as uncommitted noise, and the sweep falls back to a single unpinned agent.
await Promise.all(files.map(file => remove(join(cwd, file)).catch(() => undefined)));
return [];
}
try {
if (!(await pushLockCommit(git, cwd)))
log(`[framework] spike locks: the checkout is not on the default branch, so the lock commit was not pushed — agents on other machines cannot see these ${locked.length} claim(s)`);
}
catch {
log(`[framework] spike locks: the lock commit could not be pushed, so agents on other machines cannot see these ${locked.length} claim(s)`);
}
return locked;
}
/**
* Release the PENDING locks whose agents are presumed dead, so a crashed spike does not brick its
* ticket forever. A lock is stale exactly when all three hold (#1327's rule): its content is still
* the placeholder, no open PR touches it (a finished agent's PR carries the real sibling — the
* #1313 claim takes over from here), and its last commit is older than {@link SPIKE_LOCK_STALE_MS}.
* An *uncommitted* placeholder is left alone: it is a batch being acquired right now, or a commit
* failure {@link acquireSpikeLocks} already rolled back.
*
* Deletions land as one pathspec-scoped commit, pushed best-effort like the acquisition.
* Resolves the released tickets' filenames. Never throws.
*/
export async function releaseStaleSpikeLocks(cwd, deps = {}) {
const git = deps.git ?? nodeGitRunner();
const read = deps.read ?? (path => readFile(path, 'utf8'));
const remove = deps.remove ?? (path => rm(path));
const list = deps.list ?? (dir => readdir(dir));
const prTouches = deps.prTouches ?? defaultPrTouches;
const now = deps.now ?? (() => Date.now());
const staleMs = deps.staleMs ?? SPIKE_LOCK_STALE_MS;
const log = deps.log ?? (() => { });
try {
const names = await list(join(cwd, TICKETS_DIR)).catch(() => []);
const released = [];
const files = [];
for (const name of names) {
if (!/\.spike\.md$/.test(name))
continue;
const ticket = `${name.replace(/\.spike\.md$/, '')}.md`;
const { spike, plan } = lockPaths(ticket);
const [spikeMd, planMd] = await Promise.all([
read(join(cwd, spike)).catch(() => undefined),
read(join(cwd, plan)).catch(() => undefined),
]);
const pending = [
...(spikeMd !== undefined && isSpikeLock(spikeMd) ? [spike] : []),
...(planMd !== undefined && isSpikeLock(planMd) ? [plan] : []),
];
if (!pending.length)
continue;
// The newest lock commit is the claim's age. An empty answer is an uncommitted placeholder:
// a batch mid-acquisition, which is the opposite of stale.
const stamps = await Promise.all(pending.map(file => git(['log', '-1', '--format=%ct', '--', file], cwd).then(out => out.trim(), () => '')));
if (stamps.some(stamp => !stamp))
continue;
const newest = Math.max(...stamps.map(stamp => Number(stamp) * 1000));
if (!Number.isFinite(newest) || now() - newest < staleMs)
continue;
// Asked last because it is the expensive read, and only about the pending files: a stem
// whose real spike rode in on a PR while the plan stayed PENDING keeps the plan too.
const touched = await Promise.all(pending.map(file => prTouches(cwd, file)));
if (touched.some(t => t !== false))
continue;
for (const file of pending)
await remove(join(cwd, file));
files.push(...pending);
released.push(ticket);
}
if (!released.length)
return [];
await git(['add', '--', ...files], cwd);
await git(['commit', '-m', releaseMessage(files.length), '--', ...files], cwd);
try {
if (!(await pushLockCommit(git, cwd)))
log(`[framework] spike locks: the checkout is not on the default branch, so the release commit was not pushed`);
}
catch {
log(`[framework] spike locks: the release commit could not be pushed`);
}
log(`[framework] spike locks: released ${released.length} stale lock(s): ${released.join(', ')}`);
return released;
}
catch {
return [];
}
}
//# sourceMappingURL=spike-locks.js.map