mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 18:11:01 +00:00
163 lines
5.9 KiB
JavaScript
163 lines
5.9 KiB
JavaScript
// autopilot.mjs — Sequencing engine for "Run on autopilot".
|
|
//
|
|
// Given the live plan, Autopilot drives the agent through the checklist one
|
|
// step at a time: pick the next eligible step (respecting phase ordering),
|
|
// hand it to the agent, wait for the turn to finish, re-scan, and repeat —
|
|
// streaming progress to the canvas after every step.
|
|
//
|
|
// All I/O is injected (`snapshot`, `runTurn`, `buildStepPrompt`, `onProgress`)
|
|
// so the loop is unit-testable without a live session or HTTP server. The pure
|
|
// helpers below are the same selection logic the renderer uses for "Continue
|
|
// here", which keeps the visible recommendation and the automated run in sync.
|
|
|
|
// How long to wait for a single step's turn to go idle. Generous: one
|
|
// modernization step can involve edits, a build, and tests. The wait does not
|
|
// abort in-flight agent work; it only bounds how long Autopilot blocks before
|
|
// treating the step as failed.
|
|
export const AUTOPILOT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
|
|
|
|
export const AUTOPILOT_MAX_STEPS = 25;
|
|
|
|
/** The checklist Autopilot follows: progress.md when present, else plan.md. */
|
|
export function currentSteps(state) {
|
|
if (!state) return [];
|
|
if (state.progress && state.progress.steps && state.progress.steps.length) return state.progress.steps;
|
|
if (state.plan && state.plan.steps) return state.plan.steps;
|
|
return [];
|
|
}
|
|
|
|
/** Stable identity for a step across re-scans (phase + title). */
|
|
export function stepKey(step) {
|
|
if (!step) return "";
|
|
return (step.section || "") + "::" + (step.title || "");
|
|
}
|
|
|
|
/**
|
|
* The next step Autopilot should run: the first not-done step in the active
|
|
* phase, falling back to the first not-done step overall. Mirrors the renderer's
|
|
* "Continue here" so the automated run never jumps ahead of the safe next step.
|
|
* @returns {object|null}
|
|
*/
|
|
export function selectNextStep(state) {
|
|
const steps = currentSteps(state);
|
|
if (!steps.length) return null;
|
|
const ord = (state && state.ordering) || { activeRank: null };
|
|
const inPhase = steps.find(
|
|
(x) => x.status !== "done" && (ord.activeRank == null || x.rank === ord.activeRank)
|
|
);
|
|
return inPhase || steps.find((x) => x.status !== "done") || null;
|
|
}
|
|
|
|
/** Whether the given step is now checked off in a freshly scanned state. */
|
|
export function isStepDone(state, step) {
|
|
const k = stepKey(step);
|
|
const match = currentSteps(state).find((s) => stepKey(s) === k);
|
|
return !!(match && match.status === "done");
|
|
}
|
|
|
|
/** Construct the mutable, serializable run record broadcast to the canvas. */
|
|
export function makeRun({ scope, maxSteps, startRank } = {}) {
|
|
return {
|
|
running: true,
|
|
cancelled: false,
|
|
scope: scope === "all" ? "all" : "phase",
|
|
maxSteps: maxSteps || AUTOPILOT_MAX_STEPS,
|
|
startRank: startRank == null ? null : startRank,
|
|
status: "running",
|
|
current: null,
|
|
completed: [],
|
|
startedAt: Date.now(),
|
|
finishedAt: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Drive the run to completion. Mutates `run` in place and calls
|
|
* `deps.onProgress(run, state|null)` whenever something changes so the caller
|
|
* can broadcast. Resolves with the final `run`.
|
|
*
|
|
* @param {object} run from makeRun()
|
|
* @param {{
|
|
* snapshot: () => Promise<object>,
|
|
* runTurn: (prompt: string) => Promise<any>,
|
|
* buildStepPrompt: (step: object) => string,
|
|
* onProgress: (run: object, state: object|null) => void,
|
|
* log?: Function,
|
|
* }} deps
|
|
*/
|
|
export async function runAutopilot(run, deps) {
|
|
const onProgress = deps.onProgress || (() => {});
|
|
let lastKey = null;
|
|
try {
|
|
while (true) {
|
|
if (run.cancelled) {
|
|
run.status = "cancelled";
|
|
break;
|
|
}
|
|
if (run.completed.length >= run.maxSteps) {
|
|
run.status = "capped";
|
|
break;
|
|
}
|
|
const state = await deps.snapshot();
|
|
const step = selectNextStep(state);
|
|
if (!step) {
|
|
run.status = "completed";
|
|
break;
|
|
}
|
|
// Phase scope: stop once the active phase advances past where we began.
|
|
if (run.scope === "phase" && run.startRank != null && step.rank != null && step.rank > run.startRank) {
|
|
run.status = "phase_done";
|
|
break;
|
|
}
|
|
const key = stepKey(step);
|
|
// Selecting the same step twice running means the previous attempt did
|
|
// not check it off — the agent is stuck or waiting on a decision. Stop
|
|
// and hand control back rather than loop on it.
|
|
if (key === lastKey) {
|
|
run.status = "stuck";
|
|
run.stuck = step.title;
|
|
break;
|
|
}
|
|
lastKey = key;
|
|
|
|
run.current = { title: step.title, section: step.section || null };
|
|
onProgress(run, state);
|
|
if (deps.log) deps.log("Autopilot → " + step.title, { ephemeral: true });
|
|
|
|
let stepError = null;
|
|
try {
|
|
await deps.runTurn(deps.buildStepPrompt(step));
|
|
} catch (e) {
|
|
stepError = (e && e.message) || String(e);
|
|
}
|
|
|
|
const after = await deps.snapshot();
|
|
const done = isStepDone(after, step);
|
|
run.completed.push({
|
|
title: step.title,
|
|
section: step.section || null,
|
|
done,
|
|
error: stepError,
|
|
at: Date.now(),
|
|
});
|
|
run.current = null;
|
|
onProgress(run, after);
|
|
|
|
if (stepError) {
|
|
run.status = "error";
|
|
run.error = stepError;
|
|
break;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
run.status = "error";
|
|
run.error = (e && e.message) || String(e);
|
|
} finally {
|
|
if (run.status === "running") run.status = "completed";
|
|
run.running = false;
|
|
run.finishedAt = Date.now();
|
|
onProgress(run, null);
|
|
}
|
|
return run;
|
|
}
|