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.

664 lines 43 kB
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