Public: anyone can read this; the crew can change it.
A "stop" decision must not outlive the red check that caused it: re-decide when the pull's head turns green #544
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/542-a-stop-decision-must-not-outlive-the-red → main 2 commits fork
6 files
changed +202
−5
src/core/decisions.ts +21
| @@ -126,6 +126,27 @@ export function latestDecision( | ||
| 126 | 126 | return row ? toDecision(row) : undefined; |
| 127 | 127 | } |
| 128 | 128 | |
| 129 | /** | |
| 130 | * Whether a `retry` decision still stops its pull. The crew's stop is its | |
| 131 | * answer to a red check (the check is in what it saw), so a check that | |
| 132 | * passes afterwards — a re-run on the same head, or a newer head — is the | |
| 133 | * end of it: the ledger's `green` outcome, or a green verdict finished | |
| 134 | * after it was taken (`greenAt`). Any other stop is a person's, and only a | |
| 135 | * person lifts it. | |
| 136 | */ | |
| 137 | export function stopStands( | |
| 138 | decision: Decision | undefined, | |
| 139 | greenAt?: string | undefined, | |
| 140 | ): boolean { | |
| 141 | if (decision?.kind !== "retry" || decision.answer !== "stop") return false; | |
| 142 | const state = decision.state; | |
| 143 | const aboutACheck = | |
| 144 | typeof state === "object" && state !== null && "check" in state; | |
| 145 | if (!aboutACheck) return true; | |
| 146 | if (decision.outcome === "green") return false; | |
| 147 | return greenAt === undefined || greenAt <= decision.createdAt; | |
| 148 | } | |
| 149 | ||
| 129 | 150 | /** What came of the newest unresolved decision of this kind about an issue. */ |
| 130 | 151 | export function resolveOpen( |
| 131 | 152 | db: Database, |
src/core/runs.ts +18
| @@ -627,6 +627,24 @@ export function checksForHead( | ||
| 627 | 627 | .map(toRun); |
| 628 | 628 | } |
| 629 | 629 | |
| 630 | /** | |
| 631 | * What this head says now: the newest run of each kind (and environment) | |
| 632 | * at it. A re-run on the same commit supersedes the red it was run for, so | |
| 633 | * "does this head pass" is whether every row here is `ok`. | |
| 634 | */ | |
| 635 | export function verdictForHead( | |
| 636 | db: Database, | |
| 637 | issueId: string, | |
| 638 | headOid: string, | |
| 639 | ): Run[] { | |
| 640 | const newest = new Map<string, Run>(); | |
| 641 | for (const run of checksForHead(db, issueId, headOid)) { | |
| 642 | const key = `${run.kind}:${run.env}`; | |
| 643 | if (!newest.has(key)) newest.set(key, run); | |
| 644 | } | |
| 645 | return [...newest.values()]; | |
| 646 | } | |
| 647 | ||
| 630 | 648 | export interface CheckSummary { |
| 631 | 649 | /** `ok` when the latest deploy of this head succeeded; `none` when there is none. */ |
| 632 | 650 | readonly state: RunState | "none"; |
src/crew/autonomy.ts +12 −2
| @@ -36,6 +36,7 @@ import { | ||
| 36 | 36 | latestDecision, |
| 37 | 37 | recordDecision, |
| 38 | 38 | resolveOpen, |
| 39 | stopStands, | |
| 39 | 40 | } from "../core/decisions.ts"; |
| 40 | 41 | import { certain } from "./decide.ts"; |
| 41 | 42 | import { decideAndRecord } from "./ledger.ts"; |
| @@ -64,9 +65,9 @@ import { | ||
| 64 | 65 | import { |
| 65 | 66 | activeRunFor, |
| 66 | 67 | automaticRunsSince, |
| 67 | checksForHead, | |
| 68 | 68 | listActive, |
| 69 | 69 | type Run, |
| 70 | verdictForHead, | |
| 70 | 71 | } from "../core/runs.ts"; |
| 71 | 72 | import { |
| 72 | 73 | agentFor, |
| @@ -889,9 +890,18 @@ export async function landIfGreen( | ||
| 889 | 890 | if (pull.state !== "open" || pull.mergedAt !== null) return false; |
| 890 | 891 | const by = getUserById(app.db, repo.autonomyBy); |
| 891 | 892 | if (!by) return false; |
| 892 | const checks = checksForHead(app.db, pull.id, pull.headOid); | |
| 893 | // The newest run of each kind is what the head says: a red that a re-run | |
| 894 | // on the same commit has since turned green is history. | |
| 895 | const checks = verdictForHead(app.db, pull.id, pull.headOid); | |
| 893 | 896 | if (checks.length === 0) return false; |
| 894 | 897 | if (checks.some((c) => c.state !== "ok")) return false; |
| 898 | // A stop the crew took on a red check ends with the green after it; a | |
| 899 | // person's stop waits for a person. | |
| 900 | const greenAt = checks | |
| 901 | .map((c) => c.finishedAt ?? "") | |
| 902 | .reduce((a, b) => (a > b ? a : b), ""); | |
| 903 | if (stopStands(latestDecision(app.db, pull.id, "retry"), greenAt)) | |
| 904 | return false; | |
| 895 | 905 | // Conflicts first: a pull that no longer merges is the crew's to bring |
| 896 | 906 | // up to date, before anyone is asked whether it should land. A conflict |
| 897 | 907 | // is remembered by the two tips it was between: until one of them moves |
src/web/studio/asks.ts +2 −2
| @@ -12,7 +12,7 @@ import { previewEnv } from "../../apps/data.ts"; | ||
| 12 | 12 | import { crewForRepos } from "../../core/agents.ts"; |
| 13 | 13 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 14 | 14 | import { appsForRepos, getAppByRepo, listApps } from "../../core/apps.ts"; |
| 15 | import { latestDecision } from "../../core/decisions.ts"; | |
| 15 | import { latestDecision, stopStands } from "../../core/decisions.ts"; | |
| 16 | 16 | import { |
| 17 | 17 | getIssueById, |
| 18 | 18 | listComments, |
| @@ -184,7 +184,7 @@ function askCandidatesFor( | ||
| 184 | 184 | if (!issue.authorIsAgent) continue; |
| 185 | 185 | const since = lastHumanWord(db, issue, who); |
| 186 | 186 | const tries = automaticRunsSince(db, issue.id, since); |
| 187 | const stopped = latestDecision(db, issue.id, "retry")?.answer === "stop"; | |
| 187 | const stopped = stopStands(latestDecision(db, issue.id, "retry")); | |
| 188 | 188 | // A repository on its own parks what it cannot finish and moves on; |
| 189 | 189 | // only an assisted one stops here for a person. |
| 190 | 190 | if ( |
test/asks.test.ts +55 −1
| @@ -12,7 +12,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | 13 | import { openPull } from "../src/actions/pulls.ts"; |
| 14 | 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 15 | import { recordDecision } from "../src/core/decisions.ts"; | |
| 15 | import { recordDecision, resolveOpen } from "../src/core/decisions.ts"; | |
| 16 | 16 | import type { Repo } from "../src/core/repos.ts"; |
| 17 | 17 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 18 | 18 | import { getUserByName } from "../src/core/users.ts"; |
| @@ -161,4 +161,58 @@ describe("the asks page", () => { | ||
| 161 | 161 | expect(other).not.toContain("queue has waited"); |
| 162 | 162 | expect(sentence(other)).toBe("All fine"); |
| 163 | 163 | }); |
| 164 | ||
| 165 | test("a stop the crew took on a red check is no card once a check on that head passes", async () => { | |
| 166 | const aliceUser = getUserByName(app.db, "alice")!; | |
| 167 | const bot = getUserByName(app.db, "bot")!; | |
| 168 | await commitToBranch(app, aliceUser, repo, { | |
| 169 | branch: "bot/2-flaky", | |
| 170 | base: "main", | |
| 171 | message: "Bot work, unlucky", | |
| 172 | files: [{ path: "FLAKY.md", content: "flaky\n" }], | |
| 173 | }); | |
| 174 | const opened = await openPull(app, bot, repo, { | |
| 175 | title: "Bot met a flaky runner", | |
| 176 | body: "", | |
| 177 | baseBranch: "main", | |
| 178 | headBranch: "bot/2-flaky", | |
| 179 | }); | |
| 180 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 181 | const pull = opened.value; | |
| 182 | const check = (state: "ok" | "failed") => { | |
| 183 | const run = createRun(app.db, { | |
| 184 | repoId: repo.id, | |
| 185 | kind: "check", | |
| 186 | issueId: pull.id, | |
| 187 | env: `pull-${pull.number}`, | |
| 188 | commitOid: pull.headOid, | |
| 189 | reason: "retry", | |
| 190 | }); | |
| 191 | claimRun(app.db, run.id); | |
| 192 | finishRun(app.db, run.id, { state, summary: state }); | |
| 193 | }; | |
| 194 | check("failed"); | |
| 195 | recordDecision(app.db, { | |
| 196 | kind: "retry", | |
| 197 | repoId: repo.id, | |
| 198 | issueId: pull.id, | |
| 199 | state: { check: { kind: "check", summary: "red" } }, | |
| 200 | answer: "stop", | |
| 201 | p: 0.8, | |
| 202 | by: "decider", | |
| 203 | ms: 1, | |
| 204 | }); | |
| 205 | const card = `id="ask-stopped:${pull.id}"`; | |
| 206 | expect(await get(alice, "/asks")).toContain(card); | |
| 207 | // The re-run passes: the stop is answered, and the pull is green. | |
| 208 | check("ok"); | |
| 209 | resolveOpen(app.db, pull.id, "retry", "green"); | |
| 210 | const green = await get(alice, "/asks"); | |
| 211 | expect(green).not.toContain(card); | |
| 212 | expect(green).toContain(`id="ask-look:${pull.id}"`); | |
| 213 | // Red again, before anything new is decided: the old stop is spent, and | |
| 214 | // the crew's tries are what the card is counted from. | |
| 215 | check("failed"); | |
| 216 | expect(await get(alice, "/asks")).not.toContain(card); | |
| 217 | }); | |
| 164 | 218 | }); |
test/autonomy.test.ts +94
| @@ -19,6 +19,11 @@ import { | ||
| 19 | 19 | updateRepoSettings, |
| 20 | 20 | } from "../src/actions/repos.ts"; |
| 21 | 21 | import { setCrewRole } from "../src/core/agents.ts"; |
| 22 | import { | |
| 23 | latestDecision, | |
| 24 | recordDecision, | |
| 25 | resolveOpen, | |
| 26 | } from "../src/core/decisions.ts"; | |
| 22 | 27 | import { addDependency } from "../src/core/dependencies.ts"; |
| 23 | 28 | import { getIssue, getIssueById, type Issue } from "../src/core/issues.ts"; |
| 24 | 29 | import { getPull } from "../src/core/pulls.ts"; |
| @@ -386,6 +391,95 @@ describe("a repository on its own", () => { | ||
| 386 | 391 | ).toBeNull(); |
| 387 | 392 | }); |
| 388 | 393 | |
| 394 | test("a stop on red ends with the green after it: the pull lands on the next tick; a person's stop stays", async () => { | |
| 395 | app.db | |
| 396 | .query("UPDATE repo SET autonomy = 'autonomous' WHERE id = ?1") | |
| 397 | .run(repo.id); | |
| 398 | const proposed = async (name: string) => { | |
| 399 | const branch = await commitToBranch(app, alice, repo, { | |
| 400 | branch: `scout/${name}`, | |
| 401 | base: "main", | |
| 402 | message: name, | |
| 403 | files: [{ path: `${name}.ts`, content: `export const ${name} = 1;\n` }], | |
| 404 | }); | |
| 405 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 406 | const opened = await openPull(app, scout, repo, { | |
| 407 | title: name, | |
| 408 | baseBranch: "main", | |
| 409 | headBranch: `scout/${name}`, | |
| 410 | }); | |
| 411 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 412 | return opened.value; | |
| 413 | }; | |
| 414 | const check = ( | |
| 415 | pull: { id: string; number: number }, | |
| 416 | head: string, | |
| 417 | state: "ok" | "failed", | |
| 418 | ) => { | |
| 419 | const run = createRun(app.db, { | |
| 420 | repoId: repo.id, | |
| 421 | kind: "check", | |
| 422 | issueId: pull.id, | |
| 423 | env: `pull-${pull.number}`, | |
| 424 | commitOid: head, | |
| 425 | reason: "retry", | |
| 426 | }); | |
| 427 | claimRun(app.db, run.id); | |
| 428 | finishRun(app.db, run.id, { state, summary: state }); | |
| 429 | // What dispatch does with every check's result. | |
| 430 | resolveOpen(app.db, pull.id, "retry", state === "ok" ? "green" : "red"); | |
| 431 | }; | |
| 432 | const stop = (pull: { id: string }, state: unknown, by: string) => | |
| 433 | recordDecision(app.db, { | |
| 434 | kind: "retry", | |
| 435 | repoId: repo.id, | |
| 436 | issueId: pull.id, | |
| 437 | state, | |
| 438 | answer: "stop", | |
| 439 | p: 0.9, | |
| 440 | by, | |
| 441 | ms: 1, | |
| 442 | }); | |
| 443 | const crewSaw = { check: { kind: "check", summary: "check failed" } }; | |
| 444 | const merged = (n: number) => getPull(app.db, repo.id, n)!.mergedAt !== null; | |
| 445 | ||
| 446 | // The same head, run again: the red it was run for is history. | |
| 447 | const rerun = await proposed("gravity"); | |
| 448 | check(rerun, rerun.headOid, "failed"); | |
| 449 | stop(rerun, crewSaw, "decider"); | |
| 450 | expect(await sweepGreen(app, fresh())).toBe(0); | |
| 451 | check(rerun, rerun.headOid, "ok"); | |
| 452 | expect(latestDecision(app.db, rerun.id, "retry")?.outcome).toBe("green"); | |
| 453 | expect(await sweepGreen(app, fresh())).toBe(1); | |
| 454 | expect(merged(rerun.number)).toBe(true); | |
| 455 | ||
| 456 | // Red on the re-run too, then a newer head that passes. | |
| 457 | const pushed = await proposed("friction"); | |
| 458 | check(pushed, pushed.headOid, "failed"); | |
| 459 | stop(pushed, crewSaw, "decider"); | |
| 460 | check(pushed, pushed.headOid, "failed"); | |
| 461 | const again = await commitToBranch(app, alice, repo, { | |
| 462 | branch: "scout/friction", | |
| 463 | message: "friction, fixed", | |
| 464 | files: [{ path: "friction.ts", content: "export const friction = 2;\n" }], | |
| 465 | }); | |
| 466 | if (!again.ok) throw new Error(JSON.stringify(again.error)); | |
| 467 | const newer = getPull(app.db, repo.id, pushed.number)!; | |
| 468 | expect(newer.headOid).not.toBe(pushed.headOid); | |
| 469 | check(newer, newer.headOid, "ok"); | |
| 470 | expect(await sweepGreen(app, fresh())).toBe(1); | |
| 471 | expect(merged(pushed.number)).toBe(true); | |
| 472 | ||
| 473 | // A stop that is not the crew's answer to a check is a person's: green | |
| 474 | // does not lift it. | |
| 475 | const held = await proposed("drag"); | |
| 476 | check(held, held.headOid, "failed"); | |
| 477 | stop(held, "hold this one for me", "alice"); | |
| 478 | check(held, held.headOid, "ok"); | |
| 479 | expect(await sweepGreen(app, fresh())).toBe(0); | |
| 480 | expect(merged(held.number)).toBe(false); | |
| 481 | }); | |
| 482 | ||
| 389 | 483 | test("the builder pick is one fixed-choice function", () => { |
| 390 | 484 | const issue = { ...issues[1]!, assigneeId: null }; |
| 391 | 485 | const inFlight = new Map([[scout.id, 1]]); |
new above
offline