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.
220 lines • 12.5 kB
JavaScript
import { nodeGitRunner } from './project.js';
import { ghPrsForBranchOrThrow, pickAgentPr } from './dashboard/gh.js';
import { openRemoteBranchPullRequest } from './dashboard/agent-handoff.js';
import { agentBranchName } from './branch-names.js';
import { listAgents, nodeStoreFs, startedAtFromAgentId } from './store/index.js';
import { patchArchivedAgentOnDataBranch } from './archived-agent-patch.js';
import { errorMessage } from './error-message.js';
// Adopt the branch a cloud session actually worked on (#1601).
//
// A web run is a local wrapper that hands the task to claude.ai and ends; the cloud session does
// the work on a branch of its own naming (`claude/*`), never the designated run branch. Nothing
// ever told the run's record about that branch, so every surface keyed to it — the session row,
// the PR resolution, CI watch, merge — stared at an empty `tf-agent-*` branch, and the run read
// as "nothing committed" while its work sat on origin.
//
// The hand-off anchor (#1601) makes the match exact rather than guessed: the driver pushes an
// empty commit unique to the run as the ref the session clones at, so the session's branch — and
// only it — descends from that commit. This pass walks origin's `claude/*` heads, matches each
// waiting run by that ancestry, and records what it finds onto the run's archive as one commit
// on the data branch: the branch, the PR the session opened for it — and when the run was armed
// for a PR the session never opened, it opens the draft PR itself, which is the armed handoff
// finally resolving against the facts.
//
// Conservative on every unprovable case: a run matching no head (the session has not pushed, or
// did nothing) or more than one (ancestry alone cannot say which) is simply retried next pass,
// and a run older than the window stops being asked about at all.
/** How long after its start a run is still asked about: safely past any cloud session's life. */
export const CLOUD_ADOPTION_WINDOW_MS = 48 * 60 * 60 * 1000;
/** Whether the run's record still carries the branch it was born on, i.e. no adoption happened. */
function onBirthBranch(meta) {
return meta.branch === undefined || meta.branch === agentBranchName(meta.id);
}
/** Whether the run's handoff was armed to open a PR. Absent means armed, matching the agent. */
function prArmed(meta) {
return meta.handoff?.pr !== false;
}
/** The settled web runs this pass still owes an answer. */
function waitingRuns(agents, now) {
return agents.filter(meta => {
if (meta.target !== 'web' || meta.status === 'running' || !meta.cloudAnchor)
return false;
const startedAt = meta.startedAt ?? startedAtFromAgentId(meta.id);
const startedMs = startedAt === undefined ? NaN : Date.parse(startedAt);
if (!Number.isFinite(startedMs) || now - startedMs > CLOUD_ADOPTION_WINDOW_MS)
return false;
// Owed: the branch is not adopted yet, or it is and the armed PR is still unaccounted for.
// An unarmed run stops being asked about once its branch is recorded — a PR someone opens
// later is still found live, by branch name, by every surface that shows PRs.
return onBirthBranch(meta) || (meta.pr === undefined && prArmed(meta) && meta.status === 'done');
});
}
/** Where the pass keeps origin's `claude/*` heads locally. */
const CLOUD_HEAD_PREFIX = 'refs/remotes/origin/claude/';
/**
* Bring origin's `claude/*` heads local, once for the whole pass rather than once per head.
*
* The objects belong to a cloud VM, so they have to be fetched before any ancestry can be read.
* This replaces a fetch per unmatched head per waiting run (#1607) — the saving is the call
* count, not the transfer: a second fetch of heads already local negotiates and sends nothing.
*
* The destination refspec is named rather than left to chance. An ordinary checkout would write
* these refs anyway, because git opportunistically updates the remote-tracking branches its
* configured refspec covers even when the command line names its own; a checkout cloned with a
* narrower refspec — `--single-branch` — would not, and would re-fetch every pass forever.
*
* Pruned, because these refs are now a standing local copy of a list that used to be read live
* from origin each pass: without it a `claude/*` branch deleted on origin would go on matching.
*/
async function fetchCloudHeads(git, cwd) {
await git(['fetch', '--prune', 'origin', `+refs/heads/claude/*:${CLOUD_HEAD_PREFIX}*`], cwd);
}
/**
* The `claude/*` heads descending from `anchor` — the proof a branch is this run's, asked of git
* once for the whole set rather than once per head.
*
* `--contains` is the same ancestry question `merge-base --is-ancestor` answered one head at a
* time, and asking it this way also answers "how many" in the same call. Unprovable reads as no
* match and is retried next pass: an anchor whose object is not local is exactly the case where
* the session has pushed nothing for it to be an ancestor of.
*/
async function headsDescendingFrom(git, cwd, anchor) {
const listing = await git(['for-each-ref', `--contains=${anchor}`, '--format=%(objectname) %(refname)', CLOUD_HEAD_PREFIX], cwd).catch(() => '');
const heads = [];
for (const line of listing.split('\n')) {
const head = /^([0-9a-f]{40,64}) (.+)$/.exec(line.trim());
if (head?.[2]?.startsWith(CLOUD_HEAD_PREFIX)) {
heads.push({ ref: `claude/${head[2].slice(CLOUD_HEAD_PREFIX.length)}`, sha: head[1] });
}
}
return heads;
}
/**
* Adopt one project's cloud work (#1601): match each waiting web run to the `claude/*` head
* descending from its hand-off anchor, and record the branch and its PR onto the run's archive —
* opening the armed draft PR when the session never did. Never throws: a repo with no remote
* (or offline) adopts nothing, and every unmatched run is retried next pass.
*/
export async function adoptCloudWork(cwd, deps = {}) {
const git = deps.git ?? nodeGitRunner();
const prs = deps.prs ?? ghPrsForBranchOrThrow;
const agents = deps.agents ?? ((project, since) => listAgents(project, nodeStoreFs(), since));
const patchArchive = deps.patch ?? patchArchivedAgentOnDataBranch;
const openPr = deps.openPr ?? openRemoteBranchPullRequest;
const now = deps.now ? deps.now() : Date.now();
const result = { adopted: [], failed: [] };
// Only the window's runs are asked for: an archive grows without bound, and everything older
// than the window is a record `waitingRuns` would drop anyway — so it is never read (#1607).
const waiting = waitingRuns(await agents(cwd, now - CLOUD_ADOPTION_WINDOW_MS).catch(() => []), now);
if (waiting.length === 0)
return result;
try {
await fetchCloudHeads(git, cwd);
}
catch {
return result; // no remote, or it cannot be reached: nothing to match against
}
for (const run of waiting) {
const matches = await headsDescendingFrom(git, cwd, run.cloudAnchor);
// Exactly one, or nothing happens: zero is a session that has not pushed (or never will),
// and two is a history this pass cannot arbitrate — both are the next pass's question.
if (matches.length !== 1)
continue;
const head = matches[0];
const branch = head.ref;
// A run whose record names some other branch — neither the one it was born on nor this head
// — is not this pass's to answer: a PR opened here would be recorded against a branch it
// does not live on.
if (!onBirthBranch(run) && run.branch !== branch)
continue;
// The PR the session opened for its branch, if any — filtered by the run's start so a
// predecessor's PR on a reused name is never this run's, `latest` so the last PR that saw
// the branch answers (#1512). "None" and "could not list" must not look alike here: a
// listing that fails opens nothing this pass (a second draft PR on a branch that already
// has one is the cost of guessing), and the run is asked again next time.
const since = run.startedAt ?? startedAtFromAgentId(run.id);
const listing = await prs(cwd, branch).then(found => ({ ok: true, pr: pickAgentPr(found, since, 'latest') }), (err) => ({ ok: false, error: errorMessage(err) }));
let pr = listing.ok ? listing.pr : undefined;
let opened = false;
if (!listing.ok) {
result.failed.push({ agentId: run.id, error: `could not list the PRs of ${branch} (${listing.error}), so no draft PR was opened this pass` });
}
else if (!pr && prArmed(run) && run.status === 'done' && head.sha !== run.cloudAnchor) {
// The armed handoff, finally resolving (#1601): the run was armed for a PR, the session
// opened none, and the branch carries something beyond the hand-off itself — so the draft
// PR the wrapper's epilogue could never open (it saw only the empty run branch) opens now.
// A run whose PR arming was off gets its branch recorded and nothing else.
const openedPr = await openPr(cwd, run, branch);
if (openedPr.ok && openedPr.number !== undefined && openedPr.url) {
pr = { number: openedPr.number, url: openedPr.url, state: 'OPEN', title: '' };
opened = true;
}
else if (!openedPr.ok) {
result.failed.push({ agentId: run.id, error: `could not open the armed draft PR for ${branch}: ${openedPr.error}` });
}
}
// One commit on the data branch carries whatever this pass learned: the branch (first time
// only), the PR (once known). Nothing learned, nothing written — and nothing announced.
const patch = {
...(onBirthBranch(run) ? { branch } : {}),
...(pr && run.pr === undefined ? { pr: { number: pr.number, url: pr.url } } : {}),
};
if (Object.keys(patch).length === 0)
continue;
if (!(await patchArchive(cwd, run.id, patch, `[The Framework] adopt session ${run.id}'s cloud work`))) {
result.failed.push({ agentId: run.id, error: `could not record ${branch} on the run's archive` });
continue;
}
result.adopted.push({
agentId: run.id,
branch,
...(pr ? { pr: { number: pr.number, url: pr.url } } : {}),
...(opened ? { opened: true } : {}),
});
}
return result;
}
/**
* Adopt every registered project's cloud work (#1601), one turn per call.
*
* Adoptions and failures are said out loud, unmatched runs are not: a session that has not
* pushed yet is the normal state of every run this watches, and a line per tick about it would
* be noise. A run's row changing branch with no line explaining why would read as a bug.
*/
export function startCloudWorkAdoption(opts) {
const adopt = opts.adopt ?? adoptCloudWork;
let stopped = false;
const passAll = async () => {
for (const project of await opts.projects().catch(() => [])) {
if (stopped)
break;
const { adopted, failed } = await adopt(project.path).catch(() => ({ adopted: [], failed: [] }));
for (const adoption of adopted) {
const prLine = adoption.pr ? (adoption.opened ? `; opened its armed draft PR ${adoption.pr.url}` : `; its PR is ${adoption.pr.url}`) : '';
opts.log(`[framework] session ${adoption.agentId}'s cloud work landed on ${adoption.branch} — adopted as its branch (#1601)${prLine}`);
}
for (const failure of failed) {
opts.log(`[framework] cloud work adoption for session ${failure.agentId}: ${failure.error}`);
}
}
};
// Overlapping ticks join the pass already running rather than being dropped, so awaiting
// `tick()` means the pass finished — same rule as the worktree sweep, for the same reason.
let inflight;
const tick = () => {
if (stopped)
return Promise.resolve();
inflight ??= passAll().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-work.js.map