From 37fa015a1e88507bf35e8cf2177411248ec8d673 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 16:11:49 -0800 Subject: [PATCH 1/6] chore(quest): claim quest/m0/kix/execution.md Quest-Claim: dd078214e50a6435 From fe46e9160b9248997641c103fc120d5a2c9744ee Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 16:59:38 -0800 Subject: [PATCH 2/6] feat(kix): start ready quests on free runner slots and widen PR decisions A maintainer opts a project in, optionally per milestone, and their hub starts the next unclaimed ready quest in `quest unblocked` order whenever a runner slot frees. Start jobs first check the quest is worth starting and ask start, skip, deprioritize, or delete when in doubt. Held PRs name their holder, a starter's draft quiet for a week is offered for takeover, and a handed-back draft can be marked ready. The quest now covers only delete. Co-Authored-By: Claude Opus 5.5 --- cloudflare/README.md | 27 ++++ cloudflare/dev/github.ts | 4 +- cloudflare/dev/seed.ts | 4 +- cloudflare/migrations/0023_autostart.sql | 11 ++ cloudflare/src/github/service.ts | 26 ++++ cloudflare/src/inbox/model.ts | 71 ++++++--- cloudflare/src/inbox/page.tsx | 73 ++++++++- cloudflare/src/index.tsx | 2 + cloudflare/src/jobs/autostart.ts | 188 +++++++++++++++++++++++ cloudflare/src/jobs/hub.ts | 22 ++- cloudflare/src/jobs/routes.ts | 43 ++++-- cloudflare/src/jobs/store.ts | 7 +- cloudflare/src/jobs/trail.ts | 3 + cloudflare/test/autostart.test.ts | 178 +++++++++++++++++++++ cloudflare/test/inbox.test.ts | 21 ++- cloudflare/test/jobs.test.ts | 7 +- cloudflare/test/seed.test.ts | 8 +- cloudflare/test/trail.test.ts | 3 +- quest/m0/kix/README.md | 2 +- quest/m0/kix/execution.md | 83 +++------- 20 files changed, 672 insertions(+), 111 deletions(-) create mode 100644 cloudflare/migrations/0023_autostart.sql create mode 100644 cloudflare/src/jobs/autostart.ts create mode 100644 cloudflare/test/autostart.test.ts diff --git a/cloudflare/README.md b/cloudflare/README.md index 9de6305d..c531f753 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -261,6 +261,33 @@ awaits a decision, and any other whose issue, pull request, or quest is still observed, so finished work stays set aside. Retrying a failed planning interview keeps its `plans/` target. +A pull request someone holds is in progress with no actions, and names its +holder: the author of a draft without a `## Waiting` (or older `## Decisions +needed`) section, or the `quest:*` label a session holds it with. Such a draft +quiet for a week becomes a "quiet draft" decision offering to take it over +with an iterate job, or to skip it until it changes; a labeled hold is never +offered, since its session or job releases it. Every other open pull request +offers merge, iterate, and skip, and a draft handed back with `## Waiting` +also offers "Mark ready for review" (`POST +/projects/github///pulls//ready` with the `head` on screen), +which the App does once no job of that pull request is active. + +Each project's "Start ready quests on your runners" box opts the viewer in to +starting its ready quests without a click, optionally only in some milestones +(`POST /projects/github///autostart`, stored in `autostart`; +`src/jobs/autostart.ts`). Whenever a dispatch pass leaves one of the +opted-in maintainer's runners with a free slot and a usable account, their +hub starts the next unclaimed ready quest in `quest unblocked` order, +round-robin across their opted-in projects, as an ordinary start job they +requested, after the same current GitHub checks as their own start. A quest +that already had a job, or that they skipped in the inbox at its current +text, waits for them, so a skipped, stopped, or failed start never reruns on +its own. The scheduled sweep wakes every opted-in hub, so quests that became +ready since the last runner event start too. Losing the maintainer role ends +the opt-in. A start job's agent first checks the quest is still worth +starting, and asks one decision when in doubt: start, skip, deprioritize to a +later milestone, or delete. + Beside the decisions (below them on a phone), an Agents card shows each job on the maintainer's projects (`src/inbox/activity.ts`): every target's latest job while it is queued, running, or waiting, and an ended one while the pull diff --git a/cloudflare/dev/github.ts b/cloudflare/dev/github.ts index aedb2868..69c1f9b0 100644 --- a/cloudflare/dev/github.ts +++ b/cloudflare/dev/github.ts @@ -16,6 +16,8 @@ export type Issue = { body?: string; comments?: Comment[]; closed?: boolean; + /** When GitHub last saw activity on it; a fixed past date when absent. */ + updatedAt?: string; }; export type Thread = { path: string; line: number | null; resolved?: boolean; outdated?: boolean; comments: Comment[] }; export type Pull = Issue & { @@ -208,7 +210,7 @@ function entity(repo: Repo, item: Issue) { number: item.number, title: item.title, state: (item as Pull).merged ? "MERGED" : item.closed ? "CLOSED" : "OPEN", - updatedAt, + updatedAt: item.updatedAt ?? updatedAt, closedAt: item.closed ? updatedAt : null, author: { login: item.author }, comments: { totalCount: item.comments?.length ?? 0 }, diff --git a/cloudflare/dev/seed.ts b/cloudflare/dev/seed.ts index a3fdf8de..fcd82f87 100644 --- a/cloudflare/dev/seed.ts +++ b/cloudflare/dev/seed.ts @@ -107,6 +107,7 @@ export function world(): World { review: "CHANGES_REQUESTED", reviews: [{ state: "CHANGES_REQUESTED", author: "reviewer" }], }, + // A draft its starter left quiet for weeks: offered for takeover. { number: 15, title: "Try a shared build cache", @@ -124,7 +125,7 @@ export function world(): World { checks: null, review: "REVIEW_REQUIRED", }, - // A claim its starter still holds: work in progress, not a decision. + // A claim its starter still holds and touched just now: work in progress, not a decision. { number: 17, title: "Draft the theme picker", @@ -134,6 +135,7 @@ export function world(): World { review: null, draft: true, body: "Started on the picker.", + updatedAt: new Date().toISOString(), }, // A draft handed back with decisions: it waits on the maintainer. { diff --git a/cloudflare/migrations/0023_autostart.sql b/cloudflare/migrations/0023_autostart.sql new file mode 100644 index 00000000..e81dc526 --- /dev/null +++ b/cloudflare/migrations/0023_autostart.sql @@ -0,0 +1,11 @@ +-- A maintainer's opt-in to start a project's ready quests on their own +-- runners whenever one of their job slots frees, within the listed milestone +-- directories (a JSON array), or every milestone when NULL. +CREATE TABLE autostart ( + repository INTEGER NOT NULL REFERENCES github_projects(id) ON DELETE CASCADE, + user TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + milestones TEXT, + createdAt INTEGER NOT NULL, + PRIMARY KEY(repository, user) +); +CREATE INDEX autostart_user ON autostart(user); diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts index bd7878c1..d363bcbb 100644 --- a/cloudflare/src/github/service.ts +++ b/cloudflare/src/github/service.ts @@ -1120,6 +1120,32 @@ export class GitHubService { return (await this.#signed(token, repo, pull, revision)) || (await this.#approved(token, repo, pull, revision)); } + /** + * Mark a draft ready for review at the exact head the maintainer saw, as + * the App, accepting it as handed back. GitHub's own rules still decide + * whether and how it merges. + */ + markReady(repo: GitHubRepository, userId: string, pull: number, head: string) { + const permissions = { pull_requests: "write" } as const; + return this.#authorized(repo, userId, permissions, async (token, current) => { + this.#public(current); + const live = await this.#api<{ state: string; draft: boolean; node_id: string; head: { sha: string } }>( + token, + `${repositoryPath(repo)}/pulls/${number(pull)}`, + ); + if (live.state !== "open" || live.head.sha !== sha(head)) + throw new GitHubError("The pull request's head moved since you looked; review it again", 409); + if (live.draft) { + const result = await this.#api<{ errors?: unknown[] }>(token, "/graphql", "POST", { + query: "mutation($id: ID!) { markPullRequestReadyForReview(input: {pullRequestId: $id}) { clientMutationId } }", + variables: { id: live.node_id }, + }); + if (result.errors?.length) throw new GitHubError("Could not mark the pull request ready for review"); + } + await this.#invalidate(repo); + }); + } + /** * Approve one exact open pull request head for host runners, as the * maintainer `userId` saw it. The approval lapses when the head moves, and diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts index bf2ce422..9f073270 100644 --- a/cloudflare/src/inbox/model.ts +++ b/cloudflare/src/inbox/model.ts @@ -7,6 +7,8 @@ import { parse, questBranch } from "quest/core"; import { encodePath } from "../board/model"; import { GitHubError, type Enrollment, GitHubService, NotMaintainer } from "../github/service"; import { type Activity, type ProjectState, type Pull, owned, readState } from "../github/state"; +import { type Autostart, digest } from "../jobs/autostart"; +import * as autostart from "../jobs/autostart"; import { type Prompt, parsePrompt } from "../jobs/prompt"; import { hub } from "../jobs/routes"; import * as store from "../jobs/store"; @@ -20,11 +22,11 @@ export type Read = { repository: number; item: string; seen: string | null; skip /** * What the item asks of the maintainer: answer an agent, pick an account for * a limited job, approve another maintainer's answer to their own job, trust - * a pull request head no runner may run yet, retry a failure, or start work - * on an issue, PR, or ready quest. `null` is work in progress, which only - * informs. + * a pull request head no runner may run yet, retry a failure, start work + * on an issue, PR, or ready quest, or take over a draft its starter left + * quiet for `STALE`. `null` is work in progress, which only informs. */ -export type Need = "answer" | "account" | "approve" | "trust" | "retry" | "start"; +export type Need = "answer" | "account" | "approve" | "trust" | "retry" | "start" | "takeover"; export type Kind = "issue" | "pull" | "quest" | "plan" | "devshell"; export type Item = { @@ -42,6 +44,8 @@ export type Item = { /** The job's open question, or its answer awaiting approval, and its prompt when an agent asked it. */ question: QuestionView | null; prompt: Prompt | null; + /** Who holds a PR nobody may act on: its draft's author, or the hold label a session holds it with. */ + holder: string | null; /** A "trust" decision's exact head, and GitHub's view of its commits beyond the default branch. */ head: { sha: string; compare: string } | null; need: Need | null; @@ -54,14 +58,13 @@ export type Item = { }; /** One project's place in the inbox, or why it is unavailable. */ -export type ProjectView = { project: Enrollment; state: ProjectState | null; error: string | null }; - -// FNV-1a: a short, stable version for a quest's text. -function digest(text: string) { - let hash = 0x811c9dc5; - for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193); - return (hash >>> 0).toString(16); -} +export type ProjectView = { + project: Enrollment; + state: ProjectState | null; + error: string | null; + /** The viewer's opt-in to start this project's ready quests on their runners. */ + autostart?: Autostart | null; +}; function kindOf(target: string): Kind { if (target.startsWith("issues/")) return "issue"; @@ -145,13 +148,31 @@ export function entities(project: Enrollment, state: ProjectState | null) { } /** - * A PR whose observed draft its starter still holds, or that a session holds - * with a hold label, such as a merge job whose landing is queued, so nothing - * waits on the maintainer. + * How long a draft its starter holds can stay quiet before the maintainer is + * offered to take it over. A week outlasts a weekend away from a local + * session while still surfacing abandoned claims. + */ +export const STALE = 7 * 24 * 60 * 60 * 1000; + +/** + * Who holds a PR, so nothing waits on the maintainer: the hold label a + * session holds it with, such as a merge job whose landing is queued, or the + * author of an observed draft they have not handed back. Null when free. */ -function held(entity: Pick) { +function holder(entity: Pick) { const pull = entity.kind === "pull" ? (entity.activity?.data as Pull | null | undefined) : null; - return !!pull && (owned(pull) || !!pull.hold); + if (pull?.hold) return pull.hold; + if (pull && owned(pull)) return pull.author?.login ?? "its starter"; + return null; +} + +/** + * Whether a held draft has been quiet past `STALE`. Only a starter's draft is + * offered: a hold label names a live session or job, which releases its own. + */ +function abandoned(entity: Pick, now: number) { + const pull = entity.kind === "pull" ? (entity.activity?.data as Pull | null | undefined) : null; + return !!pull && !pull.hold && owned(pull) && Date.parse(pull.updatedAt) < now - STALE; } /** What an item is called and links to when nothing observed names its target. */ @@ -180,6 +201,7 @@ export function decisions( jobs: JobView[], reads: Read[], user: string, + now = Date.now(), ): Item[] { const { project } = view; const latest = new Map(); @@ -196,6 +218,7 @@ export function decisions( let question: QuestionView | null = null; let head: Item["head"] = null; let ended = false; + const holds = entity ? holder(entity) : null; if (job?.state === "queued" && store.onPull(job.workflow) && !job.trusted) { // Its head has commits no current maintainer signed, so no runner takes it until one approves the head. const sha = job.head ?? job.revision; @@ -217,10 +240,13 @@ export function decisions( } else if (entity) { // Starting new work on a paused project would be refused, so it is not offered. if (project.state !== "enabled") continue; - // A draft its starter still holds is their work in progress until they hand it back. - if (!held(entity)) { + // A draft its starter still holds is their work in progress until they hand it back or leave it. + if (!holds) { need = "start"; ended = !!job && !changed; + } else if (abandoned(entity, now)) { + need = "takeover"; + ended = !!job && !changed; } } else continue; const read = reads.find((r) => r.repository === project.id && r.item === target); @@ -239,6 +265,7 @@ export function decisions( question, prompt, head, + holder: holds, need, version, unread: need !== null && read?.seen !== version, @@ -323,7 +350,11 @@ export async function load(env: Env, user: string) { const views = await Promise.all( (await github.projects(user)).map(async (project): Promise => { try { - return { project, state: await github.projectState(project, user), error: null }; + const [state, scope] = await Promise.all([ + github.projectState(project, user), + autostart.read(env.DB, project.id, user), + ]); + return { project, state, error: null, autostart: scope }; } catch (error) { if (error instanceof NotMaintainer) return null; if (!(error instanceof GitHubError)) throw error; diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx index c07cb7fa..ddb75f93 100644 --- a/cloudflare/src/inbox/page.tsx +++ b/cloudflare/src/inbox/page.tsx @@ -37,6 +37,8 @@ const recorded: Record = { stop: "Stop requested.", skip: "Skipped until it changes.", merge: "Pull request merged.", + autostart: "Ready quest setting saved.", + ready: "Marked ready for review.", }; const kinds = { pull: "Pull request", issue: "Issue", quest: "Ready quest", plan: "Plan", devshell: "Bootstrap" }; @@ -54,7 +56,8 @@ function needLabel(item: Item) { if (item.skipped && item.job) return `${kinds[item.kind]} ยท job ${item.job.state}`; return item.followups ? "Follow-ups to plan" : kinds[item.kind]; } - if (!item.job) return "Claimed draft"; + if (item.need === "takeover") return "Quiet draft"; + if (!item.job) return item.holder ? `Held by ${item.holder}` : "Claimed draft"; if (item.job.state === "waiting") return "Awaiting approval"; return item.job.state === "queued" ? "Queued" : "Working"; } @@ -62,8 +65,13 @@ function needLabel(item: Item) { /** Plainly what the item's job is doing, with the next useful action where there is one. */ function progress(item: Item, runners: Runners, user: string) { const job = item.job; + const pull = item.kind === "pull" ? (item.activity?.data as Pull | null | undefined) : null; + if (item.need === "takeover") + return `This draft, started by ${item.holder}, has been quiet since ${pull?.updatedAt.slice(0, 10)}. Take it over to iterate on it with your runners, or skip it until it changes.`; if (!job && item.need === null) - return "Its starter holds this draft. It waits on you once they mark it ready or hand back decisions."; + return pull?.hold + ? `A session holds this pull request with its ${pull.hold} label and releases it when done.` + : `This draft is held by ${item.holder ?? "its starter"}. It waits on you once they mark it ready or hand back decisions.`; if (!job || item.need === "start") return null; const whose = job.actor === user ? "your" : "the requester's"; if (item.need === "trust") @@ -406,7 +414,7 @@ function work(item: Item, view: ProjectView) { workflow: "iterate", target: item.target, revision: (item.activity!.data as Pull).headRefOid, - label: "Iterate on this PR", + label: item.need === "takeover" ? "Take over and iterate" : "Iterate on this PR", }; if (item.kind === "issue") return { workflow: "plan", target: item.target, revision: commit, label: "Plan this issue" }; @@ -434,6 +442,12 @@ function landing(item: Item) { return { pull, clean: ["CLEAN", "HAS_HOOKS"].includes(pull.mergeStateStatus ?? "") }; } +/** The observed draft its starter handed back with a `## Waiting` section, which the maintainer may mark ready. */ +function handedBack(item: Item) { + const pull = item.kind === "pull" ? (item.activity?.data as Pull | null | undefined) : null; + return pull?.state === "OPEN" && pull.isDraft && pull.handedBack ? pull : null; +} + function Refresh(props: { project: Item["project"] }) { return (
@@ -446,12 +460,14 @@ function Refresh(props: { project: Item["project"] }) { function Actions(props: { item: Item; view: ProjectView | undefined; runners: Runners }) { const { item, view } = props; const base = projectURL(item.project.owner, item.project.name); - const starts = item.need === "start" || item.need === "retry"; + const starts = item.need === "start" || item.need === "retry" || item.need === "takeover"; const reason = starts ? blocked(item, view) : null; // Only an unblocked item has the observations its work is bound to. const job = starts && !reason ? work(item, view!) : null; // Only an idle item offers merge: no queued, running, or waiting job can still push to its head. const merge = starts && !reason ? landing(item) : null; + // A draft handed back with its waits can be accepted as it is; a draft its starter holds is theirs to mark. + const draft = item.need === "start" && !reason ? handedBack(item) : null; const active = item.job && ["queued", "running", "waiting"].includes(item.job.state) && !item.job.cancel; return (
@@ -473,6 +489,12 @@ function Actions(props: { item: Item; view: ProjectView | undefined; runners: Ru )} + {draft && ( +
+ + +
+ )} {reason ? ( <>

{reason}

@@ -496,7 +518,7 @@ function Actions(props: { item: Item; view: ProjectView | undefined; runners: Ru Review on GitHub )} - {(item.need === "start" || item.need === "retry") && !item.skipped && ( + {starts && !item.skipped && (
@@ -683,6 +705,46 @@ function NewPlan(props: { project: ProjectView["project"]; commit: string; runne ); } +/** + * The viewer's opt-in to start the project's ready quests on their own + * runners whenever a slot frees, in `quest unblocked` order, optionally + * within some milestones. + */ +function Autostart(props: { view: ProjectView }) { + const { project, autostart } = props.view; + const action = `${projectURL(project.owner, project.name)}/autostart`; + const id = `autostart-${project.id}`; + const scope = autostart?.milestones?.join(", ") ?? ""; + return ( +
+ ); +} + function Project(props: { view: ProjectView; runners: Runners }) { const { project, state, error } = props.view; const base = projectURL(project.owner, project.name); @@ -713,6 +775,7 @@ function Project(props: { view: ProjectView; runners: Runners }) { {!error && project.state === "enabled" && state?.project.available && ( )} + {!error && } ); } diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx index a734cd5a..663756a1 100644 --- a/cloudflare/src/index.tsx +++ b/cloudflare/src/index.tsx @@ -26,6 +26,7 @@ import { activeHighlights, cards, highlights } from "./inbox/activity"; import * as inbox from "./inbox/model"; import { AgentCards, InboxPage } from "./inbox/page"; import { purge, retain } from "./jobs/output"; +import * as autostart from "./jobs/autostart"; import { INBOX_HEADER } from "./jobs/hub"; import * as landing from "./jobs/landing"; import { BootstrapRequired, fromForm, hub, inboxURL, jobs, nextURL, resume, stop } from "./jobs/routes"; @@ -755,6 +756,7 @@ export default { fetch: app.fetch, async scheduled(controller, env) { await landing.sweep(env); + await autostart.sweep(env); if (controller.cron !== landing.SWEEP_CRON) await retain(env, Date.now()); }, } satisfies ExportedHandler; diff --git a/cloudflare/src/jobs/autostart.ts b/cloudflare/src/jobs/autostart.ts new file mode 100644 index 00000000..ceeb0dd2 --- /dev/null +++ b/cloudflare/src/jobs/autostart.ts @@ -0,0 +1,188 @@ +// Starting ready work without a click: a maintainer who opts a project in +// lends it their runners, and whenever one of their slots frees, kix starts +// the next unclaimed ready quest in `quest unblocked` order, as +// `/quest-spawn` does locally. Each start is an ordinary start job, so the +// agent's first step still checks the quest is worth the slot and turns a +// doubtful one into a decision. + +import { questBranch, unblocked, parse } from "quest/core"; +import { GitHubError, NotMaintainer, GitHubService, refName, type Enrollment } from "../github/service"; +import { type ProjectState, readState, startable } from "../github/state"; +import * as store from "./store"; +import { JobError } from "./store"; + +/** A milestone directory under `quest/`, such as `m0`. */ +const MILESTONE = /^m\d{1,4}$/; + +/** A maintainer's opt-in on one project: every milestone, or only those named. */ +export type Autostart = { milestones: string[] | null }; + +/** FNV-1a: a short, stable version for a quest's text, which an inbox skip records. */ +export function digest(text: string) { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193); + return (hash >>> 0).toString(16); +} + +/** Parse a form's milestone list, such as `m0, m1`; empty means every milestone. */ +export function milestones(value: unknown): string[] | null { + if (value === undefined || value === "") return null; + if (typeof value !== "string") throw new JobError("Invalid milestones"); + const names = [...new Set(value.split(/[\s,]+/).filter(Boolean))]; + if (names.length > 32 || names.some((name) => !MILESTONE.test(name))) + throw new JobError("Name milestones like m0, m1"); + return names.length ? names : null; +} + +/** A start job for `target`, when observations show it ready and unclaimed. */ +export function startJob(state: ProjectState, target: unknown, failed: store.Published | null) { + if (typeof target !== "string" || !startable(state, target, failed)) + throw new JobError("Only ready, unclaimed quests on the default branch can start"); + const branch = questBranch(target); + if (!refName(branch)) throw new JobError("Rename the quest so its path can be a branch name"); + return { workflow: "start" as const, target, revision: state.project.commit, branch, trusted: true }; +} + +/** + * The quests to start next on one project, best first: ready and unclaimed + * in `quest unblocked` order, within the opted-in milestones, and never a + * quest that already had a job, so a skipped, cancelled, or failed start + * waits for the maintainer instead of rerunning, nor one the maintainer + * skipped in the inbox at its current text. + */ +export function candidates(state: ProjectState, scope: Autostart, taken: Set, skipped: Set) { + const { available, documents } = state.project; + if (!available) return []; + let ranked: string[]; + try { + ranked = unblocked(documents.map((d) => parse(d.path, d.content))).map((quest) => quest.path); + } catch { + // A tree `quest check` rejects has no ranking; its board says why. + return []; + } + const text = new Map(documents.map((d) => [d.path, d.content])); + return ranked.filter( + (path) => + available.includes(path) && + (!scope.milestones || scope.milestones.includes(path.split("/")[1]!)) && + !taken.has(path) && + !skipped.has(`${path}\n${digest(text.get(path) ?? "")}`), + ); +} + +/** Take one item from each queue in turn, so one busy project cannot starve another. */ +function interleave(queues: T[][]): T[] { + const out: T[] = []; + for (let i = 0; queues.some((queue) => i < queue.length); i++) + for (const queue of queues) if (i < queue.length) out.push(queue[i]!); + return out; +} + +/** The owner's opt-ins on enabled projects they still belong to. */ +async function optIns(db: D1Database, owner: string) { + const { results } = await db + .prepare( + `SELECT p.id,p.owner,p.name,p.state,a.milestones FROM autostart a JOIN github_projects p ON p.id=a.repository + JOIN github_project_members m ON m.repository=a.repository AND m.user=a.user + WHERE a.user=? AND p.state='enabled' ORDER BY a.createdAt,p.id`, + ) + .bind(owner) + .all(); + return results.map(({ milestones, ...project }) => ({ + project, + scope: { milestones: milestones ? (JSON.parse(milestones) as string[]) : null }, + })); +} + +/** The owner's opt-in on one project, or null. */ +export async function read(db: D1Database, repository: number, user: string): Promise { + const row = await db + .prepare("SELECT milestones FROM autostart WHERE repository=? AND user=?") + .bind(repository, user) + .first<{ milestones: string | null }>(); + return row ? { milestones: row.milestones ? JSON.parse(row.milestones) : null } : null; +} + +/** Opt a project in for `user`'s runners, or out with `null`. */ +export async function save(db: D1Database, repository: number, user: string, scope: Autostart | null, now: number) { + if (!scope) { + await db.prepare("DELETE FROM autostart WHERE repository=? AND user=?").bind(repository, user).run(); + return; + } + await db + .prepare( + "INSERT INTO autostart(repository,user,milestones,createdAt) VALUES(?,?,?,?) ON CONFLICT(repository,user) DO UPDATE SET milestones=excluded.milestones", + ) + .bind(repository, user, scope.milestones ? JSON.stringify(scope.milestones) : null, now) + .run(); +} + +/** + * Start up to `free` quests across the owner's opted-in projects, from stored + * observations first, then each through the same current GitHub checks as a + * maintainer's own start. Returns how many jobs it created. + */ +export async function autostart(env: Env, owner: string, free: number) { + if (free <= 0) return 0; + const opted = await optIns(env.DB, owner); + if (!opted.length) return 0; + const queues = await Promise.all( + opted.map(async ({ project, scope }) => { + const [state, jobs, skips] = await Promise.all([ + readState(env.DB, project), + env.DB.prepare("SELECT DISTINCT target FROM jobs WHERE repository=?") + .bind(project.id) + .all<{ target: string }>(), + env.DB.prepare( + "SELECT item,skipped FROM inbox_items WHERE user=? AND repository=? AND skipped IS NOT NULL", + ) + .bind(owner, project.id) + .all<{ item: string; skipped: string }>(), + ]); + if (!state) return []; + const taken = new Set(jobs.results.map((row) => row.target)); + const skipped = new Set(skips.results.map((row) => `${row.item}\n${row.skipped}`)); + return candidates(state, scope, taken, skipped).map((target) => ({ project, target })); + }), + ); + const github = new GitHubService(env); + // A project that cannot take a start now, such as one without a flake, is not asked again this pass. + const refused = new Set(); + let started = 0; + for (const { project, target } of interleave(queues)) { + if (started >= free) break; + if (refused.has(project.id)) continue; + try { + const { job, enabled, flake } = await github.jobSource(project, owner, (state) => + startJob(state, target, null), + ); + if (!enabled || !flake) { + refused.add(project.id); + continue; + } + const created = await store.createJob( + env.DB, + { ...job, repository: project.id, actor: owner, pinRunner: null, pinAccount: null }, + Date.now(), + ); + if (created.created) started++; + } catch (error) { + if (error instanceof NotMaintainer) { + // Lost authority ends the opt-in: it lent runners to a project its owner no longer maintains. + await save(env.DB, project.id, owner, null, Date.now()); + refused.add(project.id); + } else if (error instanceof JobError) continue; + else if (error instanceof GitHubError) refused.add(project.id); + else throw error; + } + } + return started; +} + +/** Wake every hub with an opt-in, so quests that became ready since the last runner event start. */ +export async function sweep(env: Env) { + const { results } = await env.DB.prepare( + "SELECT DISTINCT a.user FROM autostart a JOIN github_projects p ON p.id=a.repository WHERE p.state='enabled'", + ).all<{ user: string }>(); + await Promise.all(results.map(({ user }) => env.RUNNERS.get(env.RUNNERS.idFromName(user)).dispatch(user))); +} diff --git a/cloudflare/src/jobs/hub.ts b/cloudflare/src/jobs/hub.ts index 81c54d5c..1e162ad3 100644 --- a/cloudflare/src/jobs/hub.ts +++ b/cloudflare/src/jobs/hub.ts @@ -1,5 +1,6 @@ import { DurableObject } from "cloudflare:workers"; import * as push from "../push/notify"; +import { autostart } from "./autostart"; import * as landing from "./landing"; import * as output from "./output"; import { MessageError, type RunnerMessage, parse } from "./protocol"; @@ -27,6 +28,8 @@ export class RunnerHub extends DurableObject { // Handlers interleave at each D1 await. Runner messages and dispatch passes // run one at a time, so a pass cannot assign from a status it has outlived. #serial: Promise = Promise.resolve(); + // One autostart pass at a time, so two passes cannot fill the same free slot. + #starting = false; async fetch(request: Request) { const viewer = request.headers.get(INBOX_HEADER); @@ -197,7 +200,7 @@ export class RunnerHub extends DurableObject { const reported = (ws.deserializeAttachment() as RunnerAttachment).slots; if (reported !== null) slots.set(runner, reported); } - const { assign, cancel, ended, wake } = await store.dispatch( + const { assign, cancel, ended, wake, idle } = await store.dispatch( this.env.DB, owner, slots, @@ -215,6 +218,23 @@ export class RunnerHub extends DurableObject { if (assign.length || cancel.length || ended.length) await this.#notify(owner); if (wake === null) await this.ctx.storage.deleteAlarm(); else await this.ctx.storage.setAlarm(wake); + // GitHub reads stay outside the serialized pass, so runner messages never wait on them. + if (idle > 0) this.ctx.waitUntil(this.#autostart(owner, idle)); + } + + /** Fill free slots with the owner's opted-in ready quests, then route what it created. */ + async #autostart(owner: string, free: number) { + if (this.#starting) return; + this.#starting = true; + let started = 0; + try { + started = await autostart(this.env, owner, free); + } catch (error) { + console.error({ event: "autostart_failed", name: error instanceof Error ? error.name : "unknown" }); + } finally { + this.#starting = false; + } + if (started) await this.dispatch(owner); } async #handle(ws: WebSocket, attachment: RunnerAttachment, message: RunnerMessage) { diff --git a/cloudflare/src/jobs/routes.ts b/cloudflare/src/jobs/routes.ts index a084ffde..5dad6770 100644 --- a/cloudflare/src/jobs/routes.ts +++ b/cloudflare/src/jobs/routes.ts @@ -1,9 +1,9 @@ import { type Context, Hono } from "hono"; -import { questBranch } from "quest/core"; import { createAuth } from "../auth"; import * as push from "../push/notify"; -import { GitHubError, GitHubService, refName, type Stopped } from "../github/service"; -import { type ProjectState, type Pull, startable } from "../github/state"; +import { GitHubError, GitHubService, type Stopped } from "../github/service"; +import type { ProjectState, Pull } from "../github/state"; +import * as autostart from "./autostart"; import { INBOX_HEADER, RUNNER_HEADER } from "./hub"; import * as landing from "./landing"; import * as output from "./output"; @@ -46,7 +46,7 @@ export function inboxURL(repository: number, target: string) { return `/inbox?${new URLSearchParams({ project: String(repository), item: target })}`; } /** What an inbox form just recorded, which the next decision's page notes. */ -export type Done = "answer" | "approve" | "trust" | "start" | "stop" | "skip" | "merge"; +export type Done = "answer" | "approve" | "trust" | "start" | "stop" | "skip" | "merge" | "autostart" | "ready"; /** * The inbox's next active decision, or its empty state, after a form recorded * one. The item acted on stays reachable from its list, with its earlier answers. @@ -300,13 +300,7 @@ function resolve( !item.missing && item.data?.state === "OPEN", ); - if (workflow === "start") { - if (typeof target !== "string" || !startable(state, target, failed)) - throw new JobError("Only ready, unclaimed quests on the default branch can start"); - const branch = questBranch(target); - if (!refName(branch)) throw new JobError("Rename the quest so its path can be a branch name"); - return { workflow, target, revision: commit, branch, trusted: true }; - } + if (workflow === "start") return autostart.startJob(state, target, failed); if (workflow === "plan") { // A new interview gets its own target. Retrying a failed one keeps it and // its request; planning a finished job's follow-ups targets that job's ID. @@ -406,6 +400,19 @@ jobs.post(base, async (c) => { return reply(c, form, "start", created, created.created ? 201 : 200); }); +// A maintainer lends their own runners to a project's ready quests, or stops. +jobs.post("/projects/github/:owner/:repository/autostart", async (c) => { + const { user, repo } = await maintained(c); + const { body, form } = await input(c); + // A form sends `1` or `0`; the JSON API, a boolean. + const on = form ? { "1": true, "0": false }[String(body.enabled)] : body.enabled; + if (typeof on !== "boolean") throw new JobError("Choose whether to start ready quests"); + const scope = on ? { milestones: autostart.milestones(body.milestones) } : null; + await autostart.save(c.env.DB, repo.id, user, scope, Date.now()); + if (on) await hub(c.env, user).dispatch(user); + return reply(c, form, "autostart", null); +}); + jobs.post(`${base}/:job/answer`, async (c) => { const { user, repo, job } = await owned(c); const { body, form } = await input(c); @@ -479,6 +486,20 @@ jobs.post("/projects/github/:owner/:repository/pulls/:number/approve", async (c) return reply(c, form, "trust", null); }); +// A maintainer accepts a handed-back draft as it is, at the head they saw. +jobs.post("/projects/github/:owner/:repository/pulls/:number/ready", async (c) => { + const { user, repo, github } = await maintained(c); + const { body, form } = await input(c); + const number = c.req.param("number"); + if (!/^[1-9]\d{0,14}$/.test(number) || typeof body.head !== "string") + throw new JobError("Pull request and head required"); + // A job may still push to the pull request, so it stays as the job leaves it. + if (await store.active(c.env.DB, repo.id, `pulls/${number}`)) + throw new JobError("Stop this pull request's job before marking it ready"); + await github.markReady(repo, user, Number(number), body.head); + return reply(c, form, "ready", null); +}); + /** The longest reply to a publication decision, in characters as the inbox form counts them. */ export const PUBLICATION_REPLY = 1000; diff --git a/cloudflare/src/jobs/store.ts b/cloudflare/src/jobs/store.ts index af464153..27be3b53 100644 --- a/cloudflare/src/jobs/store.ts +++ b/cloudflare/src/jobs/store.ts @@ -339,7 +339,7 @@ export type Claimed = NonNullable>> & { owner * A running job has a lease exactly while its runner is disconnected; an * expired lease moves the job. Then queued work is assigned. Returns what to * send each runner, the merge jobs it cancelled that still hold their pull - * request, and when to run again. Every assignment its runner has + * request, when to run again, and how many usable slots stay free. Every assignment its runner has * not acknowledged is sent again, since a delivery can be lost after the * claim commits. Until acknowledged, an assignment holds one of the runner's * reported free slots, which it could not count yet. @@ -484,6 +484,10 @@ export async function dispatch( } assign.push({ runner, job: { ...job, ...project } }); } + // Free slots left on runners with a usable account, which ready work may fill. + let idle = 0; + for (const [runner, n] of free) + if (n > 0 && accounts.some((offer) => offer.runner === runner && !offer.limited)) idle += n; const wake = await db .prepare("SELECT MIN(lease) AS at FROM jobs WHERE actor=? AND state='running' AND lease IS NOT NULL") .bind(owner) @@ -493,6 +497,7 @@ export async function dispatch( cancel: [...(cancels!.results as (Attempt & { runner: string })[]), ...revoked], ended: ended(cancelled!), wake: revoked.length ? now : (wake?.at ?? null), + idle, }; } diff --git a/cloudflare/src/jobs/trail.ts b/cloudflare/src/jobs/trail.ts index cffe5d78..a1cd065a 100644 --- a/cloudflare/src/jobs/trail.ts +++ b/cloudflare/src/jobs/trail.ts @@ -15,6 +15,9 @@ export const BODY_LIMIT = 65_536; /** What a job's agent is told beyond the workflow's own skill, or null. */ export function instructions(workflow: Workflow): string | null { + // kix may start ready quests on its own, so the job first checks the quest still deserves the slot. + if (workflow === "start") + return "Before implementing, check that this quest is still worth starting now: its problem may no longer be real, a later change may have made its plan wrong, or more urgent work may depend on its slot. If it is clearly worth starting, start without asking. If you doubt it, ask the maintainer one decision before changing anything, with these options and your recommendation: Start (implement it as planned), Skip (end without publishing anything), Deprioritize (move it to a later milestone with `quest mv` and deliver only that change), or Delete (abandon it with `quest rm`, edit the mentions it reports, and deliver only that change). Name what made you doubt it."; if (workflow !== "plan") return null; return "kix adds this pull request's decision record from the maintainer's recorded answers. Do not write the interview's questions, options, or answers into the pull request body; summarize the proposed quests instead. Still record each settled decision and its reason in the quest's Plan."; } diff --git a/cloudflare/test/autostart.test.ts b/cloudflare/test/autostart.test.ts new file mode 100644 index 00000000..2fd68ba9 --- /dev/null +++ b/cloudflare/test/autostart.test.ts @@ -0,0 +1,178 @@ +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Repo } from "../dev/github"; +import type { ProjectState } from "../src/github/state"; +import { app } from "../src/index"; +import { autostart, candidates, digest, milestones } from "../src/jobs/autostart"; +import * as store from "../src/jobs/store"; +import { privateKey } from "./github-key"; +import { origin } from "./login-fixtures"; +import { head, maintainerOf } from "./project-fixtures"; + +const testEnv = { ...env, GITHUB_APP_PRIVATE_KEY: privateKey }; +const db = env.DB; + +describe("choosing what to start", () => { + const doc = (path: string, body: string) => ({ path, content: `# [S] ${path}\n\n## Goal\n\nDo it.\n${body}` }); + const documents = [ + { path: "quest/README.md", content: "# Project\n\n## Goal\n\nBuild it.\n" }, + { path: "quest/m0/README.md", content: "# Now\n\n## Goal\n\nFirst.\n" }, + { path: "quest/m1/README.md", content: "# Next\n\n## Goal\n\nLater.\n" }, + doc("quest/m1/a.md", ""), + doc("quest/m1/b.md", ""), + doc("quest/m1/c.md", "\n## Required\n\n- [B](/quest/m1/b.md) - first\n"), + doc("quest/m0/d.md", ""), + doc("quest/m0/claimed.md", ""), + ]; + const state = (available: string[] | null) => ({ project: { documents, available } }) as unknown as ProjectState; + const all = { milestones: null }; + const none = new Set(); + + it("ranks unclaimed ready quests as quest unblocked does, within the opted-in milestones", () => { + const available = ["quest/m0/d.md", "quest/m1/a.md", "quest/m1/b.md"]; + // Milestone first, then downstream impact: b unblocks c. + expect(candidates(state(available), all, none, none)).toEqual([ + "quest/m0/d.md", + "quest/m1/b.md", + "quest/m1/a.md", + ]); + expect(candidates(state(available), { milestones: ["m1"] }, none, none)).toEqual([ + "quest/m1/b.md", + "quest/m1/a.md", + ]); + // Unknown occupancy starts nothing. + expect(candidates(state(null), all, none, none)).toEqual([]); + }); + + it("leaves a quest that already had a job, or that the maintainer skipped at its current text", () => { + const available = ["quest/m0/d.md", "quest/m1/b.md"]; + const d = documents.find((x) => x.path === "quest/m0/d.md")!.content; + expect(candidates(state(available), all, new Set(["quest/m0/d.md"]), none)).toEqual(["quest/m1/b.md"]); + expect(candidates(state(available), all, none, new Set([`quest/m0/d.md\n${digest(d)}`]))).toEqual([ + "quest/m1/b.md", + ]); + // A skip of older text lapses once the quest changes. + expect(candidates(state(available), all, none, new Set(["quest/m0/d.md\nold"]))).toHaveLength(2); + }); + + it("reads a milestone list, refusing anything else", () => { + expect(milestones(undefined)).toBeNull(); + expect(milestones("m0, m1 m0")).toEqual(["m0", "m1"]); + expect(() => milestones("m0,../x")).toThrow("Name milestones like m0, m1"); + }); +}); + +describe("starting ready quests on free slots", () => { + let user = ""; + let cookie = ""; + let repo: Repo; + beforeEach(async () => { + ({ user, cookie, repo } = await maintainerOf()); + }); + afterEach(() => vi.restoreAllMocks()); + const sockets: WebSocket[] = []; + afterEach(() => { + for (const ws of sockets.splice(0)) ws.close(); + }); + + function opt(fields: [string, string][]) { + return app.request( + `${origin}/projects/github/upstream/project/autostart`, + { method: "POST", headers: { Cookie: cookie, Origin: origin }, body: new URLSearchParams(fields) }, + testEnv, + ); + } + async function connect(slots: number) { + const { token } = await store.createRunner(db, user, "laptop", "linux", false, 0); + const response = await app.request( + `${origin}/api/runner/connect`, + { headers: { Authorization: `Bearer ${token}`, Upgrade: "websocket" } }, + testEnv, + ); + const ws = response.webSocket!; + ws.accept(); + sockets.push(ws); + const inbox: any[] = []; + const waiting: ((message: any) => void)[] = []; + ws.addEventListener("message", (event) => { + const message = JSON.parse(event.data as string); + const next = waiting.shift(); + if (next) next(message); + else inbox.push(message); + }); + const next = (): Promise => + inbox.length ? Promise.resolve(inbox.shift()) : new Promise((resolve) => waiting.push(resolve)); + const account = { id: "main", provider: "anthropic", kind: "subscription", label: "Main", limited: false }; + ws.send(JSON.stringify({ id: 1, type: "status", accounts: [account], attempts: [], slots })); + expect(await next()).toMatchObject({ type: "reply", id: 1 }); + return next; + } + const jobs = async () => + (await db.prepare("SELECT target,workflow,actor FROM jobs WHERE repository=10").all()).results; + + it("starts the next ready quest once opted in, and only once", async () => { + const next = await connect(2); + // Without an opt-in, a free slot starts nothing. + expect(await autostart(testEnv, user, 2)).toBe(0); + const saved = await opt([["enabled", "1"]]); + expect(saved.status).toBe(303); + expect(await next()).toMatchObject({ + type: "assign", + job: { workflow: "start", target: "quest/first.md", attempt: 1 }, + instructions: expect.stringContaining("worth starting"), + }); + expect(await jobs()).toEqual([{ target: "quest/first.md", workflow: "start", actor: user }]); + // A slot is still free, but nothing else is ready, and a quest with a job is not started again. + expect(await autostart(testEnv, user, 1)).toBe(0); + const [{ id }] = (await db.prepare("SELECT id FROM jobs").all<{ id: string }>()).results; + await store.cancel(db, id!, user, Date.now()); + expect(await autostart(testEnv, user, 1)).toBe(0); + expect(await jobs()).toHaveLength(1); + }); + + it("keeps to the chosen milestones and stops when opted out", async () => { + expect( + ( + await opt([ + ["enabled", "1"], + ["milestones", "m3"], + ]) + ).status, + ).toBe(303); + expect(await db.prepare("SELECT milestones FROM autostart WHERE user=?").bind(user).first()).toEqual({ + milestones: '["m3"]', + }); + expect(await autostart(testEnv, user, 1)).toBe(0); + expect((await opt([["enabled", "0"]])).status).toBe(303); + expect(await db.prepare("SELECT 1 FROM autostart").first()).toBeNull(); + expect((await opt([["enabled", "maybe"]])).status).toBe(409); + }); + + it("ends the opt-in of a maintainer who lost their role", async () => { + await opt([["enabled", "1"]]); + await db.prepare("DELETE FROM jobs").run(); + for (const login of Object.keys(repo.roles!)) repo.roles![login] = "read"; + expect(await autostart(testEnv, user, 1)).toBe(0); + expect(await db.prepare("SELECT 1 FROM autostart").first()).toBeNull(); + expect(await jobs()).toEqual([]); + }); + + it("marks a handed-back draft ready at the head on screen", async () => { + const pr = repo.pulls[0]!; + pr.draft = true; + const ready = (sha: string) => + app.request( + `${origin}/projects/github/upstream/project/pulls/2/ready`, + { + method: "POST", + headers: { Cookie: cookie, Origin: origin }, + body: new URLSearchParams([["head", sha]]), + }, + testEnv, + ); + expect((await ready("e".repeat(40))).status).toBe(403); + expect(pr.draft).toBe(true); + expect((await ready(head)).status).toBe(303); + expect(pr.draft).toBe(false); + }); +}); diff --git a/cloudflare/test/inbox.test.ts b/cloudflare/test/inbox.test.ts index 33234a87..40f7bc28 100644 --- a/cloudflare/test/inbox.test.ts +++ b/cloudflare/test/inbox.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Pull, Repo } from "../dev/github"; import { app } from "../src/index"; import { QUIET, cards } from "../src/inbox/activity"; -import { questHref } from "../src/inbox/model"; +import { STALE, questHref } from "../src/inbox/model"; import * as output from "../src/jobs/output"; import * as store from "../src/jobs/store"; import { INBOX_HEADER } from "../src/jobs/hub"; @@ -198,27 +198,36 @@ describe("decision inbox", () => { }); it("shows a draft its starter holds as work in progress until it hands back decisions", async () => { - const draft = (handedBack: boolean) => + const draft = (handedBack: boolean, updatedAt = new Date().toISOString()) => db .prepare( - "UPDATE github_project_activity SET data=json_set(data,'$.isDraft',json('true'),'$.handedBack',json(?)),changedAt=changedAt+1 WHERE id='21'", + "UPDATE github_project_activity SET data=json_set(data,'$.isDraft',json('true'),'$.handedBack',json(?),'$.updatedAt',?),changedAt=changedAt+1 WHERE id='21'", ) - .bind(String(handedBack)) + .bind(String(handedBack), updatedAt) .run(); await draft(false); const html = await inbox(); expect(thread(html)).not.toContain(">Fix<"); expect(html.match(/

In progress<\/h2>[\s\S]*?<\/ol>/)![0]).toMatch( - /Fix<\/a>