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.

100 lines 5.33 kB
import { listProjectWorktrees, removeProjectWorktree } from './worktrees.js'; import { withAgentLock } from './agent-locks.js'; import { repoHasRemote, worktreePath } from './store/index.js'; /** * Reclaim every retained worktree in `cwd` whose work can reach the remote (E5). * * The decision is entirely {@link removeProjectWorktree}'s — commit what is pending, push the * branch, remove only once the remote has it — so the automatic path and the manual one (the * dashboard's Remove button) are one behaviour rather than two that can disagree. This adds the * loop, the one thing it must never touch (a live agent's checkout, where its agent is working), and * the agent lock. * * The lock is load-bearing: an agent's meta flips to `done` a beat before its teardown finishes * archiving, so a sweep landing in that window would remove the checkout out from under the * archive — which then recreates the directory it was reading from, and the removal silently * un-happens. Every other actor on a checkout already takes this lock. */ export async function removeMergedWorktrees(cwd, deps = {}) { // Sizes off: `du` over every retained checkout is the expensive part of the listing, and a sweep // that only decides removal never reads the number. const worktrees = deps.worktrees ?? ((path) => listProjectWorktrees(path, { sizes: false })); const remove = deps.remove ?? removeProjectWorktree; const hasRemote = deps.hasRemote ?? repoHasRemote; const result = { removed: [], failed: [] }; const rows = (await worktrees(cwd).catch(() => [])).filter(row => !row.live && !deps.busy?.has(row.agentId)); if (!rows.length) return result; // Asked once per project, not once per checkout: with no remote the rule keeps everything, and // that answer cannot change between two rows of the same sweep — so the doomed per-checkout // probe-and-push cycle is skipped while each retained checkout is still accounted for. if (!(await hasRemote(cwd))) { for (const row of rows) result.failed.push({ agentId: row.agentId, error: 'the repo has no remote; its worktree was kept' }); return result; } for (const row of rows) { const outcome = await withAgentLock(worktreePath(cwd, row.agentId), () => remove(cwd, row.agentId)); if (outcome.ok) result.removed.push({ agentId: row.agentId }); else result.failed.push({ agentId: row.agentId, error: outcome.error }); } return result; } /** * Sweep every registered project's reclaimable worktrees (#1036), one turn per call. * * Says what it removed rather than removing it silently: a checkout vanishing from under someone * with no line explaining why reads as a bug, even when the work behind it is safe. */ export function startMergedWorktreeSweep(opts) { const sweep = opts.sweep ?? ((cwd) => removeMergedWorktrees(cwd, { ...(opts.busy ? { busy: opts.busy() } : {}) })); let stopped = false; // Each checkout's last-announced keep reason, so a retained checkout is accounted for once per // *state* rather than re-announced every ten minutes for the life of the daemon — a permanently // unpushable one (no remote, a publish-nothing session) repeats forever. A changed reason is a // changed state and is said again (a remote added, pushes now failing on auth); a removal // clears the entry, so a same-id checkout that reappears is a new thing to account for; a // daemon restart starts the accounting over, which is the boot-time announcement the retained // state deserves. const announced = new Map(); const sweepAll = async () => { for (const project of await opts.projects().catch(() => [])) { if (stopped) break; const { removed, failed } = await sweep(project.path).catch(() => ({ removed: [], failed: [] })); for (const item of removed) { announced.delete(item.agentId); opts.log(`[framework] removed the worktree for session ${item.agentId}: its branch is on the remote. The branch and the session are kept.`); } for (const item of failed) { if (announced.get(item.agentId) === item.error) continue; announced.set(item.agentId, item.error); opts.log(`[framework] kept the worktree for session ${item.agentId}: ${item.error}`); } } }; // Overlapping ticks join the sweep already running rather than being dropped: awaiting `tick()` // has to mean the sweep finished, or an on-demand caller (and a test) gets a silent no-op // whenever the clock's turn happens to be mid-flight. 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`, including once at start-up — // the case this exists for is a machine that was off while the work could not be pushed. return { tick, stop: () => { stopped = true; }, }; } //# sourceMappingURL=merged-worktrees.js.map