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.
664 lines • 43 kB
JavaScript
import { presets } from './preset-catalog.js';
import { DEFAULT_AUTO_PM_CONCURRENCY } from './preference-defaults.js';
import { TICKETS_DIR, ticketFromQueueEntry } from './tickets.js';
import { AGENT_BRANCH_PREFIX } from './branch-names.js';
/**
* Auto PM (#685): spend leftover subscription quota on product management instead of
* letting it expire. While the account is still under its quota boundary (#879) and nobody
* is at the keyboard, the daemon runs the cycle by itself: it works the agent queue down entry by entry (#855),
* and once that is empty it refills it — triaging tickets, then spiking and planning
* the ones that have neither yet.
*
* The whole feature is one policy question ("is now a good time to spend tokens on our
* own roadmap?"), so that question lives here as a pure function and the daemon only
* supplies the readings. #298 is the parent idea (background jobs / "max out the usage"),
* and #879 defines the boundary this reads.
*/
/** How often the daemon re-asks {@link autoPmDecision}. */
export const DEFAULT_AUTO_PM_INTERVAL_MS = 10 * 60 * 1000;
/**
* How long a project is left alone after an auto agent is started for it. A spawned agent
* takes a moment to appear in the daemon's live-run map, and without this the next tick
* would see "nothing running, queue still empty" and start a second one.
*/
export const DEFAULT_AUTO_PM_COOLDOWN_MS = 30 * 60 * 1000;
/**
* Whether the budget allows spending unasked.
*
* The gate is the quota boundary (#879): the pro-rated share of the week's allowance elapsed so
* far, rising continuously with the clock (#960 Edit), so auto PM spends up to that line and
* stands down at it. Work the user asks for is free to cross it and borrow against the days still
* to come; work nobody asked for is exactly what the line is there to stop.
*
* **It fails closed on a quota it cannot read, and that is the opposite of the per-agent guard.**
* #519 settled that an unreadable quota must never *stop* the user's own work, so
* `startConsumptionGuard` fails open. Quietly burning a subscription on work nobody asked for
* is a far worse failure than skipping a tick.
*
* Reading the account's own week also means a restarted daemon is not blind: the figure is
* absolute and complete, unlike the delta meter this replaced, which reported zero consumed
* after a restart however much the account had spent (#848).
*/
export function quotaHeadroom(quota) {
if (!quota)
return { start: false, reason: 'the quota could not be read, so there is no way to tell what is spare' };
const reached = quota.reached;
if (reached) {
// Name the line it actually stopped at (#960). With the slider moved, saying "the week's 43%"
// when the agent stopped at 63% would send someone looking for a bug that is a setting.
// The offset is rounded to one decimal for the sentence: a dragged slider stores integers,
// but the half-day default (#960 Edit) is 100/14 and fifteen digits of it would say less.
const { limit, boundary } = quota;
const offsetText = `${limit.offset > 0 ? '+' : ''}${Math.round(limit.offset * 10) / 10}`;
const line = limit.offset === 0
? `the week's ${Math.round(boundary.percent)}%`
: `your ${Math.round(limit.percent)}% limit (${offsetText} on the week's ${Math.round(boundary.percent)}%)`;
return {
start: false,
reason: `${reached.label} is ${Math.round(reached.percentUsed)}% used, at or past day ${boundary.day} of ${line}`,
};
}
return { start: true };
}
/**
* Whether to start a PM agent for one project right now. Every condition is a reason to
* *not* spend the user's quota, checked cheapest first so the common "someone is working"
* case never reaches the meter.
*/
export function autoPmDecision(input) {
if (!input.enabled)
return { start: false, reason: 'auto PM is off' };
const concurrency = Math.max(1, Math.floor(input.concurrency ?? DEFAULT_AUTO_PM_CONCURRENCY));
if (input.activeAgents >= concurrency) {
// The cap is named, for the same reason the quota refusal names its line (#960): with the
// setting raised or lowered, "already going" on its own reads as a bug rather than a setting.
// At one — what this was before #1204 — the old wording is kept exactly.
const going = `${input.activeAgents} run${input.activeAgents === 1 ? ' is' : 's are'} already going`;
return { start: false, reason: concurrency === 1 ? going : `${going}, and the routine keeps at most ${concurrency} at once` };
}
const cooldownMs = input.cooldownMs ?? DEFAULT_AUTO_PM_COOLDOWN_MS;
if (input.sinceLastStartMs !== undefined && input.sinceLastStartMs < cooldownMs) {
return { start: false, reason: 'a run was started for this project a moment ago' };
}
if (input.backlogEmpty === undefined) {
return { start: false, reason: 'the agent queue could not be read, so there is no way to tell what to do' };
}
const headroom = quotaHeadroom(input.quota);
if (!headroom.start)
return headroom;
// The queue picks the job, and a non-empty one wins (#855). It used to be a refusal, on the
// reasoning that the backlog loop would drain it — but that loop only exists inside an agent a
// human started, so unattended the queue filled once and nothing ever emptied it again.
return { start: true, mode: input.backlogEmpty ? 'pm' : 'drain' };
}
/**
* A drain job pinned to one named queue entry (#1204).
*
* With a single agent the stock prompt ("the FIRST open entry") is exact. With several going at
* once it is a collision: every drain forks the same checkout, so every one of them reads the same
* first entry and implements it as many times over. Naming the entry is what makes a batch of
* drains work on disjoint things.
*
* The prompt also tells the agent to stop when the entry is already checked off or gone: the
* assignment is a snapshot, and a human may retire the entry between the sweep's read and the
* run's own.
*
* When the entry links back to a ticket the sweep has claimed (#1420), the agent is also told
* which claim is its own — the same contract {@link pinnedPlanJob} carries, because the same
* gap exists: without the lock the claim on the *implementation* lived only in this daemon's
* memory, so another machine's sweep could book the same ticket. Ticket, plan and lock live on
* the data branch (#1582), so the agent retires them there once its work is published — nothing
* else releases a lock since #1420 dropped the timer. The queue entry itself is NOT the agent's
* to touch: the daemon checks it off at settle, once the run's ending reports the work landed.
*/
export function pinnedDrainJob(job, entry, assignment) {
const stem = assignment?.ticket.replace(/\.md$/, '');
return {
...job,
entry,
...(assignment ? { claim: assignment } : {}),
prompt: [
'Work on this one open task-queue entry only (the queue lives on the data branch — see "The data branch"):',
'',
`- ${entry}`,
'',
'Do not start any other entry, and do not check the entry off — the framework retires it once your work lands. If the entry is already checked off or no longer on the queue, stop and do nothing.',
...(assignment
? [
'',
`Your claim on the entry's ticket is already in place on the data branch: \`${TICKETS_DIR}/${stem}.lock.md\` holds \`CLAIMED: ${assignment.agentId}\`. Once your work is published, remove \`${TICKETS_DIR}/${stem}.md\`, \`${TICKETS_DIR}/${stem}.plan.md\`, and \`${TICKETS_DIR}/${stem}.lock.md\` on the data branch (see "The data branch") — closed tickets leave it, and the lock lifts when your work lands. If the lock file is missing or names a different agent, the ticket is not yours — stop and do nothing.`,
]
: []),
].join('\n'),
describe: `draining the queue entry "${entryPreview(entry)}"`,
};
}
/** An entry as a log line can carry it: one line, bounded. */
function entryPreview(entry) {
const flat = entry.replace(/\s+/g, ' ').trim();
return flat.length > 80 ? `${flat.slice(0, 80)}…` : flat;
}
/**
* A fan-out job pinned to one locked ticket (#1327).
*
* The stock prompt covers every ticket that has no plan or claim yet, and with a batch going out
* that instruction is the same collision {@link pinnedDrainJob} exists for: every agent forks the
* same checkout and would pick the same most-important ticket. The pin is *appended* to the stock
* prompt rather than spliced into it, so the verdict rules the preset carries keep riding along
* verbatim and a rewritten preset (the maintainer owns its wording) cannot silently lose the pin.
*
* The agent is also told which claim is its own: its ticket's `.lock.md` already exists with the
* `CLAIMED:` line the daemon pushed (#1420), and finding anything else there means the
* assignment is stale — another agent's claim, or work that landed meanwhile — so it stops. It
* is told to delete the lock in the same data-branch commit as the plan (#1582), because nothing
* else releases it: #1420 removed the staleness timer, so a forgotten lock stands until a human
* clicks it away.
*/
export function pinnedPlanJob(job, assignment) {
const { ticket, agentId } = assignment;
const stem = ticket.replace(/\.md$/, '');
return {
...job,
ticket,
claim: assignment,
prompt: [
job.prompt.trimEnd(),
'',
`You are one agent of a concurrent batch, so the scope above narrows: plan exactly one ticket, \`tickets/${ticket}\`, and no other.`,
'',
`Your claim on it is already in place on the data branch: \`tickets/${stem}.lock.md\` holds \`CLAIMED: ${agentId}\`. Write the real \`tickets/${stem}.plan.md\` and delete \`tickets/${stem}.lock.md\` in the same data-branch commit (see "The data branch") — the lock lifts when your work lands, and the plan is a data write, not a PR. If the lock file is missing, names a different agent, or a plan already exists, the ticket is not yours — stop and do nothing.`,
].join('\n'),
describe: `planning "${entryPreview(ticket)}"`,
};
}
/**
* The default cycle: bring the tickets across from GitHub (#1208), triage the quick ones (#891),
* then the significant-but-agreed ones (#892), and only then make more plans (#685). Planning is
* the most expensive turn and the one whose output the earlier jobs consume, so it runs last.
*
* Importing leads because it is the only job that can add a ticket none of the others have seen
* (#1334): a routine that triages and plans a set nothing ever refills eventually has nothing
* left to do, and a new issue would wait for a human to press the button. It is safe to repeat --
* the preset resumes from `tickets/meta.json`'s `lastImportedAt` and reconciles, so a firing with
* nothing changed since the last one is a no-op rather than a re-import.
*
* This rotation is what #891/#892 mean by "with a cron job regularly firing this preset". No
* separate scheduler is involved and none is needed: the rotation already fires on every idle tick
* where the queue is dry, which is exactly when the queue wants refilling. That is the opposite of
* the maintenance sweep (#882), which is paced by a calendar because it looks at static history and
* would otherwise never come due — hence its own {@link AUTO_PM_MAINTENANCE_JOB} outside the cycle.
*
* The gated triage sibling (#698) is deliberately not here: it ends in `<AWAIT>`, so firing it with
* nobody at the keyboard would park an agent against a human who will never answer.
*
* Each triage prompt pins its own session name and aborts if that branch already exists, so a
* rotation that comes round again while the previous triage is still in flight is a no-op rather
* than a duplicate. The rotation still advances past it, which is the wanted behaviour: the next
* idle tick tries the next job instead of retrying a job that is already running.
*/
export const AUTO_PM_JOBS = [
{
name: presets.updateTickets.name,
prompt: presets.updateTickets.render(),
label: presets.updateTickets.label,
tooltip: presets.updateTickets.tooltip,
},
{
name: presets.triageQuick.name,
prompt: presets.triageQuick.render(),
label: presets.triageQuick.label,
tooltip: presets.triageQuick.tooltip,
pinnedBranch: `${AGENT_BRANCH_PREFIX}${presets.triageQuick.name}`,
},
{
name: presets.triageConsensual.name,
prompt: presets.triageConsensual.render(),
label: presets.triageConsensual.label,
tooltip: presets.triageConsensual.tooltip,
pinnedBranch: `${AGENT_BRANCH_PREFIX}${presets.triageConsensual.name}`,
},
{
name: presets.planTickets.name,
prompt: presets.planTickets.render(),
label: presets.planTickets.label,
tooltip: presets.planTickets.tooltip,
fansOut: true,
},
];
/**
* The job for a queue that is not empty (#855): work its first entry off. Outside the rotation
* on purpose — the rotation is about what to *make* when there is nothing to do, and this is
* the thing to do.
*/
export const AUTO_PM_DRAIN_JOB = {
name: presets.drainQueue.name,
prompt: presets.drainQueue.render(),
label: presets.drainQueue.label,
tooltip: presets.drainQueue.tooltip,
drains: true,
autoMerge: true,
};
/**
* The periodic codebase-wide sweep (#882): fire the [Maintenance] preset (#881) so a repo that
* adopted The Framework late gets its pre-existing history looked at.
*
* Outside the rotation, like {@link AUTO_PM_DRAIN_JOB} and for the same kind of reason: the
* rotation is "what to make next" and cycles every idle tick, while this is paced by a calendar
* and must not advance or be advanced by the cycle. It takes precedence over the rotation when
* due, because the entries it queues are what the rotation would otherwise be inventing work
* instead of.
*
* The prompt renders at module load with no session, so `tf.params.what` falls back to its
* default of the entire codebase, which is exactly this job's scope.
*/
export const AUTO_PM_MAINTENANCE_JOB = {
name: presets.maintenance.name,
prompt: presets.maintenance.render(),
describe: 'sweeping the codebase for maintenance work',
label: presets.maintenance.label,
tooltip: presets.maintenance.tooltip,
};
/**
* Every routine the sweep can fire, in the order a surface should list them (#1159).
*
* Derived from the three constants above rather than written out again, so the list the dashboard
* shows and the jobs the daemon actually runs cannot drift. The order is the sweep's own precedence
* (#855/#882 read the other way round): draining comes first because it is what happens whenever
* there is queued work, the rotation is what happens when there is not, and the calendar-paced
* maintenance sweep is the exception outside both.
*/
export const AUTO_PM_ROUTINES = [
AUTO_PM_DRAIN_JOB,
...AUTO_PM_JOBS,
AUTO_PM_MAINTENANCE_JOB,
];
/** The sentence a start is reported as: the log line and the outcome message say the same thing. */
const doing = (job) => job.describe ?? job.label ?? job.name;
/**
* Start the auto-PM sweep (#685): every {@link DEFAULT_AUTO_PM_INTERVAL_MS}, ask
* {@link autoPmDecision} for each project and start an agent for the ones that say yes.
*
* Ticks never overlap — a sweep reads a live-agent map that its own `start` calls mutate,
* so a second sweep running over the first would decide against a stale picture.
*
* Nothing here survives the daemon: per #519 a Ctrl+C that stops everything is the feature,
* not a gap, so this loop is deliberately not restartable from outside the process.
*/
export function startAutoPm(deps) {
const now = deps.now ?? (() => Date.now());
const intervalMs = deps.intervalMs ?? DEFAULT_AUTO_PM_INTERVAL_MS;
const startedAt = now();
// What the last sweep decided, for `report()`. Undefined only in the moment before the
// start-up sweep below lands.
let lastSweep;
const lastStart = new Map();
const pending = new Map();
// Work this loop already spawned an agent for that ended with nothing to hand off (#1583): the
// drain's entry, or the plan agent's ticket. Releasing such a claim re-opens the work, and a
// job that deterministically ends commitless would otherwise respawn every cooldown, forever,
// burning a quota run per cycle. One attempt per daemon lifetime: a restart forgets the set,
// which allows one more try rather than forbidding the work for good — a human retires or
// fixes the entry in between.
const endedDry = new Map();
// Where each project is in the job cycle. Per project, not global: two repos idle at once
// should each work through the rotation, not take alternate halves of it.
const nextJob = new Map();
let sweeping = false;
let stopped = false;
const tick = async (opts) => {
if (stopped || sweeping)
return;
sweeping = true;
let enabled = false;
const outcomes = [];
// Every branch that logs also records, so the panel says exactly what the log says.
const note = (project, started, message) => outcomes.push({ projectId: project.id, path: project.path, started, message });
try {
// The preference is the cheapest gate and the one the user flips most, so it is read
// once per sweep rather than per project. An on-demand sweep outranks it — the click is
// the consent the preference exists to record — but still reads it, so the report says
// where the box stood.
enabled = await deps.enabled().catch(() => false);
if (!enabled && !opts?.onDemand)
return;
const projects = await deps.projects().catch(() => []);
if (!projects.length)
return;
// Read beside the master switch and for the same reason (#1209): it is the same preference
// file, and a routine switched off mid-sweep should not fire for the projects still to come.
const optedOut = new Set(await deps.optedOut?.().catch(() => []) ?? []);
const wanted = (job) => (job && !optedOut.has(job.name) ? job : undefined);
// The rotation, minus what is switched off. Filtered rather than skipped at the index, so
// the cycle stays a cycle: with two of four off, the remaining two alternate instead of
// every other tick landing on a job that cannot run.
const rotation = deps.jobs.filter(job => !optedOut.has(job.name));
// How many agents each project may keep going (#1204). Read beside the opt-outs and for the
// same reason: it is the same preference file, re-read so the setting takes effect
// mid-schedule. Floored at one, since zero agents is the master switch's job.
const concurrency = Math.max(1, Math.floor((await deps.concurrency?.().catch(() => undefined)) ?? DEFAULT_AUTO_PM_CONCURRENCY));
for (const project of projects) {
// Land anything a previous agent produced before judging whether the queue is empty:
// its entries are still on that agent's branch, and the checkout cannot see them.
const outstanding = pending.get(project.id) ?? [];
if (outstanding.length) {
const stillPending = [];
let landed = 0;
for (const agent of outstanding) {
const outcome = await deps.promote(project, agent).catch(() => ({ settled: false, promoted: false }));
if (outcome.promoted)
landed++;
if (!outcome.settled) {
stillPending.push(agent);
continue;
}
// The run ended cleanly but its epilogue has not reported yet — `end` lands before
// the handoff event does — and the ending is the one fact the release keys off, so a
// claim-carrying agent caught in that gap is held a couple more sweeps rather than
// settled blind. Bounded, so a process that died mid-epilogue cannot pin its queue
// entry forever; past the bound it settles unread, which is the pre-#1583 behavior.
// Held for the entry's check-off too (#1582): retiring a drained entry keys off the
// same reported ending the claim release does.
if ((agent.claim || agent.entry !== undefined) && outcome.handoffPending && (agent.waits ?? 0) < 2) {
stillPending.push({ ...agent, waits: (agent.waits ?? 0) + 1 });
continue;
}
// A settled run that ended with nothing to hand off is never opening the PR that
// lifts the lock it was started under (#1583), so the claim minted for it is freed —
// the one dead claim the sweep can *know* is dead, rather than guess by a timer. A
// release that could not land is retried next sweep, bounded like the hold above.
if (agent.claim && outcome.handoffSkip === 'no-commits' && deps.releaseLock) {
// Remembered before the release, not after: respawning the same work is the hazard
// whether or not the release lands.
const dry = endedDry.get(project.id) ?? new Set();
dry.add(agent.entry ?? agent.claim.ticket);
endedDry.set(project.id, dry);
const ok = await deps.releaseLock(project, agent.claim).catch(() => false);
if (!ok && (agent.waits ?? 0) < 2) {
stillPending.push({ ...agent, waits: (agent.waits ?? 0) + 1 });
continue;
}
}
}
if (stillPending.length)
pending.set(project.id, stillPending);
else
pending.delete(project.id);
if (landed) {
// The queue the decision below reads was just filled, so that read is stale. Leave it
// to the next tick, and let the backlog loop have the work in the meantime.
deps.log(`[framework] auto PM: landed the queue from ${landed} run(s) in ${project.path}`);
note(project, false, `landed the queue from ${landed} finished run${landed === 1 ? '' : 's'}`);
continue;
}
}
const entries = await deps.queue(project).catch(() => undefined);
// Per project, because the model the work would run on is (#1619). It costs no reading:
// the meter is polled elsewhere and this only measures the last one against the boundary.
const quota = await deps.quota(project).catch(() => undefined);
const activeAgents = deps.activeAgents(project);
const since = lastStart.get(project.id);
const decision = autoPmDecision({
enabled: true,
backlogEmpty: entries === undefined ? undefined : entries.length === 0,
activeAgents: activeAgents,
concurrency,
quota,
...(since !== undefined ? { sinceLastStartMs: now() - since } : {}),
...(deps.cooldownMs !== undefined ? { cooldownMs: deps.cooldownMs } : {}),
});
if (!decision.start) {
// Logged, so a wedged sweep is distinguishable from a healthy idle one (#855).
deps.log(`[framework] auto PM: standing down for ${project.path} — ${decision.reason}`);
note(project, false, decision.reason);
continue;
}
const drainJob = wanted(deps.drainJob ?? AUTO_PM_DRAIN_JOB);
// A drain-only sweep (#1204) works the queue or says why not — it never borrows the
// click for a rotation job the user did not ask for. Logged like every other stand-down
// (#855/#1433): these two used to be the only silent ones.
if (opts?.drainOnly) {
if (decision.mode !== 'drain') {
deps.log(`[framework] auto PM: standing down for ${project.path} — the queue is empty, so there is nothing to drain`);
note(project, false, 'the queue is empty, so there is nothing to drain');
continue;
}
if (!drainJob) {
deps.log(`[framework] auto PM: standing down for ${project.path} — the queue has work waiting and its routine is switched off`);
note(project, false, 'the queue has work waiting and its routine is switched off');
continue;
}
}
/**
* What this tick does, which is the queue-picked mode (#855) unless the draining routine
* is switched off — then the rotation gets the tick instead of the sweep standing down.
*
* #1209 means "do not *work* the queue", and the rotation does not work it: triage and
* planning put entries *on* it. Standing down here read that switch as "do nothing at
* all", which made every inventing routine unreachable for as long as the queue had
* anything on it — and since the queue is auto-populated, that is most of the time. The
* only way to reach `Spike & plan` was to empty the queue by hand (#1432).
*
* A drain-only sweep never gets here: it has already stood down above, because the click
* that fires it asked for the queue specifically.
*/
const mode = decision.mode === 'drain' && !drainJob ? 'pm' : decision.mode;
const index = nextJob.get(project.id) ?? 0;
// A due codebase sweep (#882) outranks the rotation: the rotation invents work, and the
// sweep is a standing instruction to go find some. Only ever while the queue is empty --
// a repo with entries waiting has plenty to do, and the sweep would only add more.
//
// Asked before the schedule is read, so a switched-off sweep costs no disk read and,
// more importantly, leaves its calendar untouched: it must come due normally once it is
// switched back on, rather than having been silently ticked past while it was off.
// `decision.mode`, not the effective `mode` above: the question here is whether the queue
// is genuinely empty, and a tick that fell through to the rotation because draining is
// switched off still has entries waiting — exactly the case this sweep stays out of.
const maintenanceJob = wanted(deps.maintenanceJob ?? AUTO_PM_MAINTENANCE_JOB);
const sweep = decision.mode === 'pm' &&
maintenanceJob !== undefined &&
(await deps.maintenanceDue?.(project).catch(() => false)) === true;
const job = sweep ? maintenanceJob : mode === 'drain' ? drainJob : rotation[index % rotation.length];
if (!job) {
// Told apart on purpose: a rotation emptied by the checkboxes is a setting the user can
// see and undo, and reads nothing like a daemon wired without jobs at all.
note(project, false, deps.jobs.length ? 'every routine that makes new work is switched off' : 'there is no job to run');
continue;
}
// What to start this tick (#1204/#1327). Draining fans out, and so does a rotation job
// that declares {@link AutoPmJob.fansOut} — the property both share is what each agent
// does to shared files: a drain takes one entry *off* the queue, a pinned plan agent writes
// one ticket's *own* sibling files, so several agents do disjoint work and land disjoint
// edits. Every other rotation job rewrites the whole queue document from the same fork
// point, so two at once would revert each other's promotion; those stay one per tick.
const batch = [job];
if (mode === 'drain') {
// An entry an agent still in flight was pinned to is not offered again.
const assigned = new Set((pending.get(project.id) ?? []).flatMap(agent => (agent.entry !== undefined ? [agent.entry] : [])));
// An entry someone else already worked is not on the queue to begin with (E2): the
// check-off travels in that agent's own PR, and the merge is what takes it off. A third
// claim used to be re-derived here — from agent metas, their PRs, and the queue diffs of
// open PRs on other machines — which is a guess assembled at read time rather than a
// claim anyone wrote down.
// An entry whose agent already ended with nothing to hand off (#1583) is not offered
// again either: its released claim would just mint the same commitless run every
// cooldown. A human retires or reshapes the entry; a daemon restart allows one retry.
const dry = endedDry.get(project.id);
const open = (entries ?? []).filter(entry => !assigned.has(entry) && !dry?.has(entry));
if (!open.length) {
note(project, false, (entries ?? []).some(entry => dry?.has(entry))
? 'every open queue entry is being worked on, or already drained once with nothing to hand off'
: 'every open queue entry is already being worked on');
continue;
}
const picks = open.slice(0, concurrency - activeAgents);
// The cross-machine claim on what a drain *implements* (#1420): an entry that links
// back to a ticket (#1164) gets its `.lock.md` before its agent starts, the same
// pushed claim planning already makes — without it the booking lived only in this
// daemon's `pending` map, and another machine's sweep could implement the same ticket.
// A ticketless entry has nothing on disk to lock and proceeds as before. Ids are
// generated here for the same reason plan ids are: the lock's CLAIMED line and the
// pinned prompt must agree.
const linked = new Map(picks.flatMap((entry, i) => {
const ticket = ticketFromQueueEntry(entry);
return ticket
? [[entry, { ticket: ticket.slice(TICKETS_DIR.length + 1), agentId: `drain-${now()}-${i}` }]]
: [];
}));
// Matched back by agent id, not ticket: two entries linking the same ticket race for
// one lock, and only the assignment whose id the lock actually names may carry it.
const locked = new Set(deps.lockDrains && linked.size
? (await deps.lockDrains(project, [...linked.values()]).catch(() => [])).map(a => a.agentId)
: []);
batch.length = 0;
batch.push(...picks.flatMap(entry => {
const assignment = linked.get(entry);
// No seam wired means no claim to carry: the entry drains exactly as before #1420.
if (!assignment || !deps.lockDrains)
return [pinnedDrainJob(job, entry)];
// A lost race costs this batch the entry, not the batch: the claim that won it is
// pushed, so the check-off will arrive in that agent's own PR.
return locked.has(assignment.agentId) ? [pinnedDrainJob(job, entry, assignment)] : [];
}));
if (!batch.length) {
note(project, false, 'every entry in this batch links a ticket another agent already claimed');
continue;
}
}
else if (job.fansOut && deps.planCandidates && deps.lockPlans) {
// The fan-out for a rotation job that writes per-ticket files (#1327). Both seams or
// neither: candidates without locks would fan out unguarded, which is exactly the
// double-work the locks exist to prevent — so a loop wired without them keeps the
// stock single agent already in the batch.
//
// A ticket an agent still in flight was pinned to is not offered again, same as a drain's
// entry. The durable half is the lock files themselves: unlike drain entries, the claim
// is on disk and pushed, so no #1253-style meta lookup is needed here.
const pinned = new Set((pending.get(project.id) ?? []).flatMap(agent => (agent.ticket !== undefined ? [agent.ticket] : [])));
const candidates = ((await deps.planCandidates(project).catch(() => [])) ?? []).filter(
// A ticket whose plan agent already ended with nothing to hand off (#1583) is skipped
// for the same reason a drained-dry entry is: its released claim respawns the same
// commitless run forever.
ticket => !pinned.has(ticket) && !endedDry.get(project.id)?.has(ticket));
if (!candidates.length) {
// Nothing left to plan is this job's work being done, not a refusal: the rotation
// advances, so the next tick tries the next job instead of re-asking forever.
nextJob.set(project.id, index + 1);
note(project, false, 'every open ticket already has a plan, or an agent on the way to one');
continue;
}
// Ids are generated here rather than by the lock writer, because the id has to reach
// two places that must agree: the lock file's CLAIMED line and the pinned prompt that
// tells the agent which claim is its own. Clock+index rather than randomness, so a
// test with an injected clock can predict them.
const assignments = candidates
.slice(0, concurrency - activeAgents)
.map((ticket, i) => ({ ticket, agentId: `plan-${now()}-${i}` }));
const locked = await deps.lockPlans(project, assignments).catch(() => []);
if (locked.length) {
batch.length = 0;
batch.push(...locked.map(assignment => pinnedPlanJob(job, assignment)));
}
// Nothing locked falls through with the stock single job: one unpinned agent is the
// pre-#1327 behavior, and safe without a lock.
}
// Re-checked here because everything above is awaited: an agent spawned past a `stop()` is
// missing from the live-agent map the daemon has by then cleared, so nothing suspends or
// terminates it (#983). Break, not continue: stopping is a verdict on the whole sweep.
if (stopped)
break;
// Armed before the first spawn and once for the whole batch: starting is slow, and a tick
// that overlapped the spawns would otherwise see too few live agents and top up past the cap.
lastStart.set(project.id, now());
const started = [];
for (const item of batch) {
// Re-checked per spawn, for the reason (#983) above: a stop mid-batch must not spawn the rest.
if (stopped)
break;
deps.log(`[framework] auto PM: ${doing(item)} in ${project.path}`);
// A pinned-name job aborts itself when its branch already exists (#1293); a branch
// whose PR closed is not a pending triage, so it is released before the start.
if (item.pinnedBranch)
await deps.releasePinned?.(project, item.pinnedBranch).catch(() => undefined);
const agentId = await deps.start(project, item).catch(() => undefined);
if (!agentId) {
// The batch ends at the first refusal: whatever refused this start is not going to take
// the next one a moment later, and a refused job must be retried rather than skipped.
deps.log(`[framework] auto PM: could not start a run in ${project.path}`);
break;
}
started.push(item);
// Its queue lives on the agent's branch until a later tick promotes it.
pending.set(project.id, [
...(pending.get(project.id) ?? []),
{
agentId,
...(item.entry !== undefined ? { entry: item.entry } : {}),
...(item.ticket !== undefined ? { ticket: item.ticket } : {}),
...(item.claim !== undefined ? { claim: item.claim } : {}),
},
]);
}
// A claim whose agent never started is dead on arrival (#1583): the batch's locks were
// committed and pushed before the first spawn, so a refused start — or a stop mid-batch —
// would strand the claims of every item the loop never reached, with no run that could
// ever settle them free. Released here, not left for the promote loop: these never enter
// `pending`.
for (const item of batch) {
if (item.claim && !started.includes(item))
await deps.releaseLock?.(project, item.claim).catch(() => false);
}
if (started.length) {
// Advanced only on a start that took, so a refused job is retried rather than skipped.
// Draining does not advance it: it is not part of the cycle, and a queue worked off
// over several ticks must not skip the rotation forward once per entry.
//
// A sweep does not advance it either, and stamps its own schedule instead: it is paced
// by the calendar, not the cycle, so borrowing this tick must not cost the rotation its
// turn. Stamped after the start took for the same reason the rotation is -- a sweep the
// daemon refused should be retried next tick, not postponed a whole interval.
if (sweep)
await deps.recordMaintenance?.(project).catch(() => { });
else if (mode === 'pm')
nextJob.set(project.id, index + 1);
// One line per project however many agents went out, and a single start keeps the old
// wording exactly.
const described = started.map(item => doing(item)).join('; ');
note(project, true, started.length === 1 ? described : `started ${started.length} agents: ${described}`);
}
else if (!stopped) {
// Nothing took, so the cooldown armed above is given back: a batch that started nothing
// spent nothing, and holding it would strand the project for a whole cooldown.
lastStart.delete(project.id);
note(project, false, 'the daemon could not start a run');
}
}
}
finally {
sweeping = false;
// Recorded even when the sweep returned early, so "switched off" and "on, and standing
// down for a reason" are distinguishable from the dashboard (#1161).
lastSweep = { enabled, sweptAt: now(), outcomes };
}
};
// No timer of its own (E4): the daemon's one clock calls `tick` every Nth turn. `intervalMs`
// stays as the *declared* cadence — the clock runs this at that multiple, and `nextSweepAt`
// below is what the usage panel reads to say when the next sweep is due.
/** When the sweep is next due, counted from the anchor so an out-of-band {@link tick} cannot skew it. */
const nextSweepAt = () => startedAt + (Math.floor(Math.max(0, now() - startedAt) / intervalMs) + 1) * intervalMs;
// The first sweep is the caller's to fire (see `startBackgroundServices`), not this
// constructor's: `tick` marks the loop busy synchronously, so a sweep started here would make
// the very next `tick()` a no-op — and every test that constructs a loop and ticks it would be
// asserting against a sweep it never awaited.
return {
tick,
report: () => ({
...(lastSweep ? { enabled: lastSweep.enabled, sweptAt: lastSweep.sweptAt } : {}),
nextSweepAt: nextSweepAt(),
outcomes: lastSweep?.outcomes ?? [],
}),
stop: () => {
stopped = true;
},
};
}
//# sourceMappingURL=auto-pm.js.map