Public: anyone can read this; the crew can change it.
A branch already on the base opens no pull: 97 empty pulls in 5 minutes must not happen #8631
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8628-a-branch-already-on-the-base-opens-no-pu → main 6 commits fork 2 threads · 2 unresolved
14 files
changed +209
−22
src/core/migration-pins/20261102180000-pull-carried.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 72334d5a3c674352e7ffd37575f6f58ffc2453eee7e3b756f2d3fea0375051c2 |
src/core/migrations/20261102180000-pull-carried.ts added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | // A pull closed as already on the base landed nothing (#8628): the vitals and | |
| 2 | // the home KPIs count a landing only where this is 0. Every pull before is 0. | |
| 3 | // Additive. | |
| 4 | export default ` | |
| 5 | ALTER TABLE pull ADD COLUMN carried INTEGER NOT NULL DEFAULT 0; | |
| 6 | `; |
src/core/pulls.ts +12 −6
| @@ -16,12 +16,12 @@ import { refTip } from "../git/read.ts"; | ||
| 16 | 16 | import { readTips } from "../git/tips.ts"; |
| 17 | 17 | import { |
| 18 | 18 | aheadCount, |
| 19 | contentOnBase, | |
| 19 | 20 | fetchInto, |
| 20 | 21 | merge, |
| 21 | 22 | mergeBase, |
| 22 | 23 | mergeable, |
| 23 | 24 | resolveOid, |
| 24 | treeOf, | |
| 25 | 25 | writeRef, |
| 26 | 26 | type MergeError, |
| 27 | 27 | type MergeStrategy, |
| @@ -1129,6 +1129,7 @@ export async function mergePull( | ||
| 1129 | 1129 | actorId: input.actorId, |
| 1130 | 1130 | baseOid: status.baseOid, |
| 1131 | 1131 | headOid: status.headOid, |
| 1132 | carried: true, | |
| 1132 | 1133 | }); |
| 1133 | 1134 | return ok({ mergeOid: carrier, carried: true }); |
| 1134 | 1135 | } |
| @@ -1189,9 +1190,7 @@ async function carriedBy( | ||
| 1189 | 1190 | ): Promise<string | undefined> { |
| 1190 | 1191 | if (strategy !== "squash" || status.ahead === 0 || !status.mergeable) |
| 1191 | 1192 | return undefined; |
| 1192 | const merged = await mergeable(dir, status.baseOid, status.headOid); | |
| 1193 | if (!merged.ok) return undefined; | |
| 1194 | if (merged.value.treeOid !== (await treeOf(dir, status.baseOid))) | |
| 1193 | if (!(await contentOnBase(dir, status.baseOid, status.headOid))) | |
| 1195 | 1194 | return undefined; |
| 1196 | 1195 | const landing = await resolveOid(dir, `refs/pull/${pull.number}/merged`); |
| 1197 | 1196 | if ( |
| @@ -1207,12 +1206,18 @@ async function carriedBy( | ||
| 1207 | 1206 | function closeAsMerged( |
| 1208 | 1207 | db: Database, |
| 1209 | 1208 | pullId: string, |
| 1210 | landed: { mergeOid: string; actorId: string; baseOid: string; headOid: string }, | |
| 1209 | landed: { | |
| 1210 | mergeOid: string; | |
| 1211 | actorId: string; | |
| 1212 | baseOid: string; | |
| 1213 | headOid: string; | |
| 1214 | carried?: boolean; | |
| 1215 | }, | |
| 1211 | 1216 | ): void { |
| 1212 | 1217 | const now = new Date().toISOString(); |
| 1213 | 1218 | transact(db, () => { |
| 1214 | 1219 | db.query( |
| 1215 | "UPDATE pull SET merged_at = ?1, merged_oid = ?2, merged_by = ?3, base_oid = ?4, head_oid = ?5 WHERE issue_id = ?6 AND merged_at IS NULL", | |
| 1220 | "UPDATE pull SET merged_at = ?1, merged_oid = ?2, merged_by = ?3, base_oid = ?4, head_oid = ?5, carried = ?7 WHERE issue_id = ?6 AND merged_at IS NULL", | |
| 1216 | 1221 | ).run( |
| 1217 | 1222 | now, |
| 1218 | 1223 | landed.mergeOid, |
| @@ -1220,6 +1225,7 @@ function closeAsMerged( | ||
| 1220 | 1225 | landed.baseOid, |
| 1221 | 1226 | landed.headOid, |
| 1222 | 1227 | pullId, |
| 1228 | landed.carried === true ? 1 : 0, | |
| 1223 | 1229 | ); |
| 1224 | 1230 | setIssueState(db, pullId, "closed"); |
| 1225 | 1231 | }); |
src/crew/early.ts +10 −1
| @@ -21,7 +21,7 @@ import { listReviews } from "../core/reviews.ts"; | ||
| 21 | 21 | import type { Repo } from "../core/repos.ts"; |
| 22 | 22 | import { getUserById } from "../core/users.ts"; |
| 23 | 23 | import type { Run } from "../core/runs.ts"; |
| 24 | import { aheadCount, resolveOid } from "../git/merge.ts"; | |
| 24 | import { aheadCount, contentOnBase, resolveOid } from "../git/merge.ts"; | |
| 25 | 25 | |
| 26 | 26 | /** How often the branch is looked at at first. */ |
| 27 | 27 | export const WATCH_MS = 1000; |
| @@ -112,6 +112,8 @@ export function watchForPull(input: EarlyInput): EarlyPull { | ||
| 112 | 112 | const log = `${input.forgeUrl}/${input.repo.ownerName}/${input.repo.name}/runs/${input.run.id}`; |
| 113 | 113 | let done = input.continuing; |
| 114 | 114 | let busy: Promise<void> = Promise.resolve(); |
| 115 | // The (base, head) pair last found level in content: judged once, not every tick. | |
| 116 | let level = ""; | |
| 115 | 117 | const tick = async (): Promise<void> => { |
| 116 | 118 | if (done) return; |
| 117 | 119 | const head = await resolveOid(input.forkBare, `refs/heads/${input.branch}`); |
| @@ -119,6 +121,13 @@ export function watchForPull(input: EarlyInput): EarlyPull { | ||
| 119 | 121 | const base = await resolveOid(input.bare, `refs/heads/${input.repo.defaultBranch}`); |
| 120 | 122 | if (base === undefined || done) return; |
| 121 | 123 | if ((await aheadCount(input.forkBare, base, head)) === 0 || done) return; |
| 124 | // Ahead by commits, level in content: a squash already carried it. | |
| 125 | if (level === `${base} ${head}`) return; | |
| 126 | if (await contentOnBase(input.forkBare, base, head)) { | |
| 127 | level = `${base} ${head}`; | |
| 128 | return; | |
| 129 | } | |
| 130 | if (done) return; | |
| 122 | 131 | // Set before the open, so a failed open is not retried every second: |
| 123 | 132 | // `landWork` opens the pull at the run's end when this did not. |
| 124 | 133 | done = true; |
src/crew/runner.ts +4 −2
| @@ -80,7 +80,7 @@ import { cloneWithMirror } from "./mirror.ts"; | ||
| 80 | 80 | import { isSafeRef } from "../git/guard.ts"; |
| 81 | 81 | import { runGitCapture } from "../git/env.ts"; |
| 82 | 82 | import { looksForEnv } from "../core/looks.ts"; |
| 83 | import { aheadCount, resolveOid } from "../git/merge.ts"; | |
| 83 | import { aheadCount, contentOnBase, resolveOid } from "../git/merge.ts"; | |
| 84 | 84 | import { repoDir } from "../git/repos.ts"; |
| 85 | 85 | import { bar } from "../cloud/gate.ts"; |
| 86 | 86 | import { declarationsOf } from "../cloud/declared.ts"; |
| @@ -2109,7 +2109,9 @@ async function landWork( | ||
| 2109 | 2109 | // alternates and its own on top. |
| 2110 | 2110 | const proposed = |
| 2111 | 2111 | remote !== undefined && base !== undefined |
| 2112 | ? await aheadCount(forkBare, base, remote) | |
| 2112 | ? (await contentOnBase(forkBare, base, remote)) | |
No test covers this branch. Add a landWork test (content-level fork branch proposes 0, opens no pull) that fails if this hunk is reverted. |
||
| 2113 | ? 0 | |
| 2114 | : await aheadCount(forkBare, base, remote) | |
| 2113 | 2115 | : 0; |
| 2114 | 2116 | const report = [message.trim(), note] |
| 2115 | 2117 | .filter((part): part is string => part !== undefined && part !== "") |
src/flow/clog_tick.ts +1 −1
| @@ -63,7 +63,7 @@ function twoHours(db: Database, now: Date): { opened: number; landed: number } { | ||
| 63 | 63 | (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id |
| 64 | 64 | WHERE i.created_at >= ?1 AND i.created_at <= ?2) AS opened, |
| 65 | 65 | (SELECT COUNT(*) FROM pull p JOIN repo r ON r.id = (SELECT repo_id FROM issue WHERE id = p.issue_id) |
| 66 | WHERE p.base_branch = r.default_branch AND p.merged_at >= ?1 AND p.merged_at <= ?2) AS landed`, | |
| 66 | WHERE p.base_branch = r.default_branch AND p.carried = 0 AND p.merged_at >= ?1 AND p.merged_at <= ?2) AS landed`, | |
| 67 | 67 | ) |
| 68 | 68 | .get(from, to); |
| 69 | 69 | return { opened: row?.opened ?? 0, landed: row?.landed ?? 0 }; |
src/flow/keeper_rate.ts +2 −2
| @@ -19,7 +19,7 @@ export function landedBetween(db: Database, fromMs: number, toMs: number): numbe | ||
| 19 | 19 | const row = db |
| 20 | 20 | .query<{ n: number }, [string, string]>( |
| 21 | 21 | `SELECT COUNT(*) AS n FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id |
| 22 | WHERE p.base_branch = r.default_branch AND p.merged_at >= ?1 AND p.merged_at <= ?2`, | |
| 22 | WHERE p.base_branch = r.default_branch AND p.carried = 0 AND p.merged_at >= ?1 AND p.merged_at <= ?2`, | |
| 23 | 23 | ) |
| 24 | 24 | .get(new Date(fromMs).toISOString(), new Date(toMs).toISOString()); |
| 25 | 25 | return row?.n ?? 0; |
| @@ -35,7 +35,7 @@ export function hourly(db: Database, now: number, hours: number): { landed: numb | ||
| 35 | 35 | const merged = db |
| 36 | 36 | .query<{ at: string }, [string, string]>( |
| 37 | 37 | `SELECT p.merged_at AS at FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id |
| 38 | WHERE p.base_branch = r.default_branch AND p.merged_at >= ?1 AND p.merged_at <= ?2`, | |
| 38 | WHERE p.base_branch = r.default_branch AND p.carried = 0 AND p.merged_at >= ?1 AND p.merged_at <= ?2`, | |
| 39 | 39 | ) |
| 40 | 40 | .all(since, until); |
| 41 | 41 | for (const m of merged) { |
src/flow/kpis.ts +1 −1
| @@ -89,7 +89,7 @@ function firstLandings(db: Database, since: string, to: string): string[] { | ||
| 89 | 89 | .query<{ repo: string; oid: string; at: string; issue: string; landing: number }, [string, string]>( |
| 90 | 90 | `SELECT i.repo_id AS repo, q.merged_oid AS oid, q.merged_at AS at, q.issue_id AS issue, |
| 91 | 91 | (q.base_branch = r.default_branch AND q.merged_at >= ?1 |
| 92 | AND q.merged_oid != q.base_oid) AS landing | |
| 92 | AND q.carried = 0 AND q.merged_oid != q.base_oid) AS landing | |
| 93 | 93 | FROM pull q JOIN issue i ON i.id = q.issue_id JOIN repo r ON r.id = i.repo_id |
| 94 | 94 | WHERE q.merged_at IS NOT NULL AND q.merged_at <= ?2 |
| 95 | 95 | AND q.merged_oid IN ( |
src/flow/repo_flow.ts +2 −2
| @@ -44,9 +44,9 @@ export function readRepoFlow(db: Database, repoId: string, now = new Date()): Re | ||
| 44 | 44 | (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id |
| 45 | 45 | WHERE i.repo_id = ?1 AND i.created_at >= ?3 AND i.created_at <= ?4) AS opened6h, |
| 46 | 46 | (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id |
| 47 | WHERE i.repo_id = ?1 AND p.base_branch = r.default_branch AND p.merged_at >= ?2 AND p.merged_at <= ?4) AS landed1h, | |
| 47 | WHERE i.repo_id = ?1 AND p.base_branch = r.default_branch AND p.carried = 0 AND p.merged_at >= ?2 AND p.merged_at <= ?4) AS landed1h, | |
| 48 | 48 | (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id |
| 49 | WHERE i.repo_id = ?1 AND p.base_branch = r.default_branch AND p.merged_at >= ?3 AND p.merged_at <= ?4) AS landed6h`, | |
| 49 | WHERE i.repo_id = ?1 AND p.base_branch = r.default_branch AND p.carried = 0 AND p.merged_at >= ?3 AND p.merged_at <= ?4) AS landed6h`, | |
| 50 | 50 | ) |
| 51 | 51 | .get(repoId, h1, h6, to, fresh); |
| 52 | 52 | return { |
src/git/merge.ts +15
| @@ -376,6 +376,21 @@ export async function aheadCount( | ||
| 376 | 376 | return result.ok ? Number(result.value.trim()) || 0 : 0; |
| 377 | 377 | } |
| 378 | 378 | |
| 379 | /** | |
| 380 | * The head is ahead of the base by commit count while merging it onto the | |
| 381 | * base would write the base's own tree: its content is already there (a | |
| 382 | * squash landed it), so there is nothing to propose. The one test `watchForPull`, | |
| 383 | * `landWork` and the autonomy's carried check share. | |
| 384 | */ | |
| 385 | export async function contentOnBase( | |
| 386 | dir: string, | |
| 387 | baseOid: string, | |
| 388 | headOid: string, | |
| 389 | ): Promise<boolean> { | |
| 390 | const merged = await mergeable(dir, baseOid, headOid); | |
| 391 | return merged.ok && merged.value.treeOid === (await treeOf(dir, baseOid)); | |
| 392 | } | |
| 393 | ||
| 379 | 394 | /** |
| 380 | 395 | * Fetch a ref from another local repo (a fork) into this one, bringing its |
| 381 | 396 | * objects across, and return the fetched tip. Used to pull a fork's head into |
test/crew-early.test.ts +95 −3
| @@ -20,11 +20,13 @@ import { answeredBeforeStop } from "../src/crew/already.ts"; | ||
| 20 | 20 | import { alreadyDone } from "../src/core/done_on_main.ts"; |
| 21 | 21 | import { updateRepoSettings } from "../src/actions/repos.ts"; |
| 22 | 22 | import { pullOfBranch, watchForPull } from "../src/crew/early.ts"; |
| 23 | import { resolveOid } from "../src/git/merge.ts"; | |
| 23 | import { contentOnBase, resolveOid } from "../src/git/merge.ts"; | |
| 24 | import { openPull } from "../src/actions/pulls.ts"; | |
| 25 | import { readRepoFlow } from "../src/flow/repo_flow.ts"; | |
| 24 | 26 | import { abortAgentRun } from "../src/crew/running.ts"; |
| 25 | 27 | import { getAgentByName } from "../src/core/agents.ts"; |
| 26 | 28 | import { createIssue, getIssue, listComments } from "../src/core/issues.ts"; |
| 27 | import { listPulls } from "../src/core/pulls.ts"; | |
| 29 | import { listPulls, mergePull } from "../src/core/pulls.ts"; | |
| 28 | 30 | import { repoDir } from "../src/git/repos.ts"; |
| 29 | 31 | import { getForkOf, type Repo } from "../src/core/repos.ts"; |
| 30 | 32 | import { getRun, listRuns, type Run } from "../src/core/runs.ts"; |
| @@ -94,6 +96,25 @@ describe("the crew, with a stand-in Claude Code: the early pull", () => { | ||
| 94 | 96 | expect(log).toContain(`opened pull #${early.number} at the first push`); |
| 95 | 97 | }, 60_000); |
| 96 | 98 | |
| 99 | test("a run whose branch is ahead by commits but level in content proposes nothing and opens no pull (#8628)", async () => { | |
| 100 | rmSync(join(dataDir, "release"), { force: true }); | |
| 101 | const issue = openIssue(app, alice, repo, { title: "Plant lichen", body: "LEVEL-ME on the wall." }); | |
| 102 | if (!issue.ok) throw new Error("issue"); | |
| 103 | expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true); | |
| 104 | const branch = `bot/${issue.value.number}-plant-lichen`; | |
| 105 | const run = await settled((r) => r.branch === branch); | |
| 106 | expect(run.summary).not.toMatch(/opened pull|updated pull/); | |
| 107 | const fork = getForkOf(app.db, bot.id, repo.id)!; | |
| 108 | const forkBare = repoDir(app.reposRoot, fork.ownerName, fork.name); | |
| 109 | const bare = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 110 | // The branch is ahead by a commit, yet holds nothing the base lacks. | |
| 111 | const head = (await resolveOid(forkBare, `refs/heads/${branch}`))!; | |
| 112 | const base = (await resolveOid(bare, `refs/heads/${repo.defaultBranch}`))!; | |
| 113 | expect(await contentOnBase(forkBare, base, head)).toBe(true); | |
| 114 | expect(pullOfBranch(app, repo, fork, branch)).toBeUndefined(); | |
| 115 | expect(readRunLog(dataDir, run.id).lines.join("\n")).toContain("nothing to propose"); | |
| 116 | }, 60_000); | |
| 117 | ||
| 97 | 118 | test("a body a person edited while the run was going is left alone at the run's end", async () => { |
| 98 | 119 | rmSync(join(dataDir, "release"), { force: true }); |
| 99 | 120 | const issue = openIssue(app, alice, repo, { |
| @@ -266,7 +287,9 @@ describe("the crew, with a stand-in Claude Code: the early pull", () => { | ||
| 266 | 287 | return out.trim(); |
| 267 | 288 | }; |
| 268 | 289 | const baseTip = (await resolveOid(bare, `refs/heads/${repo.defaultBranch}`))!; |
| 269 | const tree = await git("rev-parse", `${baseTip}^{tree}`); | |
| 290 | // The empty tree: content the base does not hold (#8628 stops a branch | |
| 291 | // whose content it does). | |
| 292 | const tree = await git("hash-object", "-t", "tree", "/dev/null"); | |
| 270 | 293 | const commit = await git("commit-tree", tree, "-p", baseTip, "-m", "ferns"); |
| 271 | 294 | await git("update-ref", `refs/heads/${branch}`, commit); |
| 272 | 295 | |
| @@ -293,6 +316,75 @@ describe("the crew, with a stand-in Claude Code: the early pull", () => { | ||
| 293 | 316 | expect(pullOfBranch(app, repo, fork, branch)).toBeUndefined(); |
| 294 | 317 | }, 30_000); |
| 295 | 318 | |
| 319 | test("a branch ahead by commits but level in content opens no pull, run after run, and a carried close is no landing (#8628)", async () => { | |
| 320 | const agent = getAgentByName(app.db, "bot")!; | |
| 321 | const fork = getForkOf(app.db, bot.id, repo.id)!; | |
| 322 | const bare = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 323 | const forkBare = repoDir(app.reposRoot, fork.ownerName, fork.name); | |
| 324 | const issue = openIssue(app, alice, repo, { title: "Plant moss", body: "Damp." }); | |
| 325 | if (!issue.ok) throw new Error("issue"); | |
| 326 | const git = async (...args: string[]) => { | |
| 327 | const p = Bun.spawn(["git", ...args], { | |
| 328 | cwd: forkBare, | |
| 329 | stdout: "pipe", | |
| 330 | stderr: "pipe", | |
| 331 | env: { | |
| 332 | ...process.env, | |
| 333 | GIT_AUTHOR_NAME: "t", | |
| 334 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 335 | GIT_COMMITTER_NAME: "t", | |
| 336 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 337 | }, | |
| 338 | }); | |
| 339 | const out = await new Response(p.stdout).text(); | |
| 340 | expect(await p.exited).toBe(0); | |
| 341 | return out.trim(); | |
| 342 | }; | |
| 343 | // One commit beyond the base whose tree is the base's: its content is | |
| 344 | // already there, as after a squash landed it. | |
| 345 | const branch = "bot/already-carried"; | |
| 346 | const baseTip = (await resolveOid(bare, `refs/heads/${repo.defaultBranch}`))!; | |
| 347 | const tree = await git("rev-parse", `${baseTip}^{tree}`); | |
| 348 | const commit = await git("commit-tree", tree, "-p", baseTip, "-m", "moss"); | |
| 349 | await git("update-ref", `refs/heads/${branch}`, commit); | |
| 350 | expect(await contentOnBase(forkBare, baseTip, commit)).toBe(true); | |
| 351 | ||
| 352 | const run = listRuns(app.db, repo.id, { kind: "agent" })[0]!; | |
| 353 | for (let again = 0; again < 3; again++) { | |
| 354 | const lines: string[] = []; | |
| 355 | const early = watchForPull({ | |
| 356 | app, repo, agent, issue: issue.value, run, fork, branch, forkBare, bare, | |
| 357 | forgeUrl: "http://forge.test", | |
| 358 | write: (l) => lines.push(l), | |
| 359 | variantGroup: null, | |
| 360 | continuing: false, | |
| 361 | intervalMs: 10, | |
| 362 | }); | |
| 363 | await Bun.sleep(100); | |
| 364 | await early.stop(); | |
| 365 | expect(lines).toEqual([]); | |
| 366 | } | |
| 367 | expect(pullOfBranch(app, repo, fork, branch)).toBeUndefined(); | |
| 368 | ||
| 369 | // Opened anyway, a pull like it closes as merged, and landed counts none. | |
| 370 | const opened = await openPull(app, bot, repo, { | |
| 371 | title: "Plant moss", | |
| 372 | body: `Closes #${issue.value.number}`, | |
| 373 | baseBranch: repo.defaultBranch, | |
| 374 | headBranch: branch, | |
| 375 | headRepo: fork, | |
| 376 | }); | |
| 377 | if (!opened.ok) throw new Error("pull"); | |
| 378 | const landed = await mergePull(app.db, bare, opened.value, { | |
| 379 | strategy: "squash", | |
| 380 | message: "moss", | |
| 381 | actorId: alice.id, | |
| 382 | author: { name: "t", email: "[email protected]" }, | |
| 383 | }); | |
| 384 | expect(landed.ok && landed.value.carried).toBe(true); | |
| 385 | expect(readRepoFlow(app.db, repo.id).landed1h).toBe(0); | |
| 386 | }, 30_000); | |
| 387 | ||
| 296 | 388 | test("a run whose pull is closed under it, after it answered already done on main, notes and holds its issue", async () => { |
| 297 | 389 | rmSync(join(dataDir, "answered"), { force: true }); |
| 298 | 390 | // The note and the hold are the autonomous crew's (`settleAgentRun`). |
test/flow-carried.test.ts added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | /** A pull closed as carried (#8628) lands nothing: the clog tick, the keeper's rate and the home figures leave it out. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts"; | |
| 4 | import { loadClog, tickClog } from "../src/flow/clog_tick.ts"; | |
| 5 | import { hourly, landedBetween } from "../src/flow/keeper_rate.ts"; | |
| 6 | import { readKpis } from "../src/flow/kpis.ts"; | |
| 7 | ||
| 8 | const MIN = 60_000; | |
| 9 | let f: SnappyFixture; | |
| 10 | let n = 0; | |
| 11 | ||
| 12 | /** A merged pull on the default branch; `carried` says it found its change already there. */ | |
| 13 | function merged(at: number, carried: boolean): void { | |
| 14 | const repo = f.repos[0]!; | |
| 15 | const id = `carried-${n}`; | |
| 16 | f.app.db | |
| 17 | .query(`INSERT INTO issue (id, repo_id, number, kind, title, author_id, state) VALUES (?1, ?2, ?3, 'pull', 'p', ?4, 'closed')`) | |
| 18 | .run(id, repo.id, 5000 + n, f.owner.id); | |
| 19 | // Its own merged_oid, never the base's: only the flag can keep it out. | |
| 20 | f.app.db | |
| 21 | .query( | |
| 22 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at, merged_oid, carried) | |
| 23 | VALUES (?1, ?2, ?3, ?4, '', ?5, ?6, ?7)`, | |
| 24 | ) | |
| 25 | .run(id, repo.defaultBranch, `b/${id}`, `base-${n}`, new Date(at).toISOString(), `oid-${n}`, carried ? 1 : 0); | |
| 26 | n++; | |
| 27 | } | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | f = await snappyFixture({ repos: 1, issuesPerRepo: 0 }); | |
| 31 | }, 60_000); | |
| 32 | afterAll(async () => { | |
| 33 | await f.close(); | |
| 34 | }); | |
| 35 | ||
| 36 | test("a carried pull is no landing in the keeper's rate, the hourly bars or the home figures", () => { | |
| 37 | const now = Date.now(); | |
| 38 | merged(now - 10 * MIN, false); | |
| 39 | for (let i = 0; i < 3; i++) merged(now - (20 + i) * MIN, true); | |
| 40 | expect(landedBetween(f.app.db, now - 60 * MIN, now)).toBe(1); | |
| 41 | expect(hourly(f.app.db, now, 3).landed.reduce((a, b) => a + b, 0)).toBe(1); | |
| 42 | expect(readKpis(f.app.db, new Date(now)).landed).toBe(1); | |
| 43 | }); | |
| 44 | ||
| 45 | test("ten carried closes in two hours are not ten landings: the clog tick still sees the clog", () => { | |
| 46 | const now = Date.now(); | |
| 47 | for (let i = 0; i < 10; i++) merged(now - (30 + i) * MIN, true); | |
| 48 | tickClog(f.app.db, f.app.bus, new Date(now + MIN)); | |
| 49 | expect(loadClog(f.app.db)?.firstReasons.join(" ")).toContain("landed of"); | |
| 50 | expect(loadClog(f.app.db)?.firstReasons.join(" ")).not.toContain("11 landed"); | |
| 51 | }); |
test/helpers/crew-standin.ts +5
| @@ -135,6 +135,11 @@ touch "${dataDir}/answered" | ||
| 135 | 135 | sleep 30 |
| 136 | 136 | exit 0 |
| 137 | 137 | fi |
| 138 | if grep -q LEVEL-ME "${promptFile}"; then | |
| 139 | git commit -q --allow-empty -m "level with main" && git push -q -u origin HEAD | |
| 140 | echo '{"type":"result","subtype":"success","is_error":false,"result":"Nothing to change.","total_cost_usd":0.005,"num_turns":1,"session_id":"fake-8","terminal_reason":"completed"}' | |
| 141 | exit 0 | |
| 142 | fi | |
| 138 | 143 | if grep -q PUSH-THEN-FAIL "${promptFile}"; then |
| 139 | 144 | echo "early work $(date +%s%N)" >> CREW.md |
| 140 | 145 | git add -A && git commit -qm "crew work" && git push -q -u origin HEAD |
test/home-kpi-row.test.ts +4 −4
| @@ -124,7 +124,7 @@ describe("the builders figure from the database", () => { | ||
| 124 | 124 | const db = new Database(":memory:"); |
| 125 | 125 | db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)"); |
| 126 | 126 | db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT, cost_usd REAL, created_at TEXT)"); |
| 127 | db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); | |
| 127 | db.run("CREATE TABLE pull (carried INTEGER NOT NULL DEFAULT 0, merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); | |
| 128 | 128 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); |
| 129 | 129 | reachTables(db); |
| 130 | 130 | db.run("INSERT INTO repo VALUES ('autonomous', 3, 'main', 'a'), ('autonomous', 1, 'main', 'b'), ('off', 8, 'main', 'c')"); |
| @@ -188,7 +188,7 @@ describe("the spend figure from the database", () => { | ||
| 188 | 188 | const db = new Database(":memory:"); |
| 189 | 189 | db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)"); |
| 190 | 190 | db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT, cost_usd REAL, created_at TEXT)"); |
| 191 | db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); | |
| 191 | db.run("CREATE TABLE pull (carried INTEGER NOT NULL DEFAULT 0, merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); | |
| 192 | 192 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); |
| 193 | 193 | reachTables(db); |
| 194 | 194 | db.run( |
| @@ -240,7 +240,7 @@ describe("home kpi row: the last off-box backup", () => { | ||
| 240 | 240 | const db = new Database(":memory:"); |
| 241 | 241 | db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)"); |
| 242 | 242 | db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT, cost_usd REAL, created_at TEXT)"); |
| 243 | db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); | |
| 243 | db.run("CREATE TABLE pull (carried INTEGER NOT NULL DEFAULT 0, merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); | |
| 244 | 244 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); |
| 245 | 245 | reachTables(db); |
| 246 | 246 | expect(readKpis(db, NOW, { ceilingUsd: null, dataDir: dir }).backupAt).toBeNull(); |
| @@ -430,7 +430,7 @@ describe("the forge ceiling hit (#5798)", () => { | ||
| 430 | 430 | const db = new Database(":memory:"); |
| 431 | 431 | db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)"); |
| 432 | 432 | db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT, cost_usd REAL, created_at TEXT)"); |
| 433 | db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); | |
| 433 | db.run("CREATE TABLE pull (carried INTEGER NOT NULL DEFAULT 0, merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); | |
| 434 | 434 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); |
| 435 | 435 | reachTables(db); |
| 436 | 436 | db.run( |
new above
offline
This runs a merge-tree every WATCH_MS for a content-level branch, since done stays false. Remember the last (base, head) judged and skip when unchanged.