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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/542-a-stop-decision-must-not-outlive-the-red → main 2 commits fork

  • Merged. mason/542-a-stop-decision-must-not-outlive-the-red was merged into main as c1a879f.

Review

Sign in to review this change.
6 files changed +202 −5
src/core/decisions.ts +21
@@ -126,6 +126,27 @@ export function latestDecision(
126126 return row ? toDecision(row) : undefined;
127127 }
128128
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
129150 /** What came of the newest unresolved decision of this kind about an issue. */
130151 export function resolveOpen(
131152 db: Database,
src/core/runs.ts +18
@@ -627,6 +627,24 @@ export function checksForHead(
627627 .map(toRun);
628628 }
629629
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
630648 export interface CheckSummary {
631649 /** `ok` when the latest deploy of this head succeeded; `none` when there is none. */
632650 readonly state: RunState | "none";
src/crew/autonomy.ts +12 −2
@@ -36,6 +36,7 @@ import {
3636 latestDecision,
3737 recordDecision,
3838 resolveOpen,
39 stopStands,
3940 } from "../core/decisions.ts";
4041 import { certain } from "./decide.ts";
4142 import { decideAndRecord } from "./ledger.ts";
@@ -64,9 +65,9 @@ import {
6465 import {
6566 activeRunFor,
6667 automaticRunsSince,
67 checksForHead,
6868 listActive,
6969 type Run,
70 verdictForHead,
7071 } from "../core/runs.ts";
7172 import {
7273 agentFor,
@@ -889,9 +890,18 @@ export async function landIfGreen(
889890 if (pull.state !== "open" || pull.mergedAt !== null) return false;
890891 const by = getUserById(app.db, repo.autonomyBy);
891892 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);
893896 if (checks.length === 0) return false;
894897 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;
895905 // Conflicts first: a pull that no longer merges is the crew's to bring
896906 // up to date, before anyone is asked whether it should land. A conflict
897907 // 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";
1212 import { crewForRepos } from "../../core/agents.ts";
1313 import { appsRuntime } from "../../apps/runtime.ts";
1414 import { appsForRepos, getAppByRepo, listApps } from "../../core/apps.ts";
15 import { latestDecision } from "../../core/decisions.ts";
15 import { latestDecision, stopStands } from "../../core/decisions.ts";
1616 import {
1717 getIssueById,
1818 listComments,
@@ -184,7 +184,7 @@ function askCandidatesFor(
184184 if (!issue.authorIsAgent) continue;
185185 const since = lastHumanWord(db, issue, who);
186186 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"));
188188 // A repository on its own parks what it cannot finish and moves on;
189189 // only an assisted one stops here for a person.
190190 if (
test/asks.test.ts +55 −1
@@ -12,7 +12,7 @@ import { createApp, type App } from "../src/app.ts";
1212 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1313 import { openPull } from "../src/actions/pulls.ts";
1414 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";
1616 import type { Repo } from "../src/core/repos.ts";
1717 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
1818 import { getUserByName } from "../src/core/users.ts";
@@ -161,4 +161,58 @@ describe("the asks page", () => {
161161 expect(other).not.toContain("queue has waited");
162162 expect(sentence(other)).toBe("All fine");
163163 });
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 });
164218 });
test/autonomy.test.ts +94
@@ -19,6 +19,11 @@ import {
1919 updateRepoSettings,
2020 } from "../src/actions/repos.ts";
2121 import { setCrewRole } from "../src/core/agents.ts";
22 import {
23 latestDecision,
24 recordDecision,
25 resolveOpen,
26 } from "../src/core/decisions.ts";
2227 import { addDependency } from "../src/core/dependencies.ts";
2328 import { getIssue, getIssueById, type Issue } from "../src/core/issues.ts";
2429 import { getPull } from "../src/core/pulls.ts";
@@ -386,6 +391,95 @@ describe("a repository on its own", () => {
386391 ).toBeNull();
387392 });
388393
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
389483 test("the builder pick is one fixed-choice function", () => {
390484 const issue = { ...issues[1]!, assigneeId: null };
391485 const inFlight = new Map([[scout.id, 1]]);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs