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