g1t/services/runner/src/index.ts

1,047 lines41,693 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Hosted agents: sandboxes on Cloudflare Containers started from an intent1import { Container, type StopParams } from "@cloudflare/containers";
2import { WorkerEntrypoint } from "cloudflare:workers";
3
4import {
Acceptance checks in sandboxes, line comments and review verdicts5 type CheckJob,
6 type G1tEvent,
Issues and pull requests replace intents and attempts7 type Issue,
Agents as a team: lifecycle, merge queue, billing and a new shell8 type LifecycleJob,
9 type Plan,
10 type Comment,
Issues and pull requests replace intents and attempts11 type Pull,
Agents as a team: lifecycle, merge queue, billing and a new shell12 type QueueJob,
Issues and pull requests replace intents and attempts13 type RepoPath,
Hosted agents: sandboxes on Cloudflare Containers started from an intent14 type Result,
15 type RunHostedInput,
16 type RunnerApi,
17 type ServiceBinding,
18 type User,
19 type Viewer,
Agents as a team: lifecycle, merge queue, billing and a new shell20 billingClient,
Hosted agents: sandboxes on Cloudflare Containers started from an intent21 fail,
22 identityClient,
23 ok,
Agents as a team: lifecycle, merge queue, billing and a new shell24 reposClient,
Work service in Rust, with RFC 3339 timestamps25 workClient,
Hosted agents: sandboxes on Cloudflare Containers started from an intent26} from "@g1t/contracts";
27
Agents as a team: lifecycle, merge queue, billing and a new shell28import { type AgentRoutes, type AgentTask, canReachModel, modelEnv } from "./model-env";
Members can read a private repository's pull request forks29
Hosted agents: sandboxes on Cloudflare Containers started from an intent30export interface RunnerEnv {
31 SANDBOX: DurableObjectNamespace<AttemptSandbox>;
32 IDENTITY: ServiceBinding;
Agents as a team: lifecycle, merge queue, billing and a new shell33 REPOS: ServiceBinding;
Work service in Rust, with RFC 3339 timestamps34 WORK: ServiceBinding;
Agents as a team: lifecycle, merge queue, billing and a new shell35 BILLING: ServiceBinding;
36 /**
37 * Secret. The provider's key. Leave it unset when the gateway holds the
38 * key, so that no sandbox ever does.
39 */
Hosted agents: sandboxes on Cloudflare Containers started from an intent40 ANTHROPIC_API_KEY?: string;
41 /**
Agents as a team: lifecycle, merge queue, billing and a new shell42 * Comma-separated usernames who may start agents while workspaces are not
43 * paying with real money: when billing is off, or its cards are pretend.
44 * Once billing is live, anyone may, and the workspace is charged.
Hosted agents: sandboxes on Cloudflare Containers started from an intent45 */
g1t agents: model menu and optional AI Gateway routing46 /**
g1t's agents only for listed workspaces, whatever the state of billing47 * The workspaces whose repositories may use g1t's agents and sandboxes,
48 * comma-separated, or `*` for all. Everything else on g1t works for
49 * everyone; this is what costs money.
50 */
51 HOSTED_AGENT_WORKSPACES: string;
52 /**
Agents as a team: lifecycle, merge queue, billing and a new shell53 * Which model each kind of work runs on, as JSON:
54 * `{ implement, review, update }`, each `{ modelName, model }`.
55 * `modelName` is what people see; `model` is sent to the provider.
g1t agents: model menu and optional AI Gateway routing56 */
Agents as a team: lifecycle, merge queue, billing and a new shell57 AGENT_ROUTES: string;
g1t agents: model menu and optional AI Gateway routing58 /**
59 * A Cloudflare AI Gateway id. When set, model traffic goes through that
60 * gateway, which is where logging, spend limits, caching and fallback
61 * between providers are configured. Empty sends it to the provider
62 * directly.
63 */
64 AI_GATEWAY_ID: string;
65 CLOUDFLARE_ACCOUNT_ID: string;
Agents as a team: lifecycle, merge queue, billing and a new shell66 /** Secret. Authenticates to the gateway, if it requires it. */
g1t agents: model menu and optional AI Gateway routing67 AI_GATEWAY_TOKEN?: string;
Hosted agents: sandboxes on Cloudflare Containers started from an intent68}
69
70/** A run that takes longer than this has its token expire under it. */
71const TOKEN_TTL_SECONDS = 2 * 60 * 60;
Diffs on attempts; hosted agent presented as the g1t agent72/** How g1t's own agent is labelled. What runs behind it is g1t's choice. */
73const AGENT = "g1t-agent";
Hosted agents: sandboxes on Cloudflare Containers started from an intent74
Acceptance checks in sandboxes, line comments and review verdicts75/**
76 * What a sandbox is doing: an agent working on a pull request as someone,
77 * or a run of acceptance checks.
78 */
79type Run =
80 | { kind: "agent"; actor: User; repo: RepoPath; number: number }
Agents as a team: lifecycle, merge queue, billing and a new shell81 | { kind: "checks"; runId: string; token: string }
82 | { kind: "review"; runId: string; token: string }
83 /**
84 * A catch-up merge reports its own failure in the session. One g1t
85 * started by itself names the pull request, so that a failure stops it
86 * from trying again.
87 */
88 | { kind: "update"; pullId?: string }
89 /** The author sent back to address failed checks or a review. */
90 | { kind: "revise"; pullId: string }
91 /** An agent turning an outcome into a plan. */
92 | { kind: "plan"; planId: string; token: string }
93 /** One combined state of a merge queue, being built and checked. */
94 | { kind: "queue"; entryId: string; token: string };
Issues and pull requests replace intents and attempts95type RunRequest = Run & { envVars: Record<string, string> };
Hosted agents: sandboxes on Cloudflare Containers started from an intent96
Acceptance checks in sandboxes, line comments and review verdicts97/** Long enough to clone, install and test; then the token stops working. */
98const CHECKS_TOKEN_TTL_SECONDS = 45 * 60;
99
Hosted agents: sandboxes on Cloudflare Containers started from an intent100/**
Acceptance checks in sandboxes, line comments and review verdicts101 * One sandbox, for one agent or one run of checks. The image's entrypoint
102 * is the g1t runner, which does the work and exits; this class only starts
103 * it and cleans up if it dies without reporting.
Hosted agents: sandboxes on Cloudflare Containers started from an intent104 */
105export class AttemptSandbox extends Container<RunnerEnv> {
106 sleepAfter = "45m";
107
108 async run(request: RunRequest): Promise<void> {
Issues and pull requests replace intents and attempts109 const { envVars, ...run } = request;
110 await this.ctx.storage.put("run", run);
111 await this.start({ envVars, enableInternet: true });
Hosted agents: sandboxes on Cloudflare Containers started from an intent112 }
113
114 override async onStop({ exitCode }: StopParams): Promise<void> {
115 if (exitCode === 0) return;
Acceptance checks in sandboxes, line comments and review verdicts116 const run = await this.ctx.storage.get<Run>("run");
117 if (!run) return;
118 const work = workClient(this.env.WORK);
119 if (run.kind === "checks") {
120 // Refused harmlessly if the run did report before it stopped.
121 await work.reportChecks(run.runId, run.token, {
122 error: "The sandbox stopped before the checks finished.",
123 });
124 return;
125 }
Agents as a team: lifecycle, merge queue, billing and a new shell126 if (run.kind === "review") {
127 await work.failReview(run.runId, run.token, "The sandbox stopped before the review was written.");
128 return;
129 }
130 if (run.kind === "queue") {
131 // Refused harmlessly if the state was reported before it stopped.
132 await work.failQueue(run.entryId, run.token, "The sandbox stopped before the state was checked.");
133 return;
134 }
135 if (run.kind === "plan") {
136 // Refused harmlessly if the plan was reported before it stopped.
137 await work.failPlan(run.planId, run.token, "The sandbox stopped before the plan was written.");
138 return;
139 }
140 if (run.kind === "update" || run.kind === "revise") {
141 if (run.pullId) {
142 await work.stall(
143 run.pullId,
144 run.kind === "update"
145 ? "The agent could not catch up with the branch this will land on. Its session says why."
146 : "The agent could not address what the checks or the review found. Its session says why.",
147 );
148 }
149 return;
150 }
Issues and pull requests replace intents and attempts151 // The runner closes its own pull request when it fails. This covers a
152 // sandbox that was killed before it could; closing twice is refused
Hosted agents: sandboxes on Cloudflare Containers started from an intent153 // harmlessly.
Acceptance checks in sandboxes, line comments and review verdicts154 await work.closePull(run.actor, run.repo, run.number);
g1t agents: model menu and optional AI Gateway routing155 }
156}
157
Agents as a team: lifecycle, merge queue, billing and a new shell158/** How many other pull requests an agent is told about. */
159const MAX_IN_FLIGHT = 12;
160/** How many of each one's files are named. */
161const MAX_FILES_NAMED = 8;
162
163/**
164 * The other work going on in a repository while an agent works in it: the
165 * pull requests in progress, what each is for and which files it changes.
166 * Told to every agent, so that dozens working at once stay out of each
167 * other's way, and recorded in its session so people can see what it knew.
168 */
169type InFlight = { prompt: string | null; note: string | null };
170
171function describeInFlight(others: Pull[], mine: Set<string>): InFlight {
172 if (others.length === 0) return { prompt: null, note: null };
173 const shown = [...others]
174 // Pull requests changing the same files first: those are the ones to watch.
175 .sort(
176 (a, b) =>
177 Number(b.files.some((f) => mine.has(f.path))) - Number(a.files.some((f) => mine.has(f.path))) ||
178 b.number - a.number,
179 )
180 .slice(0, MAX_IN_FLIGHT);
181 const lines = shown.map((pull) => {
182 const files = pull.files.map((file) => file.path);
183 const named = files.slice(0, MAX_FILES_NAMED).join(", ") + (files.length > MAX_FILES_NAMED ? `, and ${files.length - MAX_FILES_NAMED} more` : "");
184 const shared = files.filter((path) => mine.has(path));
185 return `- #${pull.number} ${pull.title}${pull.issue != null ? ` (for issue #${pull.issue})` : ""}, by ${pull.agent}: ${
186 files.length ? `changes ${named}` : "nothing pushed yet"
187 }${shared.length ? `. It also changes ${shared.join(", ")}, which you are changing.` : ""}`;
188 });
189 const prompt = [
190 "Other agents and people are working in this repository at the same time. These pull requests are in progress, and any of them may merge before yours:",
191 lines.join("\n"),
192 "Keep your change to what your task needs. Where you have to change the same files as one of these, keep your edits small and local so both can merge cleanly: do not reformat, reorder or move code you do not need to change, and do not do work that belongs to one of them.",
193 ].join("\n\n");
194 const overlapping = shown.filter((pull) => pull.files.some((f) => mine.has(f.path)));
195 const note =
196 `Told about ${others.length} other pull ${others.length === 1 ? "request" : "requests"} in progress: ${shown.map((p) => `#${p.number}`).join(", ")}.` +
197 (overlapping.length ? ` ${overlapping.map((p) => `#${p.number}`).join(", ")} ${overlapping.length === 1 ? "changes" : "change"} the same files.` : "");
198 return { prompt, note };
199}
200
201/** What a g1t agent may do through g1t's own tools, in its repository. */
202const AGENT_OPERATIONS = [
203 "get_repo",
204 "list_issues",
205 "get_issue",
206 "list_labels",
207 "create_issue",
208 "add_comment",
209 "list_pull_requests",
210 "get_pull_request",
211 "get_pull_request_changes",
212 "read_session",
213 "get_merge_queue",
214 "list_events",
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request215 // Messages people send it while it works, picked up between steps.
216 "take_messages",
Agents ask each other, hand each other work, and answer217 // Asking the agents on other pull requests, and answering them.
218 "message_agent",
219 "answer_message",
Agents as a team: lifecycle, merge queue, billing and a new shell220];
221
222/** How an agent is told to use g1t's tools to work with the others. */
223const WORKING_WITH_OTHERS =
Agents ask each other, hand each other work, and answer224 "You have g1t's own tools (mcp__g1t__…) for this repository. Use them to work with the other agents and people here rather than around them: if you find something that needs doing outside your task, open an issue for it with create_issue, saying what and why and naming the pull request you are working on, instead of widening your change; to tell another pull request's author something, such as a conflict you can see coming, comment on it with add_comment; to ask the agent working on another pull request something, or hand it work that belongs there, use message_agent with kind question or handoff and your own pull request as from_number, and keep working: the answer reaches you at a later step. Answer what other agents send you with answer_message. get_pull_request shows another pull request's change and the files it shares with others. Mention anything you opened, asked or answered in your summary.";
Agents as a team: lifecycle, merge queue, billing and a new shell225
226/** Longest that what people said on a pull request is passed on. */
227const MAX_PEOPLE_SAID_CHARS = 6000;
228/** Accounts that are g1t itself, not people. */
229const NOT_PEOPLE = new Set(["g1t-agent", "g1t"]);
230
231/**
232 * What people have said on a pull request, for an agent working on it: a
233 * person's request outranks the issue's wording and any agent's review.
234 */
235function describePeopleSaid(comments: Comment[]): string | null {
236 const said = comments
237 .filter((comment) => comment.kind !== "event" && !NOT_PEOPLE.has(comment.author.username))
238 .map((comment) => {
239 const where = comment.path ? ` on ${comment.path}${comment.line ? ` line ${comment.line}` : ""}` : "";
240 const verdict =
241 comment.verdict === "request_changes"
242 ? " (asked for changes)"
243 : comment.verdict === "approve"
244 ? " (approved)"
245 : "";
246 return `- ${comment.author.username}${where}${verdict}: ${comment.body.trim()}`;
247 });
248 if (said.length === 0) return null;
249 let text = said.join("\n");
250 if (text.length > MAX_PEOPLE_SAID_CHARS) text = `…${text.slice(-MAX_PEOPLE_SAID_CHARS)}`;
251 return [
252 "What people have said on this pull request, oldest first. A change a person asked for is in scope, even where it goes beyond the issue, and it outranks any agent's review: never ask for it to be undone, and never undo it.",
253 text,
254 ].join("\n\n");
255}
256
257/** What the author is told when sent back to a pull request it made. */
258function buildRevisionPrompt(job: LifecycleJob, inFlight: string | null, peopleSaid: string | null): string {
Hosted agents: sandboxes on Cloudflare Containers started from an intent259 const parts = [
Agents ask each other, hand each other work, and answer260 `You are a coding agent working in the git repository checked out in the current directory. It holds a change you made earlier, which is open as pull request #${job.number}.`,
Agents as a team: lifecycle, merge queue, billing and a new shell261 job.issue
262 ? `It is for issue #${job.issue.number}: ${job.issue.title}\n\n${job.issue.body}`
263 : `The pull request: ${job.title}`,
264 job.description && `What you said you changed:\n\n${job.description}`,
265 job.feedback,
266 job.issue?.checks.length &&
267 `These commands must pass when you are done. Run them if the tools are installed:\n${job.issue.checks.map((check) => `- ${check}`).join("\n")}`,
268 peopleSaid,
269 inFlight,
270 WORKING_WITH_OTHERS,
271 "Address every point above, and nothing else. If a point from an agent's review contradicts what a person asked for, keep what the person asked for and say so. If you disagree with a point, leave the code as it is and say why. Commit your work with a clear message. Do not push; that is done for you. Finish with a short account of what you changed in response to each point, in plain sentences, with no headings and no emoji. Say what you did not verify.",
272 ];
273 return parts.filter(Boolean).join("\n\n");
274}
275
Agents ask each other, hand each other work, and answer276function buildPrompt(issue: Issue, instructions: string, inFlight: string | null, pullNumber: number): string {
Agents as a team: lifecycle, merge queue, billing and a new shell277 const parts = [
Agents ask each other, hand each other work, and answer278 `You are a coding agent working in the git repository checked out in the current directory, on pull request #${pullNumber} of this repository.`,
Issues and pull requests replace intents and attempts279 `Issue #${issue.number}: ${issue.title}`,
280 issue.body,
Hosted agents: sandboxes on Cloudflare Containers started from an intent281 ];
Issues and pull requests replace intents and attempts282 if (issue.checks.length > 0) {
Hosted agents: sandboxes on Cloudflare Containers started from an intent283 parts.push(
Issues and pull requests replace intents and attempts284 `These commands must pass when you are done. Run them if the tools are installed:\n${issue.checks.map((check) => `- ${check}`).join("\n")}`,
Hosted agents: sandboxes on Cloudflare Containers started from an intent285 );
286 }
287 if (instructions) parts.push(instructions);
Agents as a team: lifecycle, merge queue, billing and a new shell288 if (inFlight) parts.push(inFlight);
289 parts.push(WORKING_WITH_OTHERS);
Hosted agents: sandboxes on Cloudflare Containers started from an intent290 parts.push(
Agents as a team: lifecycle, merge queue, billing and a new shell291 "Make the change and keep it focused on the issue. Commit your work with a clear message. Do not push; that is done for you. Finish with a short summary of what you changed and why. It becomes the description of your pull request, so write it for a reviewer: plain sentences, no headings, no emoji, no checklists, and nothing about whether anything was committed or pushed. Say what you did not verify.",
Hosted agents: sandboxes on Cloudflare Containers started from an intent292 );
293 return parts.filter(Boolean).join("\n\n");
294}
295
296export default class RunnerService
297 extends WorkerEntrypoint<RunnerEnv>
298 implements RunnerApi
299{
Agents as a team: lifecycle, merge queue, billing and a new shell300 /**
301 * The JSON protocol the Rust services speak: `POST /rpc/<method>` with the
302 * arguments as the body. The site calls the methods below directly; the
303 * API, which is Rust, reaches them through here. Only bound services can.
304 */
305 async fetch(request: Request): Promise<Response> {
306 const { pathname } = new URL(request.url);
307 if (request.method === "POST" && pathname === "/rpc/run") {
308 const args = (await request.json()) as {
309 actor: User;
310 repo: RepoPath;
311 issue: number;
312 instructions?: string;
313 };
314 return Response.json(
315 await this.run(args.actor, args.repo, args.issue, { instructions: args.instructions }),
316 );
317 }
318 if (request.method === "POST" && pathname === "/rpc/plan") {
319 const args = (await request.json()) as { actor: User; repo: RepoPath; brief: string };
320 return Response.json(await this.plan(args.actor, args.repo, args.brief));
321 }
322 if (request.method === "POST" && pathname === "/rpc/apply_plan") {
323 const args = (await request.json()) as {
324 actor: User;
325 repo: RepoPath;
326 planId: string;
327 assign?: boolean;
328 keep?: number[];
329 };
330 return Response.json(
331 await this.applyPlan(args.actor, args.repo, args.planId, {
332 assign: args.assign,
333 keep: args.keep,
334 }),
335 );
336 }
Hosted agents: sandboxes on Cloudflare Containers started from an intent337 return new Response("Not found\n", { status: 404 });
338 }
339
Agents as a team: lifecycle, merge queue, billing and a new shell340 /**
341 * What a sandbox needs to reach the model routed for `task`, having
342 * opened the run the repository's workspace will be charged for. Refused
343 * when that workspace has no credit.
344 */
345 private async modelEnv(
346 task: AgentTask,
347 repo: RepoPath,
348 pull: number,
349 ): Promise<Result<Record<string, string>>> {
350 const routes: AgentRoutes = JSON.parse(this.env.AGENT_ROUTES);
351 const ticket = await billingClient(this.env.BILLING).startRun({
352 workspace: repo.namespace,
353 repo,
354 number: pull,
355 task,
356 model: routes[task].modelName,
357 });
358 if (!ticket.ok) return ticket;
359 const vars = modelEnv(this.env, routes, task, {
360 repo: `${repo.namespace}/${repo.name}`,
361 pull,
362 });
363 if (ticket.value) {
364 // How the sandbox says what the run cost. Kept from the agent.
365 vars.BILLING_RUN = ticket.value.runId;
366 vars.BILLING_TOKEN = ticket.value.token;
367 }
368 return ok(vars);
369 }
370
371 /** The same, for a step g1t takes by itself: a refusal stops the step. */
372 private async modelEnvOrThrow(
373 task: AgentTask,
374 repo: RepoPath,
375 pull: number,
376 ): Promise<Record<string, string>> {
377 const vars = await this.modelEnv(task, repo, pull);
378 if (!vars.ok) throw new Error(vars.error.message);
379 return vars.value;
g1t agents: model menu and optional AI Gateway routing380 }
381
Agents as a team: lifecycle, merge queue, billing and a new shell382 /**
g1t's agents only for listed workspaces, whatever the state of billing383 * Whether a workspace's repositories may use g1t's agents and sandboxes.
384 * Only those listed, whatever the state of billing: in the preview g1t
385 * pays for the models, so nobody else can spend on them.
Agents as a team: lifecycle, merge queue, billing and a new shell386 */
g1t's agents only for listed workspaces, whatever the state of billing387 private workspaceAllowed(namespace: string): boolean {
388 const listed = this.env.HOSTED_AGENT_WORKSPACES.split(",").map((name) => name.trim().toLowerCase());
389 return listed.includes("*") || listed.includes(namespace.toLowerCase());
Acceptance checks in sandboxes, line comments and review verdicts390 }
391
g1t's agents only for listed workspaces, whatever the state of billing392 /**
393 * Whether `viewer` may put agents to work: in `repo`'s workspace, which
394 * must be allowed and theirs, or with no repo named, in any workspace of
395 * theirs that is allowed.
396 */
397 private allowed(viewer: Viewer, repo?: RepoPath): boolean {
Agents as a team: lifecycle, merge queue, billing and a new shell398 if (!viewer || !canReachModel(this.env)) return false;
g1t's agents only for listed workspaces, whatever the state of billing399 const theirs = (viewer.workspaces ?? []).map((membership) => membership.slug.toLowerCase());
400 if (repo) {
401 return this.workspaceAllowed(repo.namespace) && theirs.includes(repo.namespace.toLowerCase());
402 }
403 return theirs.some((slug) => this.workspaceAllowed(slug));
Acceptance checks in sandboxes, line comments and review verdicts404 }
405
Agents as a team: lifecycle, merge queue, billing and a new shell406 /**
407 * Events from the bus. Each one that could change what a pull request
408 * needs next moves it along: checks when it becomes ready or its head
409 * moves, then whatever the lifecycle says once those have nothing to do.
410 */
Acceptance checks in sandboxes, line comments and review verdicts411 async queue(batch: MessageBatch<G1tEvent>): Promise<void> {
412 for (const message of batch.messages) {
413 const event = message.body;
Agents as a team: lifecycle, merge queue, billing and a new shell414 switch (event.type) {
415 // A pull request opened from a branch is ready from the start; one
416 // opened as a draft is refused until it is marked ready.
417 case "pull.opened":
418 case "pull.ready":
419 case "pull.updated":
420 if (!(await this.startChecks(event.data.pullId))) {
421 await this.advance(event.data.pullId);
422 }
423 // An agent that has finished its change leaves room for another.
424 if (event.type === "pull.ready") await this.startReady(event.data.repoId);
425 break;
426 case "checks.completed":
427 case "review.completed":
428 await this.advance(event.data.pullId);
429 break;
430 // Something joined, left or landed: test the next batch if none is.
431 case "queue.changed":
432 await this.buildQueue(event.data.repoId);
433 break;
434 // A person approved or asked for changes: one may let it merge,
435 // the other sends the agent back.
436 case "comment.created":
437 if (event.data.pullId && event.data.verdict) await this.advance(event.data.pullId);
438 break;
439 // Someone merged a pull request that is behind: bring it up to
440 // date, and the work service lands it when the push arrives.
441 case "pull.merge_requested":
442 await this.catchUpForMerge(event.data.pullId);
443 break;
444 // The branch the others would land on has moved.
445 case "pull.merged":
446 await this.advanceAll(event.data.repoId);
447 break;
448 // Something an issue was waiting on has finished, or an agent has
449 // stopped and left room for another.
450 case "issue.closed":
451 case "pull.closed":
452 await this.startReady(event.data.repoId);
453 break;
Acceptance checks in sandboxes, line comments and review verdicts454 }
455 message.ack();
456 }
457 }
458
Agents as a team: lifecycle, merge queue, billing and a new shell459 /** A sweep, for steps whose trigger was missed or whose sandbox died. */
460 async scheduled(): Promise<void> {
461 await this.advanceAll();
462 await this.startReady();
463 }
464
465 /**
466 * Puts a g1t agent on each issue that was waiting for one and can now
467 * have it: nothing it depends on is still open, and its repository has
468 * room. One that cannot be started goes back in the queue.
469 */
470 private async startReady(repoId?: string): Promise<void> {
471 const work = workClient(this.env.WORK);
472 for (const issue of await work.readyIssues(repoId)) {
473 const started = await this.run(issue.actor, issue.repo, issue.number).catch(
474 (error: unknown) => fail("conflict", String(error)),
475 );
476 if (!started.ok) await work.queueIssue(issue.actor, issue.repo, issue.number, true);
477 }
478 }
479
480 private async advanceAll(repoId?: string): Promise<void> {
481 const pulls = await workClient(this.env.WORK).managedPulls(repoId);
482 for (const pullId of pulls) await this.advance(pullId);
483 }
484
485 /**
486 * Takes the next step for a pull request g1t is seeing through, if it is
487 * g1t's turn. The work service decides and claims the step, so calling
488 * this twice starts nothing twice.
489 */
490 private async advance(pullId: string): Promise<void> {
491 const work = workClient(this.env.WORK);
492 const next = await work.advance(pullId);
493 if (next.action === "none") return;
494 const { job } = next;
495 try {
g1t's agents only for listed workspaces, whatever the state of billing496 if (!canReachModel(this.env) || !this.workspaceAllowed(job.repo.namespace)) {
497 throw new Error("g1t agents are not enabled for this workspace yet.");
Agents as a team: lifecycle, merge queue, billing and a new shell498 }
499 if (next.action === "review") {
500 const started = await this.startReview(pullId);
501 if (!started.ok) throw new Error(started.error.message);
502 } else if (next.action === "revise") {
503 await this.startRevision(job);
504 } else {
505 await this.startCatchUp(job);
506 }
507 } catch (error) {
508 // Stop, and say so on the pull request, instead of trying forever.
509 await work.stall(
510 pullId,
511 `g1t could not start the next step: ${error instanceof Error ? error.message : String(error)}`,
512 );
513 }
514 }
515
516 /** Brings a pull request up to date because a merge is waiting on it. */
517 private async catchUpForMerge(pullId: string): Promise<void> {
518 const work = workClient(this.env.WORK);
519 const job = await work.catchUpJob(pullId);
520 if (!job) return;
521 try {
522 if (!canReachModel(this.env)) throw new Error("g1t agents are not set up.");
523 await this.startCatchUp(job);
524 } catch (error) {
525 await work.stall(
526 pullId,
527 `g1t could not bring this up to date: ${error instanceof Error ? error.message : String(error)}`,
528 );
529 }
530 }
531
532 private async startCatchUp(job: LifecycleJob): Promise<void> {
533 await this.startUpdate({
534 actor: job.author,
535 repo: job.repo,
536 number: job.number,
537 remote: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
538 branch: job.branch ?? job.defaultBranch,
539 defaultBranch: job.defaultBranch,
540 about: [
541 job.title,
542 job.description,
543 job.issue && `Issue #${job.issue.number}: ${job.issue.title}\n\n${job.issue.body}`,
544 ],
545 pullId: job.pullId,
546 });
547 }
548
549 /**
550 * What else is in progress in `repo` besides pull request `number`, told
551 * to the agent working on it and noted in its session.
552 */
553 private async inFlight(actor: User, repo: RepoPath, number: number): Promise<string | null> {
554 const work = workClient(this.env.WORK);
555 const listed = await work.listPulls(repo, actor, "open");
556 if (!listed.ok) return null;
557 const mine = new Set(listed.value.find((pull) => pull.number === number)?.files.map((file) => file.path) ?? []);
558 const others = listed.value.filter((pull) => pull.number !== number);
559 const { prompt, note } = describeInFlight(others, mine);
560 if (note) await work.appendSession(actor, repo, number, [{ kind: "note", text: note }]);
561 return prompt;
562 }
563
Acceptance checks in sandboxes, line comments and review verdicts564 /**
Agents as a team: lifecycle, merge queue, billing and a new shell565 * Starts the next batch of a repository's merge queue, if it has one
566 * ready: a sandbox per entry, all at once, each building the default
567 * branch with that entry and everything ahead of it.
568 */
569 private async buildQueue(repoId: string): Promise<void> {
570 const work = workClient(this.env.WORK);
571 const jobs = await work.queueBuild(repoId);
g1t's agents only for listed workspaces, whatever the state of billing572 // Merge queue sandboxes, like any other, only where they are enabled.
573 const blocked = jobs.filter((job) => !this.workspaceAllowed(job.repo.namespace));
574 if (blocked.length > 0) {
575 await Promise.all(
576 blocked.map((job) =>
577 work.failQueue(
578 job.entryId,
579 job.token,
580 "The merge queue runs in g1t's sandboxes, which are not enabled for this workspace yet. Turn the queue off to merge directly.",
581 ),
582 ),
583 );
584 return;
585 }
Agents as a team: lifecycle, merge queue, billing and a new shell586 // A state whose sandbox could not start fails at once, rather than
587 // holding the queue until it times out.
588 await Promise.all(
589 jobs.map((job) =>
590 this.startQueueRun(job).catch((error: unknown) =>
591 work.failQueue(job.entryId, job.token, `Its sandbox could not start: ${String(error)}`),
592 ),
593 ),
594 );
595 }
596
597 private async startQueueRun(job: QueueJob): Promise<void> {
598 // To read the changes and push the tested state, as a member.
599 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
600 job.actor,
601 `Merge queue for ${job.repo.namespace}/${job.repo.name}`,
602 CHECKS_TOKEN_TTL_SECONDS,
603 );
604 const remote = (path: RepoPath) => `https://g1t.sh/${path.namespace}/${path.name}.git`;
605 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`queue-${job.entryId}-${job.baseCommit}`));
606 await sandbox.run({
607 kind: "queue",
608 entryId: job.entryId,
609 token: job.token,
610 envVars: {
611 MODE: "queue",
612 G1T_API: "https://api.g1t.sh",
613 QUEUE_ENTRY: job.entryId,
614 QUEUE_TOKEN: job.token,
615 G1T_USER: job.actor.username,
616 G1T_TOKEN: token,
617 BASE_REMOTE: remote(job.repo),
618 BASE_COMMIT: job.baseCommit,
619 QUEUE_BRANCH: job.branch,
620 STACK: JSON.stringify(
621 job.stack.map((item) => ({
622 number: item.number,
623 title: item.title,
624 remote: remote(item.source),
625 branch: item.branch,
626 commit: item.commit,
627 })),
628 ),
629 CHECKS: JSON.stringify(job.checks),
630 CONTRACT_CHECKS: JSON.stringify(job.contractChecks),
631 },
632 });
633 }
634
635 /** What people have said on pull request `number`, told to agents working on it. */
636 private async peopleSaid(actor: User, repo: RepoPath, number: number): Promise<string | null> {
637 const found = await workClient(this.env.WORK).getPull(repo, number, actor);
638 return found.ok ? describePeopleSaid(found.value.comments) : null;
639 }
640
641 /** A token for g1t's own tools, for an agent working for `actor` in `repo`. */
642 private async agentToken(actor: User, repo: RepoPath): Promise<string> {
643 const { token } = await identityClient(this.env.IDENTITY).createAgentToken(
644 actor,
645 { repo, operations: AGENT_OPERATIONS },
646 TOKEN_TTL_SECONDS,
647 );
648 return token;
649 }
650
651 private async startRevision(job: LifecycleJob): Promise<void> {
652 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
653 job.author,
654 `g1t agent revising ${job.repo.namespace}/${job.repo.name}#${job.number}`,
655 TOKEN_TTL_SECONDS,
656 );
657 const sandbox = this.env.SANDBOX.get(
658 this.env.SANDBOX.idFromName(`revise-${job.pullId}-${job.round}`),
659 );
660 await sandbox.run({
661 kind: "revise",
662 pullId: job.pullId,
663 envVars: {
664 MODE: "revise",
665 G1T_API: "https://api.g1t.sh",
666 G1T_TOKEN: token,
667 G1T_USER: job.author.username,
668 G1T_REPO: `${job.repo.namespace}/${job.repo.name}`,
669 PULL_NUMBER: String(job.number),
670 GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
671 COMMIT_MESSAGE: `Address feedback on #${job.number}`,
672 G1T_AGENT_TOKEN: await this.agentToken(job.author, job.repo),
673 // Revised from where the branch it will land on is now.
674 UPSTREAM_REMOTE: `https://g1t.sh/${job.repo.namespace}/${job.repo.name}.git`,
675 UPSTREAM_BRANCH: job.defaultBranch,
676 PROMPT: buildRevisionPrompt(
677 job,
678 await this.inFlight(job.author, job.repo, job.number),
679 await this.peopleSaid(job.author, job.repo, job.number),
680 ),
681 ...(await this.modelEnvOrThrow("implement", job.repo, job.number)),
682 },
683 });
684 }
685
686 /**
Acceptance checks in sandboxes, line comments and review verdicts687 * Runs a pull request's acceptance checks in a sandbox of its own. Does
688 * nothing when there is nothing to run.
689 */
690 private async startChecks(pullId: string): Promise<boolean> {
691 const work = workClient(this.env.WORK);
692 const started = await work.startChecks(pullId);
693 if (!started.ok) return false;
694 const job: CheckJob = started.value;
695 // Checks are commands one person wrote, run against code another
g1t's agents only for listed workspaces, whatever the state of billing696 // pushed, on g1t's machines: in the preview, only for the workspaces
697 // sandboxes are enabled for.
698 if (!this.workspaceAllowed(job.repo.namespace)) {
Acceptance checks in sandboxes, line comments and review verdicts699 await work.reportChecks(job.runId, job.token, { skip: true });
700 return false;
701 }
702 // To read the commit, which may be private, as the one who pushed it.
703 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
704 job.author,
705 `Checks on ${job.repo.namespace}/${job.repo.name}#${job.number}`,
706 CHECKS_TOKEN_TTL_SECONDS,
707 );
708 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(job.runId));
709 await sandbox.run({
710 kind: "checks",
711 runId: job.runId,
712 token: job.token,
713 envVars: {
714 MODE: "checks",
715 G1T_API: "https://api.g1t.sh",
716 CHECK_RUN: job.runId,
717 CHECK_TOKEN: job.token,
718 G1T_USER: job.author.username,
719 G1T_TOKEN: token,
720 GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
721 GIT_COMMIT: job.commit,
722 CHECKS: JSON.stringify(job.commands),
723 },
724 });
725 return true;
726 }
727
Agents as a team: lifecycle, merge queue, billing and a new shell728 /**
729 * A refusal if `actor` may not put g1t agents to work on `repo`: agents
730 * are not enabled for them, or the work would be charged to a workspace
731 * they do not belong to or that has no credit.
732 */
733 private async refusal(actor: User, repo: RepoPath): Promise<Result<never> | null> {
g1t's agents only for listed workspaces, whatever the state of billing734 if (!this.workspaceAllowed(repo.namespace)) {
735 return fail(
736 "forbidden",
737 `g1t agents are in preview and not enabled for the ${repo.namespace} workspace yet. Everything else works, and you can bring your own agent.`,
738 );
739 }
740 if (!this.allowed(actor, repo)) {
741 return fail("forbidden", `Only members of ${repo.namespace} can put g1t agents to work there.`);
Agents as a team: lifecycle, merge queue, billing and a new shell742 }
743 const billing = billingClient(this.env.BILLING);
744 if (!(await billing.status()).enabled) return null;
745 const member = (actor.workspaces ?? []).some(
746 (membership) => membership.slug === repo.namespace.toLowerCase(),
747 );
748 if (!member) {
749 return fail(
750 "forbidden",
751 `Agents are charged to the ${repo.namespace} workspace, so only its members can put them to work here.`,
752 );
753 }
754 const credit = await billing.canStart(repo.namespace);
755 return credit.ok ? null : credit;
756 }
757
758 async update(actor: User, repo: RepoPath, number: number): Promise<Result<boolean>> {
759 const refused = await this.refusal(actor, repo);
760 if (refused) return refused;
761 const found = await workClient(this.env.WORK).getPull(repo, number, actor);
762 if (!found.ok) return found;
763 const { pull, issue, behind } = found.value;
764 if (pull.status !== "draft" && pull.status !== "open") {
765 return fail("conflict", `This pull request is already ${pull.status}.`);
766 }
767 if (!behind) return fail("conflict", "This pull request is already up to date.");
768 // The result is pushed as the person asking, so they must be able to
769 // push there: a fork takes pushes only from whoever opened it.
770 const member = (actor.workspaces ?? []).some(
771 (membership) => membership.slug === repo.namespace,
772 );
773 if (pull.fork ? pull.author.id !== actor.id : !member) {
774 return fail(
775 "forbidden",
776 pull.fork
777 ? "Only whoever opened this pull request can update it."
778 : "Only members of the workspace can update this pull request.",
779 );
780 }
781 const defaultBranch = await this.defaultBranch(repo, actor);
782 await this.startUpdate({
783 actor,
784 repo,
785 number,
786 remote: pull.fork
787 ? `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`
788 : `https://g1t.sh/${repo.namespace}/${repo.name}.git`,
789 branch: pull.branch ?? defaultBranch,
790 defaultBranch,
791 about: [pull.title, pull.body, issue && `Issue #${issue.number}: ${issue.title}\n\n${issue.body}`],
792 });
793 return ok(true);
794 }
795
796 /** Starts a sandbox that merges the default branch into a pull request. */
797 private async startUpdate(update: {
798 /** Who the result is pushed as. */
799 actor: User;
800 repo: RepoPath;
801 number: number;
802 /** The pull request's source, and the branch of it holding the change. */
803 remote: string;
804 branch: string;
805 defaultBranch: string;
806 /** What the pull request is for, given to the agent on a conflict. */
807 about: (string | null | undefined | false)[];
808 /** Set when g1t started this itself. */
809 pullId?: string;
810 }): Promise<void> {
811 const { actor, repo, number } = update;
812 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
813 actor,
814 `Catching up ${repo.namespace}/${repo.name}#${number}`,
815 TOKEN_TTL_SECONDS,
816 );
817 const sandbox = this.env.SANDBOX.get(
818 this.env.SANDBOX.idFromName(`update-${repo.namespace}-${repo.name}-${number}-${Date.now()}`),
819 );
820 await sandbox.run({
821 kind: "update",
822 pullId: update.pullId,
823 envVars: {
824 MODE: "update",
825 G1T_API: "https://api.g1t.sh",
826 G1T_TOKEN: token,
827 G1T_USER: actor.username,
828 G1T_REPO: `${repo.namespace}/${repo.name}`,
829 PULL_NUMBER: String(number),
830 GIT_REMOTE: update.remote,
831 GIT_BRANCH: update.branch,
832 UPSTREAM_REMOTE: `https://g1t.sh/${repo.namespace}/${repo.name}.git`,
833 UPSTREAM_BRANCH: update.defaultBranch,
834 PROMPT: update.about.filter(Boolean).join("\n\n"),
835 ...(await this.modelEnvOrThrow("update", repo, number)),
836 },
837 });
838 }
839
840 async review(actor: User, repo: RepoPath, number: number): Promise<Result<boolean>> {
841 const refused = await this.refusal(actor, repo);
842 if (refused) return refused;
843 // Whoever can see a pull request can ask for it to be reviewed.
844 const found = await workClient(this.env.WORK).getPull(repo, number, actor);
845 if (!found.ok) return found;
846 if (found.value.reviewPending) {
847 return fail("conflict", "A g1t agent is already reviewing this pull request.");
848 }
849 return this.startReview(found.value.pull.id);
850 }
851
852 /** Starts a sandbox in which a g1t agent reviews a pull request. */
853 private async startReview(pullId: string): Promise<Result<boolean>> {
854 const started = await workClient(this.env.WORK).startReview(pullId);
855 if (!started.ok) return started;
856 const job = started.value;
857 const { repo, number } = job;
858 // To read the commit, which may be private, as the one who pushed it.
859 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
860 job.author,
861 `Review of ${repo.namespace}/${repo.name}#${number}`,
862 CHECKS_TOKEN_TTL_SECONDS,
863 );
864 const about = [
865 `Pull request #${job.number}: ${job.title}`,
866 job.description,
867 job.issue &&
868 `It is for issue #${job.issue.number}: ${job.issue.title}\n\n${job.issue.body}`,
869 job.issue?.checks.length &&
870 `The issue's acceptance checks: ${job.issue.checks.join("; ")}`,
871 await this.peopleSaid(job.author, repo, number),
872 ];
873 const model = await this.modelEnv("review", repo, number);
874 if (!model.ok) {
875 await workClient(this.env.WORK).failReview(job.runId, job.token, model.error.message);
876 return model;
877 }
878 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(job.runId));
879 await sandbox.run({
880 kind: "review",
881 runId: job.runId,
882 token: job.token,
883 envVars: {
884 MODE: "review",
885 G1T_API: "https://api.g1t.sh",
886 REVIEW_RUN: job.runId,
887 REVIEW_TOKEN: job.token,
888 G1T_USER: job.author.username,
889 G1T_TOKEN: token,
890 GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
891 GIT_COMMIT: job.commit,
892 UPSTREAM_REMOTE: `https://g1t.sh/${job.repo.namespace}/${job.repo.name}.git`,
893 UPSTREAM_BRANCH: job.defaultBranch,
894 PROMPT: about.filter(Boolean).join("\n\n"),
895 ...model.value,
896 },
897 });
898 return ok(true);
899 }
900
901 private async defaultBranch(repo: RepoPath, viewer: Viewer): Promise<string> {
902 const found = await reposClient(this.env.REPOS).get(repo, viewer);
903 return found.ok ? found.value.defaultBranch : "main";
904 }
905
Acceptance checks in sandboxes, line comments and review verdicts906 async recheck(actor: User, repo: RepoPath, number: number): Promise<Result<boolean>> {
907 const found = await workClient(this.env.WORK).getPull(repo, number, actor);
908 if (!found.ok) return found;
909 const { pull } = found.value;
910 const member = (actor.workspaces ?? []).some(
911 (membership) => membership.slug === repo.namespace,
912 );
913 if (!member && pull.author.id !== actor.id) {
914 return fail(
915 "forbidden",
916 "Only whoever opened a pull request, or a member of the workspace, can run its checks.",
917 );
918 }
919 return (await this.startChecks(pull.id))
920 ? ok(true)
921 : fail("conflict", "There are no checks to run for this pull request right now.");
Hosted agents: sandboxes on Cloudflare Containers started from an intent922 }
923
Agents as a team: lifecycle, merge queue, billing and a new shell924 async plan(actor: User, repo: RepoPath, brief: string): Promise<Result<{ planId: string }>> {
925 const refused = await this.refusal(actor, repo);
926 if (refused) return refused;
927 const work = workClient(this.env.WORK);
928 const started = await work.startPlan(actor, repo, brief);
929 if (!started.ok) return started;
930 const job = started.value;
931 const model = await this.modelEnv("plan", repo, 0);
932 if (!model.ok) {
933 await work.failPlan(job.planId, job.token, model.error.message);
934 return model;
935 }
936 // To read the repository, which may be private, as the one planning.
937 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
938 actor,
939 `Planning for ${repo.namespace}/${repo.name}`,
940 CHECKS_TOKEN_TTL_SECONDS,
941 );
942 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(job.planId));
943 await sandbox.run({
944 kind: "plan",
945 planId: job.planId,
946 token: job.token,
947 envVars: {
948 MODE: "plan",
949 G1T_API: "https://api.g1t.sh",
950 PLAN_ID: job.planId,
951 PLAN_TOKEN: job.token,
952 G1T_USER: actor.username,
953 G1T_TOKEN: token,
954 GIT_REMOTE: `https://g1t.sh/${repo.namespace}/${repo.name}.git`,
955 PROMPT: job.brief,
956 ...model.value,
957 },
958 });
959 return ok({ planId: job.planId });
960 }
961
962 async applyPlan(
963 actor: User,
964 repo: RepoPath,
965 planId: string,
966 options: { assign?: boolean; keep?: number[] } = {},
967 ): Promise<Result<Plan>> {
968 if (options.assign) {
969 const refused = await this.refusal(actor, repo);
970 if (refused) return refused;
971 }
972 const applied = await workClient(this.env.WORK).applyPlan(actor, repo, planId, options);
973 if (!applied.ok) return applied;
974 // Agents start on everything that depends on nothing; the rest follow
975 // as what they depend on merges.
976 if (options.assign) await this.startReady(applied.value.repoId);
977 return applied;
978 }
979
g1t's agents only for listed workspaces, whatever the state of billing980 async enabled(viewer: Viewer, repo?: RepoPath): Promise<boolean> {
981 return this.allowed(viewer, repo);
g1t agents: model menu and optional AI Gateway routing982 }
983
Hosted agents: sandboxes on Cloudflare Containers started from an intent984 async run(
985 actor: User,
Issues and pull requests replace intents and attempts986 repo: RepoPath,
987 issueNumber: number,
Agents as a team: lifecycle, merge queue, billing and a new shell988 input: RunHostedInput = {},
989 ): Promise<Result<Pull>> {
990 const refused = await this.refusal(actor, repo);
991 if (refused) return refused;
Work service in Rust, with RFC 3339 timestamps992 const work = workClient(this.env.WORK);
Hosted agents: sandboxes on Cloudflare Containers started from an intent993
Issues and pull requests replace intents and attempts994 const found = await work.getIssue(repo, issueNumber, actor);
995 if (!found.ok) return found;
996 const { issue } = found.value;
997
Agents as a team: lifecycle, merge queue, billing and a new shell998 const opened = await work.openPull(actor, repo, {
999 issue: issue.number,
1000 agent: AGENT,
1001 runtime: "hosted",
1002 });
1003 if (!opened.ok) return opened;
1004 const pull = opened.value;
1005 // Opened without a branch, so it has a fork.
1006 const fork = pull.fork!;
Hosted agents: sandboxes on Cloudflare Containers started from an intent1007
Agents as a team: lifecycle, merge queue, billing and a new shell1008 const model = await this.modelEnv("implement", repo, pull.number);
1009 if (!model.ok) {
1010 await work.closePull(actor, repo, pull.number);
1011 return model;
Hosted agents: sandboxes on Cloudflare Containers started from an intent1012 }
Agents as a team: lifecycle, merge queue, billing and a new shell1013
1014 // The sandbox acts as the person who assigned the issue, through a
1015 // token that only lives as long as a run can.
1016 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
1017 actor,
1018 `g1t agent on ${repo.namespace}/${repo.name}#${pull.number}`,
1019 TOKEN_TTL_SECONDS,
1020 );
1021 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(pull.id));
1022 await sandbox.run({
1023 kind: "agent",
1024 actor,
1025 repo,
1026 number: pull.number,
1027 envVars: {
1028 G1T_API: "https://api.g1t.sh",
1029 G1T_TOKEN: token,
1030 G1T_USER: actor.username,
1031 G1T_REPO: `${repo.namespace}/${repo.name}`,
1032 PULL_NUMBER: String(pull.number),
1033 GIT_REMOTE: `https://g1t.sh/${fork.namespace}/${fork.name}.git`,
1034 COMMIT_MESSAGE: issue.title,
1035 G1T_AGENT_TOKEN: await this.agentToken(actor, repo),
1036 PROMPT: buildPrompt(
1037 issue,
1038 input.instructions?.trim() ?? "",
1039 await this.inFlight(actor, repo, pull.number),
Agents ask each other, hand each other work, and answer1040 pull.number,
Agents as a team: lifecycle, merge queue, billing and a new shell1041 ),
1042 ...model.value,
1043 },
1044 });
1045 return ok(pull);
Hosted agents: sandboxes on Cloudflare Containers started from an intent1046 }
1047}