Public: anyone can read this; the crew can change it.
An agent's pull opens at its first push, marked in progress, and the run's end fills that same pull in #1978
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1965-an-agent-s-pull-opens-at-its-first-push → main 9 commits fork 8 threads · 8 unresolved
6 files
changed +409
−17
README.md +10 −5
| @@ -495,11 +495,16 @@ reads and writes that repository and administers nothing. | ||
| 495 | 495 | Assign an issue to an agent and it starts: the forge clones the repository, |
| 496 | 496 | puts the agent on a branch named after the issue, and runs Claude Code |
| 497 | 497 | headless with a brief that says who it is, what the task is, how to finish, |
| 498 | and what will be refused. When it stops, the forge pushes what it committed, | |
| 499 | opens the pull (the agent's last message is the description), and the | |
| 500 | preview comes up. Leave feedback on the pull — a comment, a request for | |
| 501 | changes, a line comment — and the agent picks it up in the background, | |
| 502 | resuming its session. A failed check re-engages it too, a bounded number of | |
| 498 | and what will be refused. The pull opens at the agent's first push, marked | |
| 499 | "In progress" with a link to the run's log, and its preview follows each later | |
| 500 | push (`src/crew/early.ts`). When the agent stops, the forge pushes what it | |
| 501 | committed and fills in that same pull (the agent's last message is the | |
| 502 | description). A run that ends without reporting leaves the pull saying so, with | |
| 503 | the log link; a run that is only paused (a limit, an outage) keeps saying "In | |
| 504 | progress". A body a person has edited is left alone, unless they kept its first | |
| 505 | words ("In progress:" or "The run ended"); change those words too. Leave | |
| 506 | feedback on the pull — a comment, a request for changes, a line comment — and | |
| 507 | the agent picks it up in the background, resuming its session. A failed check re-engages it too, a bounded number of | |
| 503 | 508 | times before a person must speak. |
| 504 | 509 | |
| 505 | 510 | **Three rounds, then a person.** When the crew reviewer has asked for |
src/crew/early.ts added +169
| @@ -0,0 +1,169 @@ | ||
| 1 | /** | |
| 2 | * The pull that opens at the agent's first push, not at the run's end. | |
| 3 | * | |
| 4 | * While the agent works, its branch in the fork is looked at once a second | |
| 5 | * for the first few seconds, then every few; each look costs git processes. | |
| 6 | * The first time it is a commit beyond the base, the pull opens with the | |
| 7 | * title, base and head `landWork` would use, and a body that says the work | |
| 8 | * is in progress and where the run's log is. Every later push reaches the | |
| 9 | * pull's preview as any push does. At the end `landWork` finds this pull by | |
| 10 | * its head and fills it in (`fillPull`); it never opens a second. | |
| 11 | */ | |
| 12 | ||
| 13 | import { openPull } from "../actions/pulls.ts"; | |
| 14 | import { editIssue } from "../actions/issues.ts"; | |
| 15 | import type { App } from "../app.ts"; | |
| 16 | import type { Agent } from "../core/agents.ts"; | |
| 17 | import type { Issue } from "../core/issues.ts"; | |
| 18 | import { listPulls, type Pull } from "../core/pulls.ts"; | |
| 19 | import type { Repo } from "../core/repos.ts"; | |
| 20 | import type { Run } from "../core/runs.ts"; | |
| 21 | import { aheadCount, resolveOid } from "../git/merge.ts"; | |
| 22 | ||
| 23 | /** How often the branch is looked at at first. */ | |
| 24 | export const WATCH_MS = 1000; | |
| 25 | ||
| 26 | /** Looks at the quick rate before the watcher slows to `SLOW_FACTOR` times it. */ | |
| 27 | export const QUICK_LOOKS = 5; | |
| 28 | export const SLOW_FACTOR = 5; | |
| 29 | ||
| 30 | /** The body's first words while the run is still going. */ | |
| 31 | export const IN_PROGRESS = "In progress:"; | |
| 32 | ||
| 33 | /** The body's first words once the run has ended without reporting on it. */ | |
| 34 | export const ENDED = "The run ended before it reported on this work."; | |
| 35 | ||
| 36 | /** The line that links the pull to the issue (or pull) it answers. */ | |
| 37 | export function trailerFor(issue: Issue): string { | |
| 38 | return issue.kind === "pull" | |
| 39 | ? `Continues #${issue.number}` | |
| 40 | : `Closes #${issue.number}`; | |
| 41 | } | |
| 42 | ||
| 43 | /** The open pull whose head is this branch of this fork, if any. */ | |
| 44 | export function pullOfBranch( | |
| 45 | app: App, | |
| 46 | repo: Repo, | |
| 47 | fork: Repo, | |
| 48 | branch: string, | |
| 49 | ): Pull | undefined { | |
| 50 | return listPulls(app.db, repo.id, { | |
| 51 | sealed: true, | |
| 52 | state: "open", | |
| 53 | limit: 200, | |
| 54 | }).find((p) => p.headRepoId === fork.id && p.headBranch === branch); | |
| 55 | } | |
| 56 | ||
| 57 | export interface EarlyInput { | |
| 58 | app: App; | |
| 59 | repo: Repo; | |
| 60 | agent: Agent; | |
| 61 | issue: Issue; | |
| 62 | run: Run; | |
| 63 | fork: Repo; | |
| 64 | branch: string; | |
| 65 | forkBare: string; | |
| 66 | bare: string; | |
| 67 | forgeUrl: string; | |
| 68 | write: (line: string) => void; | |
| 69 | variantGroup: string | null; | |
| 70 | /** A pull already stands for this work: nothing to open. */ | |
| 71 | continuing: boolean; | |
| 72 | intervalMs?: number; | |
| 73 | } | |
| 74 | ||
| 75 | export interface EarlyPull { | |
| 76 | stop: () => Promise<void>; | |
| 77 | /** The run is over: a pull still saying "in progress" says it ended. */ | |
| 78 | settle: () => void; | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Start watching; `stop` ends it, waiting for an open in flight. A push in | |
| 83 | * the last second is not looked for: `landWork` opens the pull for it. | |
| 84 | */ | |
| 85 | export function watchForPull(input: EarlyInput): EarlyPull { | |
| 86 | const log = `${input.forgeUrl}/${input.repo.ownerName}/${input.repo.name}/runs/${input.run.id}`; | |
| 87 | let done = input.continuing; | |
| 88 | let busy: Promise<void> = Promise.resolve(); | |
| 89 | const tick = async (): Promise<void> => { | |
| 90 | if (done) return; | |
| 91 | const head = await resolveOid(input.forkBare, `refs/heads/${input.branch}`); | |
| 92 | if (head === undefined || done) return; | |
| 93 | const base = await resolveOid(input.bare, `refs/heads/${input.repo.defaultBranch}`); | |
| 94 | if (base === undefined || done) return; | |
| 95 | if ((await aheadCount(input.forkBare, base, head)) === 0 || done) return; | |
| 96 | // Set before the open, so a failed open is not retried every second: | |
| 97 | // `landWork` opens the pull at the run's end when this did not. | |
| 98 | done = true; | |
| 99 | if (pullOfBranch(input.app, input.repo, input.fork, input.branch) !== undefined) | |
| 100 | return; | |
| 101 | const opened = await openPull(input.app, input.agent.user, input.repo, { | |
| 102 | title: input.issue.title, | |
| 103 | body: `${IN_PROGRESS} @${input.agent.user.username} is still working on this; the description is filled in when the run ends. Run log: ${log}\n\n${trailerFor(input.issue)}`, | |
| 104 | baseBranch: input.repo.defaultBranch, | |
| 105 | headBranch: input.branch, | |
| 106 | headRepo: input.fork, | |
| 107 | ...(input.variantGroup === null ? {} : { variantGroup: input.variantGroup }), | |
| 108 | }); | |
| 109 | input.write( | |
| 110 | opened.ok | |
| 111 | ? `opened pull #${opened.value.number} at the first push, in progress` | |
| 112 | : `could not open the pull early: ${JSON.stringify(opened.error)}`, | |
| 113 | ); | |
outdated
fillPull only runs on the landing path. Add a counterpart for abnormal ends that replaces the In progress body with an honest ended-early note and the run log link. |
||
| 114 | }; | |
| 115 | const every = input.intervalMs ?? WATCH_MS; | |
| 116 | let looks = 0; | |
| 117 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 118 | const schedule = (): void => { | |
| 119 | if (done) return; | |
| 120 | timer = setTimeout( | |
| 121 | () => { | |
| 122 | looks++; | |
| 123 | busy = busy | |
| 124 | .then(tick) | |
| 125 | .catch(() => undefined) | |
| 126 | .then(schedule); | |
| 127 | }, | |
| 128 | looks < QUICK_LOOKS ? every : every * SLOW_FACTOR, | |
| 129 | ); | |
| 130 | }; | |
| 131 | schedule(); | |
| 132 | return { | |
| 133 | settle: () => { | |
| 134 | const pull = pullOfBranch(input.app, input.repo, input.fork, input.branch); | |
| 135 | if (pull === undefined || !pull.body.startsWith(IN_PROGRESS)) return; | |
| 136 | editIssue(input.app, input.agent.user, input.repo, pull, { | |
| 137 | body: `${ENDED} Run log: ${log}\n\n${trailerFor(input.issue)}`, | |
| 138 | }); | |
| 139 | }, | |
| 140 | stop: async () => { | |
| 141 | clearTimeout(timer); | |
| 142 | done = true; | |
| 143 | await busy; | |
| 144 | }, | |
| 145 | }; | |
| 146 | } | |
| 147 | ||
| 148 | /** | |
| 149 | * The body still begins with the words the early open wrote, or `settle` | |
| 150 | * (a run that ended without reporting, then resumed by feedback). A person | |
| 151 | * who edits the body but keeps those words has it filled in over. | |
| 152 | */ | |
| 153 | export function isEarlyBody(body: string): boolean { | |
outdated
This accepts ENDED only so that a resumed run can overwrite the false 'ended' that a pause wrote. Once pauses stop settling, this second prefix should not be needed. |
||
| 154 | return body.startsWith(IN_PROGRESS) || body.startsWith(ENDED); | |
| 155 | } | |
| 156 | ||
| 157 | /** The run is over: the early pull's body becomes the run's report. */ | |
| 158 | export function fillPull( | |
| 159 | app: App, | |
| 160 | agent: Agent, | |
| 161 | repo: Repo, | |
| 162 | pull: Pull, | |
| 163 | issue: Issue, | |
| 164 | report: string, | |
| 165 | ): void { | |
| 166 | if (!isEarlyBody(pull.body)) return; | |
| 167 | const body = `${report === "" ? "" : `${report}\n\n`}${trailerFor(issue)}`; | |
| 168 | editIssue(app, agent.user, repo, pull, { body }); | |
| 169 | } |
src/crew/runner.ts +40 −12
| @@ -17,6 +17,14 @@ import { join } from "node:path"; | ||
| 17 | 17 | import type { App } from "../app.ts"; |
| 18 | 18 | import { addComment } from "../actions/issues.ts"; |
| 19 | 19 | import { openPull } from "../actions/pulls.ts"; |
| 20 | import { | |
outdated
This import is one long line and unsorted against its neighbours. Wrap it the way the other imports are written. |
||
| 21 | fillPull, | |
| 22 | isEarlyBody, | |
| 23 | pullOfBranch, | |
| 24 | trailerFor, | |
| 25 | watchForPull, | |
| 26 | type EarlyPull, | |
| 27 | } from "./early.ts"; | |
| 20 | 28 | import { forkRepo, sealedForkFor } from "../actions/repos.ts"; |
| 21 | 29 | import { announceRun } from "../actions/runs.ts"; |
| 22 | 30 | import { previewEnv } from "../apps/data.ts"; |
| @@ -661,6 +669,7 @@ async function work( | ||
| 661 | 669 | summary: `pull #${pull.number} was closed before the run began`, |
| 662 | 670 | }; |
| 663 | 671 | const continuing = pull !== undefined && pull.mergedAt !== null; |
| 672 | const pullStands = pull !== undefined && !continuing; | |
| 664 | 673 | const branch = |
| 665 | 674 | pull !== undefined && !continuing && !reviewing |
| 666 | 675 | ? pull.headBranch |
| @@ -705,6 +714,9 @@ async function work( | ||
| 705 | 714 | |
| 706 | 715 | let grant: Grant | undefined; |
| 707 | 716 | let runTmp: string | undefined; |
| 717 | let early: EarlyPull | undefined; | |
| 718 | // A paused run resumes: its pull keeps saying "In progress". | |
| 719 | let pausing = false; | |
| 708 | 720 | try { |
| 709 | 721 | // The secrets this run declared, for this run only: opened now, held for |
| 710 | 722 | // redaction from this moment, dropped in the `finally` below — a crash |
| @@ -1043,6 +1055,22 @@ async function work( | ||
| 1043 | 1055 | `sandbox: off here${options.sandboxNote === undefined ? "" : ` — ${options.sandboxNote}`}; the shell runs unrestricted`, |
| 1044 | 1056 | ); |
| 1045 | 1057 | const working = budget - reserve; |
| 1058 | if (!reviewing) | |
| 1059 | early = watchForPull({ | |
| 1060 | app, | |
| 1061 | repo, | |
| 1062 | agent, | |
| 1063 | issue, | |
| 1064 | run, | |
| 1065 | fork: fork.value, | |
| 1066 | branch, | |
| 1067 | forkBare, | |
| 1068 | bare, | |
| 1069 | forgeUrl: options.forgeUrl, | |
| 1070 | write, | |
| 1071 | variantGroup: run.variantGroup, | |
| 1072 | continuing: pullStands, | |
| 1073 | }); | |
| 1046 | 1074 | let result = await invoke( |
| 1047 | 1075 | prompt, |
| 1048 | 1076 | working, |
| @@ -1101,6 +1129,7 @@ async function work( | ||
| 1101 | 1129 | run.attempt >= MAX_PAUSES |
| 1102 | 1130 | ) |
| 1103 | 1131 | return undefined; |
| 1132 | pausing = true; | |
| 1104 | 1133 | const at = new Date(Date.now() + NETWORK_PAUSE_MS); |
| 1105 | 1134 | write(`network still down; paused, resumes ${whenText(at)}`); |
| 1106 | 1135 | return { |
| @@ -1116,6 +1145,7 @@ async function work( | ||
| 1116 | 1145 | ? { prompt, sessionId: undefined } |
| 1117 | 1146 | : { prompt: "Continue where you stopped.", sessionId: last.sessionId }, |
| 1118 | 1147 | ); |
| 1148 | await early?.stop(); | |
| 1119 | 1149 | const down = outagePause(result, result.costUsd); |
| 1120 | 1150 | if (down !== undefined) return down; |
| 1121 | 1151 | noteRunProgress(app.db, run.id, { |
| @@ -1170,6 +1200,7 @@ async function work( | ||
| 1170 | 1200 | write( |
| 1171 | 1201 | `${picked.subscription.name} rests until ${whenText(until)}; ${next.subscription.name} takes the run`, |
| 1172 | 1202 | ); |
| 1203 | pausing = true; | |
| 1173 | 1204 | return { |
| 1174 | 1205 | state: "paused", |
| 1175 | 1206 | summary: `paused: ${pauseReason(picked.subscription.name, limit, said)}; ${next.subscription.name} takes over`, |
| @@ -1193,6 +1224,7 @@ async function work( | ||
| 1193 | 1224 | } |
| 1194 | 1225 | if (run.attempt < MAX_PAUSES) { |
| 1195 | 1226 | const at = resumeAt(result.result, new Date()); |
| 1227 | pausing = true; | |
| 1196 | 1228 | return { |
| 1197 | 1229 | state: "paused", |
| 1198 | 1230 | summary: `paused: ${pauseReason(picked.kind === "ready" ? picked.subscription.name : undefined, limitOf(result.result, new Date()), said)}; resumes ${whenText(at)}`, |
| @@ -1339,6 +1371,8 @@ async function work( | ||
| 1339 | 1371 | turns: result.turns, |
| 1340 | 1372 | }; |
| 1341 | 1373 | } finally { |
| 1374 | await early?.stop(); | |
| 1375 | if (!pausing) early?.settle(); | |
| 1342 | 1376 | grant?.release(); |
| 1343 | 1377 | deleteToken(app.db, agent.user.id, token.id); |
| 1344 | 1378 | if (runTmp !== undefined) rmSync(runTmp, { recursive: true, force: true }); |
| @@ -1715,26 +1749,20 @@ async function landWork( | ||
| 1715 | 1749 | }; |
| 1716 | 1750 | } |
| 1717 | 1751 | |
| 1718 | const existing = | |
| 1719 | pull ?? | |
| 1720 | listPulls(app.db, repo.id, { | |
| 1721 | sealed: true, | |
| 1722 | state: "open", | |
| 1723 | limit: 200, | |
| 1724 | }).find((p) => p.headRepoId === fork.id && p.headBranch === branch); | |
| 1752 | const existing = pull ?? pullOfBranch(app, repo, fork, branch); | |
| 1725 | 1753 | if (existing !== undefined) { |
| 1754 | // A pull this run opened at its first push is still the run's opening. | |
| 1755 | const openedByRun = isEarlyBody(existing.body); | |
| 1756 | fillPull(app, agent, repo, existing, issue, report); | |
| 1726 | 1757 | if (report !== "") addComment(app, agent.user, repo, existing, report); |
| 1727 | 1758 | return { |
| 1728 | 1759 | pushed: true, |
| 1729 | summary: `updated pull #${existing.number} (${remote?.slice(0, 7)})${committedForIt ? ", committing its leftover changes" : ""}`, | |
| 1760 | summary: `${openedByRun ? "opened" : "updated"} pull #${existing.number} (${remote?.slice(0, 7)})${committedForIt ? ", committing its leftover changes" : ""}`, | |
| 1730 | 1761 | }; |
| 1731 | 1762 | } |
| 1732 | 1763 | // No pull to update and yet the task is one: the pull it continues was |
| 1733 | 1764 | // merged, and this one follows it rather than closing it. |
| 1734 | const trailer = | |
| 1735 | issue.kind === "pull" | |
| 1736 | ? `Continues #${issue.number}` | |
| 1737 | : `Closes #${issue.number}`; | |
| 1765 | const trailer = trailerFor(issue); | |
| 1738 | 1766 | const body = `${report === "" ? "" : `${report}\n\n`}${trailer}`; |
| 1739 | 1767 | const opened = await openPull(app, agent.user, repo, { |
| 1740 | 1768 | title: issue.title, |
test/crew-network.test.ts +25
| @@ -10,6 +10,7 @@ import type { App } from "../src/app.ts"; | ||
| 10 | 10 | import type { Repo } from "../src/core/repos.ts"; |
| 11 | 11 | import type { User } from "../src/core/users.ts"; |
| 12 | 12 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; |
| 13 | import { listPulls } from "../src/core/pulls.ts"; | |
| 13 | 14 | import { listRuns, type Run } from "../src/core/runs.ts"; |
| 14 | 15 | import { MAX_PAUSES } from "../src/crew/limits.ts"; |
| 15 | 16 | import { isNetworkError, NETWORK_PAUSE_MS } from "../src/crew/network.ts"; |
| @@ -120,6 +121,30 @@ describe("the crew, with a stand-in Claude Code: network errors", () => { | ||
| 120 | 121 | rmSync(join(standIn.dataDir, "wrapdown")); |
| 121 | 122 | }, 60_000); |
| 122 | 123 | |
| 124 | test("a run paused after its first push keeps its pull saying it is in progress", async () => { | |
| 125 | const issue = openIssue(app, alice, repo, { | |
| 126 | title: "Pause after push", | |
| 127 | body: "PUSH-THEN-DOWN", | |
| 128 | }); | |
| 129 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 130 | expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true); | |
| 131 | const until = Date.now() + 40_000; | |
| 132 | let paused: Run | undefined; | |
| 133 | while (paused === undefined && Date.now() < until) { | |
| 134 | paused = listRuns(app.db, repo.id, { kind: "agent" }).find( | |
| 135 | (r) => r.issueId === issue.value.id && r.state === "cancelled", | |
| 136 | ); | |
| 137 | if (paused === undefined) await Bun.sleep(50); | |
| 138 | } | |
| 139 | expect(paused?.summary).toStartWith("paused: network error"); | |
| 140 | const pull = listPulls(app.db, repo.id, { state: "open", limit: 200 }).find( | |
| 141 | (p) => p.headBranch === paused!.branch, | |
| 142 | ); | |
| 143 | expect(pull).toBeDefined(); | |
| 144 | expect(pull!.body).toStartWith("In progress:"); | |
| 145 | rmSync(join(standIn.dataDir, "down"), { force: true }); | |
| 146 | }, 60_000); | |
| 147 | ||
| 123 | 148 | test("an outage that outlasts the retries pauses the run; after MAX_PAUSES it ends", async () => { |
| 124 | 149 | const issue = openIssue(app, alice, repo, { |
| 125 | 150 | title: "Network down", |
test/crew.test.ts +154
| @@ -11,7 +11,9 @@ import { | ||
| 11 | 11 | mkdirSync, |
| 12 | 12 | readFileSync, |
| 13 | 13 | realpathSync, |
| 14 | rmSync, | |
| 14 | 15 | symlinkSync, |
| 16 | writeFileSync, | |
| 15 | 17 | } from "node:fs"; |
| 16 | 18 | import { mkdtemp, rm } from "node:fs/promises"; |
| 17 | 19 | import { tmpdir } from "node:os"; |
| @@ -20,11 +22,15 @@ import type { App } from "../src/app.ts"; | ||
| 20 | 22 | import { |
| 21 | 23 | addComment, |
| 22 | 24 | assignIssue, |
| 25 | editIssue, | |
| 23 | 26 | openIssue, |
| 24 | 27 | setIssueState, |
| 25 | 28 | } from "../src/actions/issues.ts"; |
| 29 | import { pullOfBranch, trailerFor, watchForPull } from "../src/crew/early.ts"; | |
| 30 | import { openPull } from "../src/actions/pulls.ts"; | |
| 26 | 31 | import { sandboxAvailable } from "../src/crew/sandbox.ts"; |
| 27 | 32 | import { queueAgentRun, type CrewHandle } from "../src/crew/dispatch.ts"; |
| 33 | import { abortAgentRun } from "../src/crew/running.ts"; | |
| 28 | 34 | import { workdirFor } from "../src/crew/runner.ts"; |
| 29 | 35 | import { getAgentByName } from "../src/core/agents.ts"; |
| 30 | 36 | import { getIssue, listComments } from "../src/core/issues.ts"; |
| @@ -367,4 +373,152 @@ describe("the crew, with a stand-in Claude Code", () => { | ||
| 367 | 373 | `pull #${closed.number} was closed before the run began`, |
| 368 | 374 | ); |
| 369 | 375 | }, 30_000); |
| 376 | ||
| 377 | test("the pull opens at the first push, in progress, and the run's end fills that same pull in", async () => { | |
| 378 | const issue = openIssue(app, alice, repo, { | |
| 379 | title: "Plant crocuses", | |
| 380 | body: "LINGER-ME by the gate.", | |
| 381 | }); | |
| 382 | expect(issue.ok).toBe(true); | |
| 383 | if (!issue.ok) return; | |
| 384 | expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true); | |
| 385 | const branch = `bot/${issue.value.number}-plant-crocuses`; | |
| 386 | const mine = () => | |
| 387 | listPulls(app.db, repo.id, { state: "open", limit: 200 }).filter( | |
| 388 | (p) => p.headBranch === branch, | |
| 389 | ); | |
| 390 | // The agent has pushed and is still working: the pull is already there. | |
| 391 | const deadline = Date.now() + 20_000; | |
| 392 | while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100); | |
| 393 | const early = mine()[0]!; | |
| 394 | expect(early).toBeDefined(); | |
| 395 | expect(early.title).toBe("Plant crocuses"); | |
| 396 | expect(early.baseBranch).toBe(repo.defaultBranch); | |
| 397 | expect(early.body).toStartWith("In progress:"); | |
| 398 | expect(early.body).toMatch(/\/runs\/[\w-]+/); | |
| 399 | expect(early.body).toContain(`Closes #${issue.value.number}`); | |
| 400 | expect(existsSync(join(dataDir, "release"))).toBe(false); | |
| 401 | ||
| 402 | writeFileSync(join(dataDir, "release"), ""); | |
| 403 | const run = await settled((r) => r.branch === branch); | |
| 404 | expect(run.state).toBe("ok"); | |
| 405 | expect(run.summary).toMatch(new RegExp(`^opened pull #${early.number}\\b`)); | |
| 406 | const after = mine(); | |
| 407 | expect(after.length).toBe(1); | |
| 408 | expect(after[0]!.number).toBe(early.number); | |
| 409 | expect(after[0]!.body).not.toContain("In progress:"); | |
| 410 | expect(after[0]!.body).toContain("Added CREW.md as asked."); | |
| 411 | expect(after[0]!.body).toContain(`Closes #${issue.value.number}`); | |
| 412 | const log = readRunLog(dataDir, run.id).lines.join("\n"); | |
| 413 | expect(log).toContain(`opened pull #${early.number} at the first push`); | |
| 414 | }, 60_000); | |
| 415 | ||
| 416 | test("a body a person edited while the run was going is left alone at the run's end", async () => { | |
| 417 | rmSync(join(dataDir, "release"), { force: true }); | |
| 418 | const issue = openIssue(app, alice, repo, { | |
| 419 | title: "Plant snowdrops", | |
| 420 | body: "LINGER-ME under the oak.", | |
| 421 | }); | |
| 422 | if (!issue.ok) throw new Error("issue"); | |
| 423 | expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true); | |
| 424 | const branch = `bot/${issue.value.number}-plant-snowdrops`; | |
| 425 | const mine = () => | |
| 426 | listPulls(app.db, repo.id, { state: "open", limit: 200 }).filter( | |
| 427 | (p) => p.headBranch === branch, | |
| 428 | ); | |
| 429 | const deadline = Date.now() + 20_000; | |
| 430 | while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100); | |
| 431 | const early = mine()[0]!; | |
| 432 | expect( | |
| 433 | editIssue(app, alice, repo, early, { body: "My own words.\n\nCloses #1" }).ok, | |
| 434 | ).toBe(true); | |
| 435 | writeFileSync(join(dataDir, "release"), ""); | |
| 436 | const run = await settled((r) => r.branch === branch); | |
| 437 | expect(run.summary).toMatch(new RegExp(`^updated pull #${early.number}\\b`)); | |
| 438 | expect(mine()[0]!.body).toBe("My own words.\n\nCloses #1"); | |
| 439 | }, 60_000); | |
| 440 | ||
| 441 | test("a run cancelled after its first push leaves the pull saying the run ended, with the log", async () => { | |
| 442 | // The stand-in waits for the release file; take it away so it lingers. | |
| 443 | rmSync(join(dataDir, "release"), { force: true }); | |
| 444 | const issue = openIssue(app, alice, repo, { | |
| 445 | title: "Plant bluebells", | |
| 446 | body: "LINGER-ME in the shade.", | |
| 447 | }); | |
| 448 | if (!issue.ok) throw new Error("issue"); | |
| 449 | expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true); | |
| 450 | const branch = `bot/${issue.value.number}-plant-bluebells`; | |
| 451 | const mine = () => | |
| 452 | listPulls(app.db, repo.id, { state: "open", limit: 200 }).filter( | |
| 453 | (p) => p.headBranch === branch, | |
| 454 | ); | |
| 455 | const deadline = Date.now() + 20_000; | |
| 456 | while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100); | |
| 457 | expect(mine()[0]!.body).toStartWith("In progress:"); | |
| 458 | const running = listRuns(app.db, repo.id, { kind: "agent" }).find( | |
| 459 | (r) => r.branch === branch, | |
| 460 | )!; | |
| 461 | expect(abortAgentRun(running.id)).toBe(true); | |
| 462 | const run = await settled((r) => r.branch === branch); | |
| 463 | expect(run.summary).toContain("cancelled"); | |
| 464 | const body = mine()[0]!.body; | |
| 465 | expect(body).not.toContain("In progress:"); | |
| 466 | expect(body).toStartWith("The run ended before it reported"); | |
| 467 | expect(body).toContain(`/runs/${run.id}`); | |
| 468 | expect(body).toContain(`Closes #${issue.value.number}`); | |
| 469 | }, 60_000); | |
| 470 | ||
| 471 | test("an early open that fails is tried once and leaves no pull behind", async () => { | |
| 472 | const agent = getAgentByName(app.db, "bot")!; | |
| 473 | const fork = getForkOf(app.db, bot.id, repo.id)!; | |
| 474 | const bare = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 475 | const forkBare = repoDir(app.reposRoot, fork.ownerName, fork.name); | |
| 476 | const issue = openIssue(app, alice, repo, { title: "Plant ferns", body: "Shade." }); | |
| 477 | if (!issue.ok) throw new Error("issue"); | |
| 478 | // A branch one commit beyond the base, in the fork. | |
| 479 | const branch = "bot/early-fails"; | |
| 480 | const git = async (...args: string[]) => { | |
| 481 | const p = Bun.spawn(["git", ...args], { | |
| 482 | cwd: forkBare, | |
| 483 | stdout: "pipe", | |
| 484 | stderr: "pipe", | |
| 485 | env: { | |
| 486 | ...process.env, | |
| 487 | GIT_AUTHOR_NAME: "t", | |
| 488 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 489 | GIT_COMMITTER_NAME: "t", | |
| 490 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 491 | }, | |
| 492 | }); | |
| 493 | const out = await new Response(p.stdout).text(); | |
| 494 | expect(await p.exited).toBe(0); | |
| 495 | return out.trim(); | |
| 496 | }; | |
| 497 | const baseTip = (await resolveOid(bare, `refs/heads/${repo.defaultBranch}`))!; | |
| 498 | const tree = await git("rev-parse", `${baseTip}^{tree}`); | |
| 499 | const commit = await git("commit-tree", tree, "-p", baseTip, "-m", "ferns"); | |
| 500 | await git("update-ref", `refs/heads/${branch}`, commit); | |
| 501 | ||
| 502 | const lines: string[] = []; | |
| 503 | // An issue with no title is one the forge cannot open a pull for. | |
| 504 | const untitled = { ...issue.value, title: "" }; | |
| 505 | const run = listRuns(app.db, repo.id, { kind: "agent" })[0]!; | |
| 506 | const early = watchForPull({ | |
| 507 | app, repo, agent, issue: untitled, run, fork, branch, forkBare, bare, | |
| 508 | forgeUrl: "http://forge.test", | |
| 509 | write: (l) => lines.push(l), | |
| 510 | variantGroup: null, | |
| 511 | continuing: false, | |
| 512 | intervalMs: 20, | |
| 513 | }); | |
| 514 | const deadline = Date.now() + 5_000; | |
| 515 | while (lines.length === 0 && Date.now() < deadline) await Bun.sleep(20); | |
| 516 | expect(lines.length).toBe(1); | |
| 517 | expect(lines[0]).toContain("could not open the pull early"); | |
| 518 | // Many looks later it has not tried again, and no pull stands. | |
| 519 | await Bun.sleep(300); | |
| 520 | await early.stop(); | |
| 521 | expect(lines.length).toBe(1); | |
| 522 | expect(pullOfBranch(app, repo, fork, branch)).toBeUndefined(); | |
| 523 | }, 30_000); | |
| 370 | 524 | }); |
test/helpers/crew-standin.ts +11
| @@ -62,6 +62,14 @@ echo "half-done" > PAUSED.md | ||
| 62 | 62 | echo '{"type":"result","subtype":"error_during_execution","is_error":true,"result":"You\u2019ve hit your session limit \u00b7 resets 7:20am (UTC)","total_cost_usd":0.02,"num_turns":2,"session_id":"fake-1","terminal_reason":"api_error"}' |
| 63 | 63 | exit 0 |
| 64 | 64 | fi |
| 65 | if grep -q PUSH-THEN-DOWN "${promptFile}" && [ ! -f "${dataDir}/down" ]; then | |
| 66 | echo "work by the crew $(date +%s%N)" >> CREW.md | |
| 67 | git add -A && git commit -qm "crew work" && git push -q -u origin HEAD | |
| 68 | sleep 2.5 | |
| 69 | touch "${dataDir}/down" | |
| 70 | echo '{"type":"result","subtype":"error_during_execution","is_error":true,"result":"API Error: Can'"'"'t reach the API server","total_cost_usd":0,"num_turns":0,"session_id":"fake-1","terminal_reason":"api_error"}' | |
| 71 | exit 0 | |
| 72 | fi | |
| 65 | 73 | if grep -q NET-DOWN "${promptFile}"; then touch "${dataDir}/down"; fi |
| 66 | 74 | if [ -f "${dataDir}/down" ] && grep -q "Continue where you stopped\\|NET-DOWN" "${promptFile}"; then |
| 67 | 75 | echo '{"type":"result","subtype":"error_during_execution","is_error":true,"result":"API Error: Can'"'"'t reach the API server","total_cost_usd":0,"num_turns":0,"session_id":"fake-1","terminal_reason":"api_error"}' |
| @@ -126,6 +134,9 @@ echo "work by the crew $(date +%s%N)" >> CREW.md | ||
| 126 | 134 | # package cache in it. Never the work; the clone's exclude keeps it out. |
| 127 | 135 | mkdir -p .tmp/cache && echo "junk" > .tmp/cache/leftover |
| 128 | 136 | git add -A && git commit -qm "crew work" && git push -q -u origin HEAD |
| 137 | if grep -q LINGER-ME "${promptFile}"; then | |
| 138 | n=0; while [ ! -f "${dataDir}/release" ] && [ $n -lt 300 ]; do sleep 0.1; n=$((n+1)); done | |
| 139 | fi | |
| 129 | 140 | echo '{"type":"assistant","message":{"content":[{"type":"text","text":"Added CREW.md."},{"type":"tool_use","name":"Read","input":{"file_path":"'"$(pwd)"'/CREW.md"}},{"type":"tool_use","name":"Bash","input":{"command":"cat /etc/hosts"}},{"type":"tool_use","name":"Bash","input":{"command":"git push"}}]}}' |
| 130 | 141 | echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW.md as asked.","total_cost_usd":0.01,"num_turns":3,"session_id":"fake-1","terminal_reason":"completed"}' |
| 131 | 142 | `, |
new above
offline
A push after a tick but before the run ends is only picked up by landWork. That is fine, but a failed early open is silent apart from a log line. Test it.