Public: anyone can read this; the crew can change it.
test/capacity.test.ts runs in under 10 s, every test under 1 s, proving what it proves today #10229
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10216-test-capacity-test-ts-runs-in-under-10-s → main 2 commits fork
1 file
changed +123
−104
test/capacity.test.ts +123 −104
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; |
| 11 | import { chmodSync, writeFileSync } from "node:fs"; | |
| 11 | import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; | |
| 12 | 12 | import { mkdtemp, rm } from "node:fs/promises"; |
| 13 | 13 | import { tmpdir } from "node:os"; |
| 14 | 14 | import { join } from "node:path"; |
| @@ -637,6 +637,7 @@ describe("the next check honours a concurrency the admin lowered, and gets the s | ||
| 637 | 637 | }); |
| 638 | 638 | |
| 639 | 639 | test("lowered to one, three repositories' checks run one at a time; each sees FORGE_CHECK_SHARDS", async () => { |
| 640 | // Invariant: a checks.concurrency lowered below the boot's three admits one check at a time, and checks.shards reaches each check's env. | |
| 640 | 641 | expect(changeCapacity(app, alice, "checks.concurrency", "1").ok).toBe(true); |
| 641 | 642 | expect(changeCapacity(app, alice, "checks.shards", "2").ok).toBe(true); |
| 642 | 643 | const repos: Repo[] = []; |
| @@ -645,53 +646,62 @@ describe("the next check honours a concurrency the admin lowered, and gets the s | ||
| 645 | 646 | if (!made.ok) throw new Error(String(made.error)); |
| 646 | 647 | repos.push(made.value); |
| 647 | 648 | } |
| 649 | // Each check holds until the test writes `release` into its home, the one | |
| 650 | // place a contained check and the test both see: a check ends when the | |
| 651 | // test says, so a second one let in beside it is still running when sampled. | |
| 652 | const hold = 'echo "shards=$FORGE_CHECK_SHARDS"; while [ ! -e "${FORGE_CHECK_SCRATCH:-$PWD.home}/release" ]; do sleep 0.01; done'; | |
| 653 | await Promise.all( | |
| 654 | repos.map((repo) => | |
| 655 | commitToBranch(app, alice, repo, { | |
| 656 | message: "A held check", | |
| 657 | files: [{ path: "forge.json", content: JSON.stringify({ check: { run: hold } }) }], | |
| 658 | }), | |
| 659 | ), | |
| 660 | ); | |
| 661 | // A running check is released once it has been seen on three samples: | |
| 662 | // time for the gate to start a second beside it, were the lowered limit | |
| 663 | // not honoured. | |
| 648 | 664 | let most = 0; |
| 649 | const watch = setInterval(() => { | |
| 650 | const running = repos | |
| 651 | .flatMap((r) => listRuns(app.db, r.id, { kind: "check", limit: 10 })) | |
| 652 | .filter((r) => r.state === "running").length; | |
| 653 | most = Math.max(most, running); | |
| 654 | }, 20); | |
| 655 | try { | |
| 656 | await Promise.all( | |
| 657 | repos.map((repo) => | |
| 658 | commitToBranch(app, alice, repo, { | |
| 659 | message: "A slow check", | |
| 660 | files: [ | |
| 661 | { | |
| 662 | path: "forge.json", | |
| 663 | content: JSON.stringify({ | |
| 664 | check: { run: 'echo "shards=$FORGE_CHECK_SHARDS"; sleep 1' }, | |
| 665 | }), | |
| 666 | }, | |
| 667 | ], | |
| 668 | }), | |
| 669 | ), | |
| 670 | ); | |
| 671 | const deadline = Date.now() + 60_000; | |
| 672 | let done: Run[] = []; | |
| 673 | while (Date.now() < deadline) { | |
| 674 | done = repos | |
| 675 | .flatMap((r) => listRuns(app.db, r.id, { kind: "check", limit: 10 })) | |
| 676 | .filter((r) => r.state === "ok" || r.state === "failed"); | |
| 677 | if (done.length === 3) break; | |
| 678 | await Bun.sleep(50); | |
| 665 | const seenRunning = new Map<string, number>(); | |
| 666 | const deadline = Date.now() + 60_000; | |
| 667 | let done: Run[] = []; | |
| 668 | while (Date.now() < deadline) { | |
| 669 | const runs = repos.flatMap((r) => listRuns(app.db, r.id, { kind: "check", limit: 10 })); | |
| 670 | const running = runs.filter((r) => r.state === "running"); | |
| 671 | most = Math.max(most, running.length); | |
| 672 | done = runs.filter((r) => r.state === "ok" || r.state === "failed"); | |
| 673 | if (done.length === 3) break; | |
| 674 | for (const run of running) { | |
| 675 | const seen = (seenRunning.get(run.id) ?? 0) + 1; | |
| 676 | seenRunning.set(run.id, seen); | |
| 677 | if (seen === 3) { | |
| 678 | const home = join(dataDir, "checks", `${run.id}.home`); | |
| 679 | mkdirSync(home, { recursive: true }); | |
| 680 | writeFileSync(join(home, "release"), ""); | |
| 681 | } | |
| 679 | 682 | } |
| 680 | expect(done.map((r) => r.state)).toEqual(["ok", "ok", "ok"]); | |
| 681 | expect(most).toBe(1); | |
| 682 | for (const run of done) | |
| 683 | expect(readRunLog(dataDir, run.id).lines).toContain("shards=2"); | |
| 684 | } finally { | |
| 685 | clearInterval(watch); | |
| 683 | await Bun.sleep(20); | |
| 686 | 684 | } |
| 685 | expect(done.map((r) => r.state)).toEqual(["ok", "ok", "ok"]); | |
| 686 | expect(most).toBe(1); | |
| 687 | for (const run of done) | |
| 688 | expect(readRunLog(dataDir, run.id).lines).toContain("shards=2"); | |
| 687 | 689 | }, 90_000); |
| 688 | 690 | }); |
| 689 | 691 | |
| 690 | 692 | describe("the crew's concurrency and shard count reach the next pick", () => { |
| 691 | test("raised from one to two at boot's one, two agents run at once; each shell is told the shards", async () => { | |
| 692 | const dataDir = await mkdtemp(join(tmpdir(), "forge-capacity-crew-")); | |
| 693 | const app = createApp({ dataDir, port: 0 }); | |
| 694 | const servers = await serve(app); | |
| 693 | let dataDir: string; | |
| 694 | let app: App; | |
| 695 | let servers: Servers; | |
| 696 | let crew: Awaited<ReturnType<typeof startCrew>>; | |
| 697 | let alice: User; | |
| 698 | let repo: Repo; | |
| 699 | const agents: User[] = []; | |
| 700 | ||
| 701 | beforeAll(async () => { | |
| 702 | dataDir = await mkdtemp(join(tmpdir(), "forge-capacity-crew-")); | |
| 703 | app = createApp({ dataDir, port: 0 }); | |
| 704 | servers = await serve(app); | |
| 695 | 705 | const fake = join(dataDir, "fake-claude"); |
| 696 | 706 | writeFileSync( |
| 697 | 707 | fake, |
| @@ -700,82 +710,91 @@ if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi | ||
| 700 | 710 | IFS= read -r first |
| 701 | 711 | echo '{"type":"system","subtype":"init","session_id":"s","model":"fake"}' |
| 702 | 712 | echo "{\\"type\\":\\"assistant\\",\\"message\\":{\\"content\\":[{\\"type\\":\\"text\\",\\"text\\":\\"shards=$FORGE_CHECK_SHARDS\\"}]}}" |
| 703 | sleep 2 | |
| 713 | while [ ! -e "${join(dataDir, "release")}" ]; do sleep 0.01; done | |
| 704 | 714 | echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total_cost_usd":0.01,"num_turns":1}' |
| 705 | 715 | `, |
| 706 | 716 | ); |
| 707 | 717 | chmodSync(fake, 0o755); |
| 708 | const crew = await startCrew(app, { | |
| 718 | crew = await startCrew(app, { | |
| 709 | 719 | forgeUrl: servers.url, |
| 710 | 720 | localUrl: servers.url, |
| 711 | 721 | binary: fake, |
| 712 | 722 | concurrency: 1, |
| 713 | 723 | budgetUsd: 1, |
| 714 | 724 | runTimeoutMs: 60_000, |
| 725 | // The pump picks queued runs on a tick; 5 s by default, 20 ms here. | |
| 726 | tickMs: 20, | |
| 715 | 727 | }); |
| 716 | try { | |
| 717 | const a = await createUser(app.db, { | |
| 718 | origin: "seed", | |
| 719 | trusted: true, | |
| 720 | username: "alice", | |
| 721 | email: "[email protected]", | |
| 722 | password: "a-good-passphrase", | |
| 723 | adminIfFirst: true, | |
| 728 | const a = await createUser(app.db, { | |
| 729 | origin: "seed", | |
| 730 | trusted: true, | |
| 731 | username: "alice", | |
| 732 | email: "alice@forge.local", | |
| 733 | password: "a-good-passphrase", | |
| 734 | adminIfFirst: true, | |
| 735 | }); | |
| 736 | if (!a.ok) throw new Error(a.error); | |
| 737 | alice = a.value; | |
| 738 | const created = await createRepo(app, alice, { | |
| 739 | name: "garden", | |
| 740 | visibility: "private", | |
| 741 | }); | |
| 742 | if (!created.ok) throw new Error(String(created.error)); | |
| 743 | repo = created.value; | |
| 744 | const seeded = await commitToBranch(app, alice, repo, { | |
| 745 | message: "Start", | |
| 746 | files: [{ path: "README.md", content: "# garden\n" }], | |
| 747 | }); | |
| 748 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 749 | for (const name of ["bot", "bee"]) { | |
| 750 | const made = await createAgent(app, alice, { | |
| 751 | username: name, | |
| 752 | brief: "Be brief.", | |
| 724 | 753 | }); |
| 725 | if (!a.ok) throw new Error(a.error); | |
| 726 | const alice = a.value; | |
| 727 | expect(changeCapacity(app, alice, "crew.concurrency", "2").ok).toBe(true); | |
| 728 | expect(changeCapacity(app, alice, "checks.shards", "4").ok).toBe(true); | |
| 729 | const created = await createRepo(app, alice, { | |
| 730 | name: "garden", | |
| 731 | visibility: "private", | |
| 732 | }); | |
| 733 | if (!created.ok) throw new Error(String(created.error)); | |
| 734 | const repo = created.value; | |
| 735 | const seeded = await commitToBranch(app, alice, repo, { | |
| 736 | message: "Start", | |
| 737 | files: [{ path: "README.md", content: "# garden\n" }], | |
| 738 | }); | |
| 739 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 740 | for (const name of ["bot", "bee"]) { | |
| 741 | const made = await createAgent(app, alice, { | |
| 742 | username: name, | |
| 743 | brief: "Be brief.", | |
| 744 | }); | |
| 745 | if (!made.ok) throw new Error(String(made.error)); | |
| 746 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); | |
| 747 | const issue = openIssue(app, alice, repo, { | |
| 748 | title: `Plant ${name}`, | |
| 749 | body: "Along the fence.", | |
| 750 | }); | |
| 751 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 752 | expect( | |
| 753 | assignIssue(app, alice, repo, issue.value, made.value.user).ok, | |
| 754 | ).toBe(true); | |
| 755 | } | |
| 756 | let most = 0; | |
| 757 | const deadline = Date.now() + 30_000; | |
| 758 | while (Date.now() < deadline) { | |
| 759 | const runs = listRuns(app.db, repo.id, { kind: "agent" }); | |
| 760 | most = Math.max(most, runs.filter((r) => r.state === "running").length); | |
| 761 | if ( | |
| 762 | runs.length === 2 && | |
| 763 | runs.every((r) => r.state !== "running" && r.state !== "queued") | |
| 764 | ) | |
| 765 | break; | |
| 766 | await Bun.sleep(25); | |
| 767 | } | |
| 768 | expect(most).toBe(2); | |
| 769 | for (const run of listRuns(app.db, repo.id, { kind: "agent" })) | |
| 770 | expect(readRunLog(dataDir, run.id).lines.join("\n")).toContain( | |
| 771 | "shards=4", | |
| 772 | ); | |
| 773 | } finally { | |
| 774 | await crew.stop(); | |
| 775 | servers.stop(); | |
| 776 | app.close(); | |
| 777 | await rm(dataDir, { recursive: true, force: true }); | |
| 754 | if (!made.ok) throw new Error(String(made.error)); | |
| 755 | if (!addToCrew(app, alice, repo, name).ok) throw new Error(`${name} not added to the crew`); | |
| 756 | agents.push(made.value.user); | |
| 778 | 757 | } |
| 758 | }); | |
| 759 | ||
| 760 | afterAll(async () => { | |
| 761 | await crew.stop(); | |
| 762 | servers.stop(); | |
| 763 | app.close(); | |
| 764 | await rm(dataDir, { recursive: true, force: true }); | |
| 765 | }); | |
| 766 | ||
| 767 | test("raised from one to two at boot's one, two agents run at once; each shell is told the shards", async () => { | |
| 768 | // Invariant: crew.concurrency raised above the boot's one lets the next pick run two agents at once, and checks.shards reaches each agent's env. | |
| 769 | expect(changeCapacity(app, alice, "crew.concurrency", "2").ok).toBe(true); | |
| 770 | expect(changeCapacity(app, alice, "checks.shards", "4").ok).toBe(true); | |
| 771 | for (const agent of agents) { | |
| 772 | const issue = openIssue(app, alice, repo, { | |
| 773 | title: `Plant ${agent.username}`, | |
| 774 | body: "Along the fence.", | |
| 775 | }); | |
| 776 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 777 | expect(assignIssue(app, alice, repo, issue.value, agent).ok).toBe(true); | |
| 778 | } | |
| 779 | // Each fake agent holds until `release` exists: released the moment two | |
| 780 | // run at once, so the overlap is seen, not raced. Were the raise not | |
| 781 | // honoured, one runs alone; it is released after three samples alone | |
| 782 | // with the other waiting, and the test fails on most = 1. | |
| 783 | let most = 0; | |
| 784 | let alone = 0; | |
| 785 | const deadline = Date.now() + 30_000; | |
| 786 | while (Date.now() < deadline) { | |
| 787 | const runs = listRuns(app.db, repo.id, { kind: "agent" }); | |
| 788 | const running = runs.filter((r) => r.state === "running").length; | |
| 789 | most = Math.max(most, running); | |
| 790 | if (running === 1) alone += 1; | |
| 791 | if (most === 2 || alone >= 3) writeFileSync(join(dataDir, "release"), ""); | |
| 792 | if (runs.length === 2 && runs.every((r) => r.state !== "running" && r.state !== "queued")) break; | |
| 793 | await Bun.sleep(20); | |
| 794 | } | |
| 795 | expect(most).toBe(2); | |
| 796 | for (const run of listRuns(app.db, repo.id, { kind: "agent" })) | |
| 797 | expect(readRunLog(dataDir, run.id).lines.join("\n")).toContain("shards=4"); | |
| 779 | 798 | }, 60_000); |
| 780 | 799 | }); |
| 781 | 800 |