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.

291 lines 14.3 kB
import { join } from 'node:path'; import { nodeGitRunner } from './project.js'; import { ghPrsForBranch } from './dashboard/gh.js'; import { startedAtFromAgentId, FRAMEWORK_DIR, AGENT_BRANCH_PREFIX, LEGACY_AGENT_BRANCH_PREFIX } from './store/index.js'; import { nodeFs } from './node-fs.js'; import { errorMessage } from './error-message.js'; // Sweep the scratch refs a cloud hand-off leaves on origin (#1547). // // Every "Run on: Claude web" run pushes two refs that nothing ever consumes again: the slash-free // `cloud-*` ref the driver pushes so the cloud session has a ref it can clone at (#1320, // anthropics/claude-code#87235), and the run branch (`tf-agent-…`) the worktree sweep // (#1036) pushes before reclaiming the checkout. The session does its work on its own `claude/*` // branch and opens its PR from there, so once provisioning settles both refs are dead names on // origin — one pair per web run, accumulating forever. // // The driver cannot safely delete its own ref: session creation only signals "session created", // not "clone finished", and a ref deleted in that window strands the session (recoverable only // with `claude --teleport`). So the daemon sweeps instead, and every deletion has to clear four // gates: // // - **Old enough** (~a day) that no provisioning can still be reading it. A run branch carries its // start time in its name; a `cloud-*` ref carries nothing, so the sweep remembers when it first // saw one and only ages it from there — which also keeps a ref pushed by *another* machine safe, // since each machine only deletes what it has itself watched for a day. // - **Holds no work**: its tip is already reachable from origin's default branch. That is what // separates a web run's empty scratch branch from a local run's branch holding unmerged commits — // the one thing this sweep must never delete. // - **No open PR**, so a deletion can never close one. // - **Not a live agent's**, the same `busy` guard the worktree sweep takes. // // Never throws, and conservative on every unprovable case: a ref it cannot prove dead simply // stays, and the next sweep asks again. /** How long a scratch ref is left alone before it may go: safely past any provisioning. */ export const SCRATCH_REF_SAFE_AGE_MS = 24 * 60 * 60 * 1000; /** * The shape of the pre-hand-off ref the cloud driver pushes: its own session id, a counter plus * the 8-hex tag (`cloud-1-3955352b`). Anchored tightly so a user's own `cloud-…` branch that does * not match the driver's naming is never even a candidate. */ export const CLOUD_SCRATCH_REF = /^cloud-\d+-[0-9a-f]{8}$/; /** * What run branches are named under; the rest is the agent id, which carries the start time. The * legacy slashed spelling (pre-#1581) is still swept — branches under it remain on remotes until * this sweep ages them out, but nothing mints it anymore. */ const RUN_BRANCH_PREFIXES = [`${AGENT_BRANCH_PREFIX}agent-`, `${LEGACY_AGENT_BRANCH_PREFIX}agent-`]; /** * Where the sweep remembers when it first saw each `cloud-*` ref, under `.the-framework/` * (gitignored, like the other per-repo bookkeeping). Needed because the ref's name carries no * timestamp and its commit date says nothing — the driver pushes the worktree's HEAD, which is * however old the base commit happens to be, not when the hand-off happened. */ export const CLOUD_REFS_FILE = 'cloud-refs.json'; /** A {@link ScratchFs} backed by `node:fs/promises`. See {@link nodeFs}. */ function nodeScratchFs() { const { read, write, mkdir } = nodeFs(); return { read, write, mkdir }; } /** The first-seen state file path for a repo. */ export function cloudRefsStatePath(cwd) { return join(cwd, FRAMEWORK_DIR, CLOUD_REFS_FILE); } /** Read a repo's first-seen state. Forgiving: missing/unreadable/malformed yields an empty one. */ async function readState(cwd, fs) { try { const parsed = JSON.parse(await fs.read(cloudRefsStatePath(cwd))); if (parsed && typeof parsed === 'object') { const record = parsed['firstSeen']; if (record && typeof record === 'object') { const firstSeen = {}; for (const [ref, at] of Object.entries(record)) { if (typeof at === 'string') firstSeen[ref] = at; } return { firstSeen }; } } } catch { // absent / unreadable / malformed -> nothing seen yet } return { firstSeen: {} }; } /** Record a repo's first-seen state, creating `.the-framework/` as needed. */ async function writeState(cwd, state, fs) { await fs.mkdir(join(cwd, FRAMEWORK_DIR)); await fs.write(cloudRefsStatePath(cwd), JSON.stringify(state, null, 2)); } /** * Parse `git ls-remote --symref origin HEAD 'refs/heads/*'`: the branch HEAD points at (the * default branch) plus every branch with its tip. One listing answers everything the * classification needs, in one network round-trip. */ function parseLsRemote(listing) { const heads = []; let defaultBranch; for (const line of listing.split('\n')) { const symref = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/.exec(line); if (symref) { defaultBranch = symref[1]; continue; } const head = /^([0-9a-f]{40,64})\t+refs\/heads\/(.+)$/.exec(line); if (head) heads.push({ ref: head[2], sha: head[1] }); } // A remote that answers no symref (some mirrors) still usually has a conventional default. if (!defaultBranch) defaultBranch = ['main', 'master'].find(name => heads.some(h => h.ref === name)); return { ...(defaultBranch !== undefined ? { defaultBranch } : {}), heads }; } /** * Whether `sha` is already reachable from the default branch — the proof the ref holds no work. * Tried against the remote's own tip first (the freshest answer, when its object is local because * this machine pushed or fetched it), then against the local `origin/<default>` tracking ref * (stale is fine: reachable from a stale tip is reachable from a newer one). Unprovable — objects * missing, no default branch at all — reads as "not landed", which keeps the ref. */ async function landed(git, cwd, sha, defaultBranch, heads) { if (defaultBranch === undefined) return false; const remoteTip = heads.find(h => h.ref === defaultBranch)?.sha; for (const tip of [remoteTip, `refs/remotes/origin/${defaultBranch}`]) { if (!tip) continue; const ancestor = await git(['merge-base', '--is-ancestor', sha, tip], cwd).then(() => true, () => false); if (ancestor) return true; } return false; } /** * Whether `sha` is an empty commit sitting on a landed parent — the hand-off anchor's shape * (#1601): its tree is its parent's tree, so it holds no work of its own, and the parent being * on the default branch means everything under it landed. The commit object may not be local * (another machine pushed the ref), so the ref is fetched first when needed; anything still * unprovable reads as "holds work", which keeps the ref for a later sweep. */ async function emptyTipOnLandedParent(git, cwd, ref, sha, defaultBranch, heads) { const present = await git(['cat-file', '-e', `${sha}^{commit}`], cwd).then(() => true, () => false); if (!present && !(await git(['fetch', 'origin', `refs/heads/${ref}`], cwd).then(() => true, () => false))) return false; try { const tree = (await git(['rev-parse', `${sha}^{tree}`], cwd)).trim(); const parent = (await git(['rev-parse', `${sha}^`], cwd)).trim(); const parentTree = (await git(['rev-parse', `${sha}^^{tree}`], cwd)).trim(); if (!tree || !parent || tree !== parentTree) return false; return await landed(git, cwd, parent, defaultBranch, heads); } catch { return false; } } /** * Sweep one repo's origin for the dead refs cloud hand-offs left behind (#1547), deleting the * ones that clear every gate. Never throws: a repo with no remote (or offline) sweeps nothing, * and a failed deletion is reported and retried next sweep. */ export async function sweepCloudScratchRefs(cwd, deps = {}) { const git = deps.git ?? nodeGitRunner(); const prs = deps.prs ?? ghPrsForBranch; const fs = deps.fs ?? nodeScratchFs(); const now = deps.now ? deps.now() : Date.now(); const ageMs = deps.ageMs ?? SCRATCH_REF_SAFE_AGE_MS; const result = { deleted: [], kept: [], failed: [] }; let listing; try { listing = await git(['ls-remote', '--symref', 'origin', 'HEAD', 'refs/heads/*'], cwd); } catch { return result; // no remote, or it cannot be reached: nothing to sweep } const { defaultBranch, heads } = parseLsRemote(listing); const state = await readState(cwd, fs); // Rebuilt from what origin actually has, so entries for refs deleted (by us or anyone) fall away. const firstSeen = {}; const candidates = []; for (const head of heads) { if (CLOUD_SCRATCH_REF.test(head.ref)) { const seenAt = state.firstSeen[head.ref]; const seenMs = seenAt === undefined ? NaN : Date.parse(seenAt); if (!Number.isFinite(seenMs)) { // First sight (or a hand-edited timestamp): the day starts now. firstSeen[head.ref] = new Date(now).toISOString(); result.kept.push({ ref: head.ref, reason: 'young' }); continue; } firstSeen[head.ref] = seenAt; if (now - seenMs < ageMs) { result.kept.push({ ref: head.ref, reason: 'young' }); continue; } candidates.push(head); continue; } const runPrefix = RUN_BRANCH_PREFIXES.find(prefix => head.ref.startsWith(prefix)); if (runPrefix !== undefined) { const id = head.ref.slice(runPrefix.length); const startedAt = startedAtFromAgentId(id); if (startedAt === undefined) continue; // a name whose age is unknowable is not ours to delete if (deps.busy?.has(id)) { result.kept.push({ ref: head.ref, reason: 'busy' }); continue; } if (now - Date.parse(startedAt) < ageMs) { result.kept.push({ ref: head.ref, reason: 'young' }); continue; } candidates.push(head); } // Every other branch — the default, `claude/*`, `tf-<session-name>` — is not a // scratch ref and is never even considered. } for (const { ref, sha } of candidates) { // The work gate first: it is local and free, and it is the one that must never be wrong. // A tip the default branch never absorbs still clears it when it is a hand-off anchor // (#1601): an empty commit whose parent landed changes nothing, and no merge ever lands // the anchor itself — a squash merge rewrites the session's history without it. if (!(await landed(git, cwd, sha, defaultBranch, heads)) && !(await emptyTipOnLandedParent(git, cwd, ref, sha, defaultBranch, heads))) { result.kept.push({ ref, reason: 'holds-work' }); continue; } // ghPrsForBranch resolves [] when gh is missing/unauthed, so a hiccup here fails toward // deletion — acceptable only because the work gate already proved the ref holds nothing. const history = await prs(cwd, ref).catch(() => []); if (history.some(pr => pr.state === 'OPEN')) { result.kept.push({ ref, reason: 'open-pr' }); continue; } try { await git(['push', 'origin', '--delete', ref], cwd); result.deleted.push(ref); delete firstSeen[ref]; } catch (err) { // The first-seen entry stays, so the retry next sweep does not restart the day. result.failed.push({ ref, error: errorMessage(err) }); } } if (JSON.stringify(firstSeen) !== JSON.stringify(state.firstSeen)) { await writeState(cwd, { firstSeen }, fs).catch(() => { // Best effort: an unwritable state file only delays deletions, never loses work. }); } return result; } /** * Sweep every registered project's leftover cloud scratch refs (#1547), one turn per call. * * Deletions and failures are said out loud, kept refs are not: a candidate that is merely not old * enough yet is the normal state of every ref this watches, and a line per tick about it would be * noise. A ref vanishing from origin with no line explaining why would read as a bug. */ export function startCloudScratchSweep(opts) { const sweep = opts.sweep ?? ((cwd) => sweepCloudScratchRefs(cwd, { ...(opts.busy ? { busy: opts.busy() } : {}) })); let stopped = false; const sweepAll = async () => { for (const project of await opts.projects().catch(() => [])) { if (stopped) break; const { deleted, failed } = await sweep(project.path).catch(() => ({ deleted: [], kept: [], failed: [] })); for (const ref of deleted) { opts.log(`[framework] deleted the leftover cloud hand-off ref ${ref} on origin: its session settled long ago and nothing consumes it (#1547).`); } for (const item of failed) { opts.log(`[framework] could not delete the leftover ref ${item.ref} on origin: ${item.error}`); } } }; // Overlapping ticks join the sweep already running rather than being dropped, so awaiting // `tick()` means the sweep finished — same rule as the worktree sweep, for the same reason. let inflight; const tick = () => { if (stopped) return Promise.resolve(); inflight ??= sweepAll().finally(() => { inflight = undefined; }); return inflight; }; // No timer of its own (E4): the daemon's one clock calls `tick`. return { tick, stop: () => { stopped = true; }, }; } //# sourceMappingURL=cloud-scratch-refs.js.map