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.
234 lines • 12 kB
JavaScript
import { listAgents, readLiveMetas } from './store/index.js';
import { mergeAgentPr, resolveAgentPr } from './dashboard/agent-handoff.js';
import { ghPrCiStatus } from './dashboard/gh.js';
// Watch the PRs the framework is waiting to land, and act on what their CI says (#1418).
//
// The merge half (#1417's ask): a session armed for auto-merge on a repo without GitHub's native
// auto-merge used to get the direct fallback — merged seconds after opening, before its first
// check ran (#1406). Since #1418 that path answers `watched` instead, and this sweep is the other
// half of the promise: poll the watched PR's checks (~1 min, the latency agreed on the issue) and
// merge it once they pass. The repo setting stops mattering: merge-on-green works everywhere.
//
// The fix half (#1418's first use case): a watched or auto-armed PR whose checks go *red* is work
// the framework produced and then abandoned to a human. The sweep starts one unattended fix
// session per failing head commit, told to land its fix on the PR's own branch — the checks rerun,
// and the merge half finishes the job if they come back green.
//
// Polling rather than webhooks on purpose: a local daemon has no public URL for GitHub to call.
// Every decision this sweep takes starts from what `gh` answers, so a hosted deployment can later
// feed the same handlers from a webhook receiver and only the trigger changes.
/**
* How long an agent's watched PR stays on the sweep's list: long enough to survive a weekend of the
* daemon being off, short enough that the sweep's `gh` spend cannot grow with the archive. A PR
* older than this is a human's to land — it has been red or unmergeable for a week.
*/
export const CI_WATCH_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
/**
* How old a check-less PR must be before "no checks" is believed to mean "this repo has no CI"
* rather than "the suite has not attached yet" — GitHub takes seconds to attach one after a push,
* and merging inside that window is the stale-check hazard (#1406) wearing a different face.
*/
export const NO_CHECKS_GRACE_MS = 3 * 60 * 1000;
/**
* How many fix sessions one PR may consume before the sweep stands down. Each attempt only
* re-arms on a new head commit, so two attempts means: the original failure, and one more after
* a fix that did not take. Past that the failure is evidently not agent-shaped.
*/
const MAX_CI_FIX_ATTEMPTS = 2;
/**
* The marker a CI-fix agent's prompt opens with, so attempts are discoverable from agent metas. The
* `@` is always there, sha or not: it is what stops "PR #12" reading as a prefix of "PR #123"
* when the metas are scanned for prior attempts.
*/
export function ciFixMarker(number, headSha = '') {
return `[ci-fix] PR #${number} @${headSha}`;
}
/**
* The prompt a CI-fix session runs (#1418). Explicit about the git mechanics because the agent gets
* an ordinary session worktree on its own scratch branch: the PR's branch may be checked out in a
* retained worktree elsewhere, so `push origin HEAD:<branch>` is the one spelling that always
* lands the fix without fighting over who holds the branch.
*/
export function ciFixPrompt(fix) {
return [
ciFixMarker(fix.number, fix.headSha),
'',
`CI is red on PR #${fix.number} ("${fix.title}"): ${fix.failed.join(', ') || 'checks failed'}.`,
`The PR's branch is \`${fix.branch}\` and its failing head commit is ${fix.headSha}.`,
'',
'Fix the failure so the checks go green:',
`1. \`git fetch origin ${fix.branch}\` and put your worktree on that exact state: \`git reset --hard origin/${fix.branch}\` (your worktree’s own branch is scratch — nothing on it is worth keeping).`,
`2. Read the failing checks (\`gh pr checks ${fix.number}\`, then \`gh run view --log-failed <run-id>\` for the failed workflow runs) and diagnose the failure.`,
'3. Fix it, and run the relevant tests/build locally to confirm.',
`4. Push the fix back onto the PR: \`git push origin HEAD:${fix.branch}\`.`,
'',
`Do NOT open a new PR and do not merge anything: the fix belongs on PR #${fix.number} (${fix.url}), and the merge happens elsewhere once the checks pass.`,
].join('\n');
}
/** Both stores' metas: the live agents (their conversation is still going) and the archive. */
async function allAgentMetas(cwd) {
const [live, archived] = await Promise.all([
readLiveMetas(cwd).catch(() => []),
listAgents(cwd).catch(() => []),
]);
return [...live, ...archived];
}
/** Whether a meta is one of this sweep's candidates: an ended agent with a merge the CI decides. */
function watchable(meta, now) {
if (meta.status === 'running')
return false;
if (meta.mergeOutcome !== 'watched' && meta.mergeOutcome !== 'auto-armed')
return false;
const updated = Date.parse(meta.updatedAt ?? '');
return Number.isFinite(updated) && now - updated <= CI_WATCH_WINDOW_MS;
}
/**
* Sweep one project's watched PRs (#1418): merge the green ones the repo could not arm GitHub
* auto-merge for, and start a fix session for the red ones.
*
* Conservative wherever the answer is unclear: a PR that is not OPEN is done (merged or a
* human's rejection — neither is this sweep's to touch), `pending` checks wait for the next
* tick, and a check-less PR only counts as green once it has been check-less for longer than a
* suite takes to attach ({@link NO_CHECKS_GRACE_MS}). An `auto-armed` PR is never merged here —
* GitHub holds that promise — but its checks going red still starts a fix.
*/
export async function sweepProjectCi(cwd, deps = {}) {
const agents = deps.agents ?? allAgentMetas;
const pr = deps.pr ?? resolveAgentPr;
const ci = deps.ci ?? ghPrCiStatus;
const merge = deps.merge ?? mergeAgentPr;
const now = deps.now ?? Date.now;
const result = { merged: [], failed: [], fixes: [] };
const metas = await agents(cwd).catch(() => []);
const candidates = metas.filter(meta => watchable(meta, now()));
if (candidates.length === 0)
return result;
const seen = new Set();
for (const meta of candidates) {
const linked = (await pr(cwd, meta).catch(() => ({ value: undefined, pending: false }))).value;
// No PR, or one that is no longer open: nothing left to watch. CLOSED stays closed on
// purpose — an unmerged close is a human's rejection of the work.
if (!linked || linked.state !== 'OPEN' || seen.has(linked.number))
continue;
seen.add(linked.number);
const status = await ci(cwd, linked.number).catch(() => ({ checks: 'none', failed: [] }));
if (status.checks === 'pending')
continue;
if (status.checks === 'failing') {
const fix = await requestFix(cwd, metas, linked, status, deps);
if (fix)
result.fixes.push(fix);
continue;
}
// Green — but the merge is only ours where GitHub could not take it (#1418): an auto-armed
// PR lands by GitHub's own hand, and a check-less one must outlive the attach window first.
if (meta.mergeOutcome !== 'watched')
continue;
if (status.checks === 'none' && !pastNoChecksGrace(linked, now()))
continue;
const attemptKey = `${cwd}\u0000${linked.number}\u0000${status.headSha ?? ''}`;
if (deps.attemptedMerges?.has(attemptKey))
continue;
const outcome = await merge(cwd, meta);
if (outcome.ok)
result.merged.push({ agentId: meta.id, number: linked.number, ...(outcome.url ? { url: outcome.url } : {}) });
else {
deps.attemptedMerges?.add(attemptKey);
result.failed.push({ agentId: meta.id, number: linked.number, error: outcome.error });
}
}
return result;
}
/** Whether a check-less PR has been open longer than a check suite takes to attach. */
function pastNoChecksGrace(pr, now) {
const created = Date.parse(pr.createdAt ?? '');
// No creation time on the record: age unknowable, so never merge on its absence.
return Number.isFinite(created) && now - created > NO_CHECKS_GRACE_MS;
}
/**
* The fix half's own restraint (#1418), applied before the wiring's gates: one session per
* failing head commit — the marker in the prompt is the durable record, scanned off the same
* metas — at most one in flight per PR, and {@link MAX_CI_FIX_ATTEMPTS} per PR ever.
*/
async function requestFix(cwd, metas, pr, status, deps) {
if (!deps.fix)
return undefined;
// No head to pin the attempt to (or no branch to land it on): a retry could not be told from a
// loop, so stand down rather than guess.
if (!status.headSha || !status.branch)
return undefined;
const attempts = metas.filter(meta => meta.intent?.startsWith(ciFixMarker(pr.number)));
if (attempts.some(meta => meta.intent?.startsWith(ciFixMarker(pr.number, status.headSha))))
return undefined;
if (attempts.some(meta => meta.status === 'running'))
return undefined;
if (attempts.length >= MAX_CI_FIX_ATTEMPTS)
return { number: pr.number, reason: 'attempts-exhausted' };
const agentId = await deps.fix(cwd, {
number: pr.number,
title: pr.title,
url: pr.url,
branch: status.branch,
headSha: status.headSha,
failed: status.failed,
});
return agentId ? { number: pr.number, agentId } : { number: pr.number, reason: 'declined' };
}
/**
* Watch every registered project's armed PRs on a timer (#1418).
*
* Same lifecycle contract as the merged-worktree sweep: an immediate start-up tick (the case is a
* daemon that was off while checks went green), overlapping ticks join the sweep in flight, the
* timer is unref'd, and everything it does is logged — a PR merging with no line explaining why
* reads as a bug even when it is the feature.
*/
export function startCiWatch(opts) {
const sweep = opts.sweep ?? sweepProjectCi;
const deps = { attemptedMerges: new Set(), ...opts.deps };
let stopped = false;
// A red or unmergeable PR stays a candidate for a week, and its line does not get truer with
// repetition: each distinct line is said once per daemon lifetime.
const said = new Set();
const sayOnce = (line) => {
if (said.has(line))
return;
said.add(line);
opts.log(line);
};
const sweepAll = async () => {
for (const project of await opts.projects().catch(() => [])) {
if (stopped)
break;
const result = await sweep(project.path, deps).catch(() => ({ merged: [], failed: [], fixes: [] }));
for (const item of result.merged) {
opts.log(`[framework] CI watch: checks passed on PR #${item.number}, merged it${item.url ? ` (${item.url})` : ''} (session ${item.agentId})`);
}
for (const item of result.failed) {
sayOnce(`[framework] CI watch: could not merge PR #${item.number}: ${item.error}`);
}
for (const item of result.fixes) {
if (item.agentId)
opts.log(`[framework] CI watch: checks failed on PR #${item.number}, started fix session ${item.agentId}`);
else if (item.reason === 'attempts-exhausted')
sayOnce(`[framework] CI watch: PR #${item.number} is still red after ${MAX_CI_FIX_ATTEMPTS} fix sessions; leaving it for a human`);
}
}
};
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=ci-watch.js.map