Public: anyone can read this; the crew can change it.
A red that main shares never parks a pull #1973
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1971-a-red-that-main-shares-never-parks-a-pul → main 6 commits fork 2 threads · 2 unresolved
8 files
changed +297
−7
scripts/gate.ts +1
| @@ -115,6 +115,7 @@ export const GATE: readonly string[] = [ | ||
| 115 | 115 | "test/crew-wrapup.test.ts", |
| 116 | 116 | "test/crew-limits.test.ts", |
| 117 | 117 | "test/crew-parks.test.ts", |
| 118 | "test/crew-red-main.test.ts", | |
| 118 | 119 | "test/crew-sandbox.test.ts", |
| 119 | 120 | "test/crew-remotes.test.ts", |
| 120 | 121 | "test/crew-brief-gate.test.ts", |
src/checks/red_main.ts +12
| @@ -173,3 +173,15 @@ export function redSentence(red: RedMain, held: number): string { | ||
| 173 | 173 | : `"${first}" and ${red.failing.length - 1} more fail`; |
| 174 | 174 | return `${red.repo}: main is red at ${red.commitOid.slice(0, 7)} — ${what}; ${held} pull check${held === 1 ? "" : "s"} wait for it to pass.`; |
| 175 | 175 | } |
| 176 | ||
| 177 | /** | |
| 178 | * Whether a failed check fails only what main's last check already fails | |
| 179 | * (#1971): it named at least one test, and every one is red on main too. | |
| 180 | * Such a failure is not the pull's, so the crew neither reworks nor parks | |
| 181 | * it; it waits for main to be green. A check that named nothing (a build | |
| 182 | * error, a timeout) or one test main does not fail is the pull's own. | |
| 183 | */ | |
| 184 | export function sharedWithMain(red: RedMain | undefined, run: Pick<Run, "checkDetail"> | undefined): boolean { | |
| 185 | const names = run?.checkDetail?.names ?? []; | |
| 186 | return red !== undefined && names.length > 0 && names.every((n) => red.failing.includes(n)); | |
| 187 | } |
src/crew/autonomy.ts +3 −1
| @@ -32,6 +32,7 @@ import { | ||
| 32 | 32 | crewRole, |
| 33 | 33 | } from "../core/agents.ts"; |
| 34 | 34 | import { pickBuilder, routeIssue } from "./route.ts"; |
| 35 | import { coveredByRedFix } from "./red_fix.ts"; | |
| 35 | 36 | import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts"; |
| 36 | 37 | import { |
| 37 | 38 | latestDecision, |
| @@ -365,7 +366,8 @@ export async function driveRepo( | ||
| 365 | 366 | (r.issue.assigneeId === null || !planners.has(r.issue.assigneeId)) && |
| 366 | 367 | !stalled(app, repo, r.issue) && |
| 367 | 368 | !atRunCap(app, repo, r.issue) && |
| 368 | parkedCount(app, repo, r.issue.id) < PARK_STRIKES, | |
| 369 | parkedCount(app, repo, r.issue.id) < PARK_STRIKES && | |
| 370 | !coveredByRedFix(app.db, repo.id, r.issue), | |
| 369 | 371 | ); |
| 370 | 372 | if (slotted.length >= paced.slots) { |
| 371 | 373 | // The builders are busy; the planner can still refill the map, when |
src/crew/dispatch.ts +56 −1
| @@ -81,6 +81,7 @@ import { | ||
| 81 | 81 | spentToday, |
| 82 | 82 | } from "./autonomy.ts"; |
| 83 | 83 | import { checksFor } from "../checks/service.ts"; |
| 84 | import { redMain, sharedWithMain } from "../checks/red_main.ts"; | |
| 84 | 85 | import { certain } from "./decide.ts"; |
| 85 | 86 | import { readRunLog } from "../runs/log.ts"; |
| 86 | 87 | import { whenText } from "./limits.ts"; |
| @@ -542,7 +543,11 @@ function route(app: App, event: ForgeEvent): void { | ||
| 542 | 543 | ) |
| 543 | 544 | return; |
| 544 | 545 | const issueId = event.payload["issueId"]; |
| 545 | if (typeof issueId !== "string") return; | |
| 546 | if (typeof issueId !== "string") { | |
| 547 | if (event.payload["kind"] === "check") | |
| 548 | recheckWaiting(app, repo, event.payload["state"]); | |
| 549 | return; | |
| 550 | } | |
| 546 | 551 | const issue = getIssueById(app.db, issueId); |
| 547 | 552 | if (!issue || issue.kind !== "pull" || issue.state !== "open") return; |
| 548 | 553 | // A check's result is the outcome of the last "try again" decision. |
| @@ -580,6 +585,9 @@ function route(app: App, event: ForgeEvent): void { | ||
| 580 | 585 | // is the agent's to look at. |
| 581 | 586 | const agent = agentFor(app, issue); |
| 582 | 587 | if (!agent) return; |
| 588 | // A red that main shares is not the pull's: no rework, no park, no | |
| 589 | // strike; it waits and is checked again when main is green (#1971). | |
| 590 | if (waitOnMain(app, repo, issue, event.payload)) return; | |
| 583 | 591 | // Bounded: after a few failed checks in a row the agent stops and a |
| 584 | 592 | // person must speak (docs/APPS.md, rule 7). |
| 585 | 593 | const since = lastHumanWord(app, issue, agent); |
| @@ -662,6 +670,53 @@ function route(app: App, event: ForgeEvent): void { | ||
| 662 | 670 | } |
| 663 | 671 | } |
| 664 | 672 | |
| 673 | /** | |
| 674 | * Pulls whose check failed only on what main fails, per repository, with | |
| 675 | * the head that was checked. Memory only: a restart forgets them, and the | |
| 676 | * pull's next push or a person's ask checks it again. | |
| 677 | */ | |
outdated
Memory-only wait set: after a restart a waiting pull is never rechecked and nothing says it is waiting. Persist it or derive it at boot, and surface it on the pull. |
||
| 678 | const waitingOnMain = new Map<string, Map<string, { env: string; commitOid: string }>>(); | |
| 679 | ||
| 680 | /** Whether a failed check is main's red, shared: if so, the pull is noted as waiting. */ | |
| 681 | function waitOnMain( | |
| 682 | app: App, | |
| 683 | repo: Repo, | |
| 684 | issue: Issue, | |
| 685 | payload: Record<string, unknown>, | |
| 686 | ): boolean { | |
| 687 | const { runId, commitOid, env } = payload; | |
| 688 | if (payload["kind"] !== "check" || typeof runId !== "string") return false; | |
| 689 | if (!sharedWithMain(redMain(app.db, repo.id), getRun(app.db, runId))) return false; | |
| 690 | if (typeof commitOid === "string" && typeof env === "string") { | |
| 691 | const waiting = waitingOnMain.get(repo.id) ?? new Map(); | |
| 692 | waiting.set(issue.id, { env, commitOid }); | |
| 693 | waitingOnMain.set(repo.id, waiting); | |
| 694 | } | |
| 695 | log.info("check failed only what main fails; waiting for main to be green", { | |
| 696 | issue: issue.number, | |
| 697 | }); | |
| 698 | return true; | |
| 699 | } | |
| 700 | ||
| 701 | /** Main's check finished: when it passed, the pulls that waited are checked again. */ | |
| 702 | function recheckWaiting(app: App, repo: Repo, state: unknown): void { | |
| 703 | const waiting = waitingOnMain.get(repo.id); | |
| 704 | if (state !== "ok" || waiting === undefined) return; | |
| 705 | waitingOnMain.delete(repo.id); | |
| 706 | const checks = checksFor(app); | |
| 707 | if (checks === undefined) return; | |
| 708 | for (const [issueId, head] of waiting) { | |
| 709 | const issue = getIssueById(app.db, issueId); | |
| 710 | const pull = issue ? getPull(app.db, repo.id, issue.number) : undefined; | |
| 711 | if (!pull || pull.state !== "open" || pull.headOid !== head.commitOid) continue; | |
| 712 | void checks | |
| 713 | .request(repo, head.env, head.commitOid, "retry", issueId) | |
| 714 | .catch((error) => | |
| 715 | log.warn("a waiting pull could not be checked again", { issue: issue?.number, error: String(error) }), | |
| 716 | ); | |
| 717 | } | |
| 718 | } | |
| 719 | ||
| 665 | 720 | /** Checks already run again once for a head; a second red is real. */ |
| 666 | 721 | const retriedChecks = new Set<string>(); |
| 667 | 722 |
src/crew/red_fix.ts added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | /** | |
| 2 | * A red main gets one fix issue, and nobody else is handed its test (#1971). | |
| 3 | * The sentinel files the fix as the vitals card's finding (`red:<repoId>`); | |
| 4 | * while that issue is open, an issue that quotes a test main fails is the | |
| 5 | * same work, and the autonomy loop does not hand it to a builder. Five | |
| 6 | * builders once wrote one line in parallel for want of this. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { redMain } from "../checks/red_main.ts"; | |
| 11 | import type { Issue } from "../core/issues.ts"; | |
| 12 | import { keyPart } from "./sentinel.ts"; | |
| 13 | ||
| 14 | /** The open issue fixing this repository's red main, if the sentinel filed one. */ | |
| 15 | export function redFixIssue(db: Database, repoId: string): string | undefined { | |
| 16 | return ( | |
| 17 | db | |
| 18 | .query<{ issue_id: string }, [string]>( | |
| 19 | `SELECT t.issue_id FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id | |
| 20 | WHERE t.key = ?1 AND i.state = 'open' LIMIT 1`, | |
| 21 | ) | |
| 22 | .get(`degraded:${keyPart(`red:${repoId}`)}`)?.issue_id ?? undefined | |
| 23 | ); | |
| 24 | } | |
| 25 | ||
| 26 | /** | |
| 27 | * Whether `issue` is work the red main's fix issue already carries: main is | |
| 28 | * red, a fix issue is open and is not this one, and this one's title or | |
| 29 | * body quotes a test main fails. | |
| 30 | */ | |
| 31 | export function coveredByRedFix( | |
| 32 | db: Database, | |
| 33 | repoId: string, | |
| 34 | issue: Pick<Issue, "id" | "title" | "body">, | |
| 35 | ): boolean { | |
| 36 | const red = redMain(db, repoId); | |
| 37 | if (red === undefined || red.failing.length === 0) return false; | |
| 38 | const fix = redFixIssue(db, repoId); | |
| 39 | if (fix === undefined || fix === issue.id) return false; | |
| 40 | const text = `${issue.title}\n${issue.body}`; | |
| 41 | return red.failing.some((name) => text.includes(name)); | |
| 42 | } |
src/crew/sentinel.ts +10 −4
| @@ -75,7 +75,7 @@ const clip = (text: string, n = 200): string => | ||
| 75 | 75 | export const scrub = (text: string): string => |
| 76 | 76 | redactSecrets(text).replace(/[A-Za-z0-9_+/=-]{32,}/g, "[redacted]"); |
| 77 | 77 | const oneLine = (text: string): string => text.replace(/\s+/g, " ").trim(); |
| 78 | const keyPart = (text: string): string => | |
| 78 | export const keyPart = (text: string): string => | |
| 79 | 79 | text.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").slice(0, 60); |
| 80 | 80 | |
| 81 | 81 | // ---- detectors ---- |
| @@ -396,8 +396,12 @@ export function degradedCauses(db: Database, now: number): Finding[] { | ||
| 396 | 396 | return out; |
| 397 | 397 | } |
| 398 | 398 | |
| 399 | /** The causes that are a budget breached: the lane, a silent run, a run killed twice. */ | |
| 400 | const BREACH_CAUSE = /^(queue:check|stuck:|budget:)/; | |
| 399 | /** | |
| 400 | * The causes that are a budget breached: the lane, a silent run, a run | |
| 401 | * killed twice, a red main holding its pulls' checks. The last gets one fix | |
| 402 | * issue (#1971), and `crew/red_fix.ts` keeps other builders off its test. | |
| 403 | */ | |
| 404 | const BREACH_CAUSE = /^(queue:check|stuck:|budget:|red:)/; | |
| 401 | 405 | |
| 402 | 406 | /** |
| 403 | 407 | * Each budget breach that holds right now, however briefly: the crew hears |
| @@ -420,7 +424,9 @@ export function budgetBreaches(db: Database, now: number): Finding[] { | ||
| 420 | 424 | ], |
| 421 | 425 | pillar: "P0 Trust: the forge says when it is not well, and is well again", |
| 422 | 426 | lookFor: `\`${s.cause}\` is gone from the degraded list and the cause is fixed, not waited out.`, |
| 423 | fix: "Diagnose the cause from the evidence and hand the mechanical fix to a builder as its own issue.", | |
| 427 | fix: s.cause.startsWith("red:") | |
| 428 | ? "Fix the failing test on main in one pull. This is the only issue for it: pulls red on the same test wait for main, and no other builder is handed it while this is open." | |
| 429 | : "Diagnose the cause from the evidence and hand the mechanical fix to a builder as its own issue.", | |
| 424 | 430 | urgent: true, |
| 425 | 431 | }); |
| 426 | 432 | } |
test/crew-red-main.test.ts added +113
| @@ -0,0 +1,113 @@ | ||
| 1 | /** | |
| 2 | * A red that main shares never parks a pull (#1971): through the crew's | |
| 3 | * event path, a pull whose check fails only what main fails is not reworked | |
| 4 | * or struck, waits, and is checked again (once, at the same head) when | |
| 5 | * main's check passes. The stand-in forge is `helpers/crew-standin.ts`. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import type { App } from "../src/app.ts"; | |
| 10 | import { announceRun } from "../src/actions/runs.ts"; | |
| 11 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 12 | import { openPull } from "../src/actions/pulls.ts"; | |
| 13 | import { getPull, type Pull } from "../src/core/pulls.ts"; | |
| 14 | import type { Repo } from "../src/core/repos.ts"; | |
| 15 | import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts"; | |
| 16 | import type { User } from "../src/core/users.ts"; | |
| 17 | import { startStandInCrew, stopStandInCrew, type StandInCrew } from "./helpers/crew-standin.ts"; | |
| 18 | ||
| 19 | describe("the crew, with a red main it shares", () => { | |
| 20 | let standIn: StandInCrew; | |
| 21 | let app: App; | |
| 22 | let alice: User; | |
| 23 | let bot: User; | |
| 24 | let repo: Repo; | |
| 25 | let requested: unknown[][] = []; | |
| 26 | let n = 0; | |
| 27 | ||
| 28 | beforeAll(async () => { | |
| 29 | standIn = await startStandInCrew(); | |
| 30 | ({ app, alice, bot, repo } = standIn); | |
| 31 | // The checks handle is a plain object: record what the crew asks of it. | |
| 32 | standIn.checks.request = async (...args: unknown[]) => { | |
| 33 | requested.push(args); | |
| 34 | return undefined; | |
| 35 | }; | |
| 36 | }, 30_000); | |
| 37 | afterAll(() => stopStandInCrew(standIn)); | |
| 38 | ||
| 39 | const pullOf = async (): Promise<Pull> => { | |
| 40 | const branch = `feat-${++n}`; | |
| 41 | const c = await commitToBranch(app, alice, repo, { | |
| 42 | message: branch, | |
| 43 | branch, | |
| 44 | files: [{ path: `${branch}.md`, content: "x\n" }], | |
| 45 | }); | |
| 46 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 47 | const o = await openPull(app, alice, repo, { title: branch, baseBranch: "main", headBranch: branch }); | |
| 48 | if (!o.ok) throw new Error(String(o.error)); | |
| 49 | app.db.run("UPDATE issue SET assignee_id = ?1 WHERE id = ?2", [bot.id, o.value.id]); | |
| 50 | return getPull(app.db, repo.id, o.value.number)!; | |
| 51 | }; | |
| 52 | const finish = (env: string, state: "ok" | "failed", names: string[], pull?: Pull) => { | |
| 53 | const run = createRun(app.db, { | |
| 54 | repoId: repo.id, | |
| 55 | kind: "check", | |
| 56 | env, | |
| 57 | commitOid: pull?.headOid ?? "a".repeat(40), | |
| 58 | reason: "push", | |
| 59 | ...(pull === undefined ? {} : { issueId: pull.id }), | |
| 60 | }); | |
| 61 | claimRun(app.db, run.id); | |
| 62 | const done = finishRun(app.db, run.id, { | |
| 63 | state, | |
| 64 | summary: state === "ok" ? "check passed" : "check failed", | |
| 65 | ...(state === "failed" ? { checkDetail: { passed: 1, failed: names.length, names } } : {}), | |
| 66 | })!; | |
| 67 | announceRun(app, done); | |
| 68 | return done; | |
| 69 | }; | |
| 70 | const reworks = (pull: Pull) => | |
| 71 | listRuns(app.db, repo.id, { kind: "agent" }).filter((r) => r.issueId === pull.id && r.reason === "check_failed"); | |
| 72 | ||
| 73 | test("a main check's finished event is a check with no pull", () => { | |
| 74 | const run = finish("main", "failed", ["X"]); | |
| 75 | expect(run.issueId).toBeNull(); | |
| 76 | expect(run.kind).toBe("check"); | |
| 77 | }); | |
| 78 | ||
| 79 | test("shared red: no rework; an own red is reworked as ever; main failing again keeps the wait", async () => { | |
| 80 | finish("main", "failed", ["X"]); | |
| 81 | const shared = await pullOf(); | |
| 82 | const own = await pullOf(); | |
| 83 | finish(`pull-${shared.number}`, "failed", ["X"], shared); | |
| 84 | finish(`pull-${own.number}`, "failed", ["X", "Y"], own); | |
| 85 | expect(reworks(shared)).toHaveLength(0); | |
| 86 | expect(reworks(own)).toHaveLength(1); | |
| 87 | // Still red: nothing is checked again, the pull still waits. | |
| 88 | requested = []; | |
| 89 | finish("main", "failed", ["X"]); | |
| 90 | expect(requested).toHaveLength(0); | |
| 91 | // Green: the waiting pull, not the reworked one, is checked again, once. | |
| 92 | finish("main", "ok", []); | |
| 93 | expect(requested).toEqual([[repo, `pull-${shared.number}`, shared.headOid, "retry", shared.id]]); | |
| 94 | finish("main", "ok", []); | |
| 95 | expect(requested).toHaveLength(1); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a pull pushed, closed or merged meanwhile is not checked again", async () => { | |
| 99 | finish("main", "failed", ["X"]); | |
| 100 | const pushed = await pullOf(); | |
| 101 | const closed = await pullOf(); | |
| 102 | const merged = await pullOf(); | |
| 103 | const kept = await pullOf(); | |
| 104 | for (const p of [pushed, closed, merged, kept]) finish(`pull-${p.number}`, "failed", ["X"], p); | |
| 105 | app.db.run("UPDATE pull SET head_oid = ?1 WHERE issue_id = ?2", ["b".repeat(40), pushed.id]); | |
| 106 | app.db.run("UPDATE issue SET state = 'closed' WHERE id = ?1", [closed.id]); | |
| 107 | app.db.run("UPDATE issue SET state = 'closed' WHERE id = ?1", [merged.id]); | |
| 108 | for (const p of [pushed, closed, merged, kept]) expect(reworks(p)).toHaveLength(0); | |
| 109 | requested = []; | |
| 110 | finish("main", "ok", []); | |
| 111 | expect(requested.map((r) => r[4])).toEqual([kept.id]); | |
| 112 | }); | |
| 113 | }); |
test/red-main.test.ts +60 −1
| @@ -9,7 +9,9 @@ import { tmpdir } from "node:os"; | ||
| 9 | 9 | import { migrations } from "../src/core/schema.ts"; |
| 10 | 10 | import { openDb } from "../src/kernel/db.ts"; |
| 11 | 11 | import { claimRun, createRun, finishRun, listActive, type Run } from "../src/core/runs.ts"; |
| 12 | import { RED_HOLD_MS, heldChecks, redMain, redSentence, unheld } from "../src/checks/red_main.ts"; | |
| 12 | import { RED_HOLD_MS, heldChecks, redMain, redSentence, sharedWithMain, unheld } from "../src/checks/red_main.ts"; | |
| 13 | import { coveredByRedFix } from "../src/crew/red_fix.ts"; | |
| 14 | import { budgetBreaches } from "../src/crew/sentinel.ts"; | |
| 13 | 15 | import { vitals } from "../src/kernel/vitals.ts"; |
| 14 | 16 | import { ErrorLedger } from "../src/kernel/log.ts"; |
| 15 | 17 | import { RefusedLedger } from "../src/kernel/internal_refused.ts"; |
| @@ -120,3 +122,60 @@ describe("red main", () => { | ||
| 120 | 122 | expect(cards[0]!.href).toBe(`/trevato/forge/runs/${failed.id}`); |
| 121 | 123 | }); |
| 122 | 124 | }); |
| 125 | ||
| 126 | describe("a red main shares (#1971)", () => { | |
| 127 | const failed = (names: string[]): Run => { | |
| 128 | const run = createRun(db, { repoId: "r1", kind: "check", env: "pull-7", issueId: pull, commitOid: OID("a"), reason: "push" }); | |
| 129 | claimRun(db, run.id); | |
| 130 | return finishRun(db, run.id, { state: "failed", summary: "check failed", checkDetail: { passed: 1, failed: names.length, names } })!; | |
| 131 | }; | |
| 132 | ||
| 133 | test("a pull red on main's test only is main's; one more test is its own", () => { | |
| 134 | main("b", "failed", ["X"]); | |
| 135 | expect(sharedWithMain(redMain(db, "r1"), failed(["X"]))).toBe(true); | |
| 136 | expect(sharedWithMain(redMain(db, "r1"), failed(["X", "Y"]))).toBe(false); | |
| 137 | expect(sharedWithMain(redMain(db, "r1"), failed(["Y"]))).toBe(false); | |
| 138 | }); | |
| 139 | ||
| 140 | test("nothing named, or a green main, shares nothing", () => { | |
| 141 | main("b", "failed", ["X"]); | |
| 142 | expect(sharedWithMain(redMain(db, "r1"), failed([]))).toBe(false); | |
| 143 | main("c", "ok"); | |
| 144 | expect(sharedWithMain(redMain(db, "r1"), failed(["X"]))).toBe(false); | |
| 145 | }); | |
| 146 | }); | |
| 147 | ||
| 148 | describe("one fix issue for a red main (#1971)", () => { | |
| 149 | const dup = { id: "i9", title: 'Fix "a panel keeps its chart" on main', body: "" }; | |
| 150 | const fixRow = () => | |
| 151 | db.run( | |
| 152 | "INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES ('degraded:red-r1', 'i1', '', 'x', 'x')", | |
| 153 | ); | |
| 154 | ||
| 155 | test("a red main's held checks are a breach: one finding, urgent, keyed by the repository", () => { | |
| 156 | db.run( | |
| 157 | "INSERT INTO degraded_span (cause, sentence, href, opened_at) VALUES ('red:r1', 'trevato/forge: main is red — \"a panel keeps its chart\" fails', '/x', 1000)", | |
| 158 | ); | |
| 159 | const found = budgetBreaches(db, 2000); | |
| 160 | expect(found.map((f) => f.key)).toEqual(["degraded:red-r1"]); | |
| 161 | expect(found[0]!.urgent).toBe(true); | |
| 162 | expect(found[0]!.fix).toContain("only issue"); | |
| 163 | }); | |
| 164 | ||
| 165 | test("while the fix issue is open, an issue quoting main's failing test is covered; not otherwise", () => { | |
| 166 | main("b", "failed", ["a panel keeps its chart"]); | |
| 167 | // No fix issue filed yet: nothing is covered. | |
| 168 | expect(coveredByRedFix(db, "r1", dup)).toBe(false); | |
| 169 | fixRow(); | |
| 170 | expect(coveredByRedFix(db, "r1", dup)).toBe(true); | |
| 171 | // The fix issue itself, and an issue about another test, are handed out. | |
| 172 | expect(coveredByRedFix(db, "r1", { ...dup, id: "i1" })).toBe(false); | |
| 173 | expect(coveredByRedFix(db, "r1", { id: "i8", title: "Something else", body: "" })).toBe(false); | |
| 174 | // Once the fix issue closes, or main is green, nothing is held back. | |
| 175 | db.run("UPDATE issue SET state = 'closed' WHERE id = 'i1'"); | |
| 176 | expect(coveredByRedFix(db, "r1", dup)).toBe(false); | |
| 177 | db.run("UPDATE issue SET state = 'open' WHERE id = 'i1'"); | |
| 178 | main("c", "ok"); | |
| 179 | expect(coveredByRedFix(db, "r1", dup)).toBe(false); | |
| 180 | }); | |
| 181 | }); |
new above
offline
This is the new control flow and nothing covers it. Add a dispatch-level test: shared red means no rework, no strike and the pull recorded as waiting; own red proceeds as before.