Public: anyone can read this; the crew can change it.
The planner is held while nothing lands or the plate is full, and the tick log says why #1444
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1127-the-planner-is-held-while-nothing-lands → main 5 commits fork 4 threads · 4 unresolved
6 files
changed +287
−6
README.md +7
| @@ -309,6 +309,13 @@ changes, a line comment — and the agent picks it up in the background, | ||
| 309 | 309 | resuming its session. A failed check re-engages it too, a bounded number of |
| 310 | 310 | times before a person must speak. |
| 311 | 311 | |
| 312 | An autonomous app's planner is **held** while nothing is landing or the plate | |
| 313 | is full: no pull has merged in 48 h while one was closed unmerged, or more than | |
| 314 | `slots × 3` ready, unassigned issues are waiting. The tick log says which | |
| 315 | (`planner: held`), and the board and the terminal's repository list say it in | |
| 316 | words. A comment on the direction issue from someone who may write there lifts | |
| 317 | the hold; an issue handed to the planner is still owed its pass. | |
| 318 | ||
| 312 | 319 | The forge asks the host for one more binary: `claude`, on `PATH` or at |
| 313 | 320 | `FORGE_CLAUDE`, authenticated as the operator (an `ANTHROPIC_API_KEY` in the |
| 314 | 321 | forge's environment is handed to runs; the machine's own Claude Code login |
src/crew/autonomy.ts +103
| @@ -59,6 +59,7 @@ import { | ||
| 59 | 59 | pullDir, |
| 60 | 60 | } from "../core/pulls.ts"; |
| 61 | 61 | import { |
| 62 | canWrite, | |
| 62 | 63 | getRepoById, |
| 63 | 64 | listAutonomousRepos, |
| 64 | 65 | setDirectionIssue, |
| @@ -439,6 +440,104 @@ function notPlanned(repo: Repo, why: string): false { | ||
| 439 | 440 | return false; |
| 440 | 441 | } |
| 441 | 442 | |
| 443 | /** Nothing landing for this long, with a pull closed unmerged, holds the planner. */ | |
| 444 | const LANDING_WINDOW_MS = 48 * 60 * 60_000; | |
| 445 | /** More ready, unassigned issues than slots × this fill the plate. */ | |
| 446 | const PLATE_PER_SLOT = 3; | |
| 447 | ||
| 448 | export type PlannerHold = | |
| 449 | | { readonly kind: "nothing landed"; readonly since: string } | |
| 450 | | { readonly kind: "plate full"; readonly ready: number }; | |
| 451 | ||
| 452 | /** Why a hold is held, as the tick log says it. */ | |
| 453 | export function holdWhy(hold: PlannerHold): string { | |
| 454 | return hold.kind === "plate full" | |
| 455 | ? `plate full (${hold.ready} ready)` | |
| 456 | : `nothing landed since ${hold.since.slice(0, 10)}`; | |
| 457 | } | |
| 458 | ||
| 459 | /** The same reason in plain words, for the board. */ | |
| 460 | export function holdWords(hold: PlannerHold): string { | |
| 461 | if (hold.kind === "plate full") | |
| 462 | return `Planning paused: ${hold.ready} issues are ready and waiting`; | |
| 463 | const at = new Date(hold.since); | |
| 464 | const day = at.toLocaleDateString("en-US", { | |
| 465 | month: "short", | |
| 466 | day: "numeric", | |
| 467 | timeZone: "UTC", | |
| 468 | }); | |
| 469 | return `Planning paused: nothing has landed since ${day}`; | |
| 470 | } | |
| 471 | ||
| 472 | /** | |
| 473 | * A word on the direction issue from someone who may write here, newer than | |
| 474 | * the planner's last pass: the owner steering, which is never held. | |
| 475 | */ | |
| 476 | function steered(app: App, repo: Repo): boolean { | |
| 477 | if (repo.directionIssueId === null) return false; | |
| 478 | const last = app.db | |
| 479 | .query<{ created_at: string }, [string]>( | |
| 480 | `SELECT created_at FROM run WHERE issue_id = ?1 AND kind = 'agent' | |
| 481 | ORDER BY created_at DESC LIMIT 1`, | |
| 482 | ) | |
| 483 | .get(repo.directionIssueId); | |
| 484 | return app.db | |
| 485 | .query<{ id: string }, [string, string]>( | |
| 486 | `SELECT DISTINCT author_id AS id FROM comment | |
| 487 | WHERE issue_id = ?1 AND created_at > ?2 AND origin_author IS NULL`, | |
| 488 | ) | |
| 489 | .all(repo.directionIssueId, last?.created_at ?? "") | |
| 490 | .some((r) => { | |
| 491 | const who = getUserById(app.db, r.id); | |
| 492 | return who !== undefined && who.kind !== "agent" && canWrite(app.db, repo, who); | |
| 493 | }); | |
| 494 | } | |
| 495 | ||
| 496 | /** | |
| 497 | * Whether the planner is held for this repository: no pull merged in 48 h | |
| 498 | * while one closed unmerged, or more ready, unassigned issues than | |
| 499 | * `slots × 3`. A steer from a writer on the direction issue lifts it. | |
| 500 | */ | |
| 501 | export function plannerHold( | |
| 502 | app: App, | |
| 503 | repo: Repo, | |
| 504 | now = new Date(), | |
| 505 | ): PlannerHold | null { | |
| 506 | if (steered(app, repo)) return null; | |
| 507 | const cutoff = new Date(now.getTime() - LANDING_WINDOW_MS).toISOString(); | |
| 508 | const merged = app.db | |
| 509 | .query<{ at: string | null }, [string]>( | |
| 510 | `SELECT MAX(p.merged_at) AS at FROM pull p JOIN issue i ON i.id = p.issue_id | |
| 511 | WHERE i.repo_id = ?1`, | |
| 512 | ) | |
| 513 | .get(repo.id)?.at ?? null; | |
| 514 | if (merged === null || merged < cutoff) { | |
| 515 | const refused = app.db | |
| 516 | .query<{ n: number }, [string, string]>( | |
| 517 | `SELECT COUNT(*) AS n FROM issue i JOIN pull p ON p.issue_id = i.id | |
| 518 | WHERE i.repo_id = ?1 AND i.kind = 'pull' AND i.state = 'closed' | |
| 519 | AND p.merged_at IS NULL AND i.closed_at >= ?2`, | |
| 520 | ) | |
| 521 | .get(repo.id, cutoff)?.n ?? 0; | |
| 522 | if (refused > 0) return { kind: "nothing landed", since: merged ?? cutoff }; | |
| 523 | } | |
| 524 | const ready = readyNow(repoGraph(app.db, repo.id, { sealed: true })).filter( | |
| 525 | (r) => r.issue.id !== repo.directionIssueId && r.issue.assigneeId === null, | |
| 526 | ).length; | |
| 527 | if (ready > Math.max(1, repo.autonomySlots) * PLATE_PER_SLOT) | |
| 528 | return { kind: "plate full", ready }; | |
| 529 | return null; | |
| 530 | } | |
| 531 | ||
| 532 | function held(repo: Repo, why: string): false { | |
| 533 | log.info("tick", { | |
| 534 | repo: `${repo.ownerName}/${repo.name}`, | |
| 535 | planner: "held", | |
| 536 | why, | |
| 537 | }); | |
| 538 | return false; | |
| 539 | } | |
| 540 | ||
| 442 | 541 | async function planIfThin( |
| 443 | 542 | app: App, |
| 444 | 543 | repo: Repo, |
| @@ -472,6 +571,10 @@ async function planIfThin( | ||
| 472 | 571 | if (ready >= THIN_MAP && owed.length === 0) return false; |
| 473 | 572 | if (atRunCap(app, repo, direction)) |
| 474 | 573 | return notPlanned(repo, "parked: the direction is at its run cap"); |
| 574 | // An issue the crew handed over is owed its pass whatever the hold: the | |
| 575 | // planner splitting it is how work starts landing again. | |
| 576 | const hold = owed.length === 0 ? plannerHold(app, repo, now) : null; | |
| 577 | if (hold !== null) return held(repo, holdWhy(hold)); | |
| 475 | 578 | if (lastPlan !== null && lastPlan !== undefined) { |
| 476 | 579 | // A planner that declined the direction is not asked again until a |
| 477 | 580 | // person has said something on it, or the crew hands it an issue. |
src/tui/screens/dashboard.ts +12 −1
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | * whole of this screen's design. |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | import { holdWords, plannerHold } from "../../crew/autonomy.ts"; | |
| 17 | 18 | import { forgeNeeds } from "../../actions/health.ts"; |
| 18 | 19 | import { mayView } from "../../core/sealed.ts"; |
| 19 | 20 | import { pinRepo, unpinRepo } from "../../actions/origins.ts"; |
| @@ -89,6 +90,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 89 | 90 | let pins: Pin[] = []; |
| 90 | 91 | let pinned = new Set<string>(); |
| 91 | 92 | let onYou: OnYouItem[] = []; |
| 93 | /** Why planning is paused, for the autonomous apps where it is; as on the board. */ | |
| 94 | let holds = new Map<string, string>(); | |
| 92 | 95 | let forge: string[] | undefined; |
| 93 | 96 | const filter = new Filter(); |
| 94 | 97 | /** One cursor per pane: moving in one must never move another. */ |
| @@ -106,6 +109,14 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 106 | 109 | pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>(); |
| 107 | 110 | all = listReposVisibleTo(f.db, f.user, { limit: 200 }); |
| 108 | 111 | onYou = assignedToMe(); |
| 112 | const now = new Date(); | |
| 113 | holds = new Map( | |
| 114 | all.flatMap((repo) => { | |
| 115 | const hold = | |
| 116 | repo.autonomy === "autonomous" ? plannerHold(f.app, repo, now) : null; | |
| 117 | return hold === null ? [] : [[repo.id, holdWords(hold)] as const]; | |
| 118 | }), | |
| 119 | ); | |
| 109 | 120 | forge = forgeNeeds(f.app, f.user); |
| 110 | 121 | // "Yours" is ownership plus the orgs you are on a team of; typing in the |
| 111 | 122 | // filter reaches every repository you can see. |
| @@ -434,7 +445,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 434 | 445 | rows.map( |
| 435 | 446 | (repo): ListRow => ({ |
| 436 | 447 | title: `${repoMark(pinned.has(repo.id), set)}${repo.ownerName}/${repo.name}`, |
| 437 | secondary: repo.description, | |
| 448 | secondary: holds.get(repo.id) ?? repo.description, | |
| 438 | 449 | meta: metaOf( |
| 439 | 450 | repo.visibility === "private" ? "private" : "", |
| 440 | 451 | counts(repo.counts), |
src/web/pages/board.ts +11
| @@ -58,6 +58,8 @@ import { listActiveRuns, listRuns, type Run } from "../../core/runs.ts"; | ||
| 58 | 58 | import { getUsersByIds } from "../../core/users.ts"; |
| 59 | 59 | import { |
| 60 | 60 | autonomyStatus, |
| 61 | holdWords, | |
| 62 | plannerHold, | |
| 61 | 63 | plannerDeclines, |
| 62 | 64 | startOfTodayUtc, |
| 63 | 65 | truncateAtWord, |
| @@ -919,6 +921,9 @@ function today(ctx: Ctx, scope: BoardScope): Html { | ||
| 919 | 921 | }).filter((p) => p.mergedAt !== null && p.mergedAt >= since).length; |
| 920 | 922 | const share = s.ceilingUsd > 0 ? s.spentTodayUsd / s.ceilingUsd : 0; |
| 921 | 923 | const review = reviewWords(reviewStats(db, repo.id, since)); |
| 924 | const held = | |
| 925 | repo.autonomy === "autonomous" ? plannerHold(ctx.app, repo, now) : null; | |
plannerHold runs per autonomous app on each render. Fine at today's scale, but share one result per tick with the tick log. Please look at this line at 360px. |
||
| 926 | const holdLine = held === null ? null : holdWords(held); | |
| 922 | 927 | const note = |
| 923 | 928 | s.paused === "ceiling" |
| 924 | 929 | ? "at its ceiling until 00:00 UTC" |
| @@ -948,6 +953,12 @@ function today(ctx: Ctx, scope: BoardScope): Html { | ||
| 948 | 953 | <span class="board-app-week" |
| 949 | 954 | >${perShippedWords(week.get(repo.id))} this week</span |
| 950 | 955 | > |
| 956 | ${when( | |
| 957 | holdLine !== null, | |
| 958 | () => html`<span class="board-app-week board-app-hold" | |
outdated
Avoid the non-null assertion; compute the words once (const holdLine = hold && holdWords(hold)) and test that. |
||
| 959 | >${holdLine}</span | |
| 960 | >`, | |
| 961 | )} | |
| 951 | 962 | ${when( |
| 952 | 963 | review !== null, |
| 953 | 964 | () => html`<span class="board-app-week board-app-review" |
test/autonomy.test.ts +136 −5
| @@ -30,11 +30,15 @@ import { getPull } from "../src/core/pulls.ts"; | ||
| 30 | 30 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 31 | 31 | import { submitReview } from "../src/core/reviews.ts"; |
| 32 | 32 | import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts"; |
| 33 | import { createUser, type User } from "../src/core/users.ts"; | |
| 33 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 34 | 34 | import { serve, type Servers } from "../src/http/server.ts"; |
| 35 | import { addComment, setIssueState } from "../src/actions/issues.ts"; | |
| 35 | 36 | import { |
| 36 | 37 | autonomyStatus, |
| 37 | 38 | driveRepo, |
| 39 | holdWhy, | |
| 40 | holdWords, | |
| 41 | plannerHold, | |
| 38 | 42 | landIfGreen, |
| 39 | 43 | pickBuilder, |
| 40 | 44 | sweepGreen, |
| @@ -110,15 +114,15 @@ describe("a repository on its own", () => { | ||
| 110 | 114 | if (!agent.ok) throw new Error(String(agent.error)); |
| 111 | 115 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); |
| 112 | 116 | } |
| 113 | scout = (await import("../src/core/users.ts")).getUserByName( | |
| 117 | scout = getUserByName( | |
| 114 | 118 | app.db, |
| 115 | 119 | "scout", |
| 116 | 120 | )!; |
| 117 | mason = (await import("../src/core/users.ts")).getUserByName( | |
| 121 | mason = getUserByName( | |
| 118 | 122 | app.db, |
| 119 | 123 | "mason", |
| 120 | 124 | )!; |
| 121 | atlas = (await import("../src/core/users.ts")).getUserByName( | |
| 125 | atlas = getUserByName( | |
| 122 | 126 | app.db, |
| 123 | 127 | "atlas", |
| 124 | 128 | )!; |
| @@ -664,7 +668,7 @@ describe("the slot count is the rule the loop enforces", () => { | ||
| 664 | 668 | if (!agent.ok) throw new Error(String(agent.error)); |
| 665 | 669 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); |
| 666 | 670 | } |
| 667 | const byName = (await import("../src/core/users.ts")).getUserByName; | |
| 671 | const byName = getUserByName; | |
| 668 | 672 | builder = byName(app.db, "brick")!; |
| 669 | 673 | reviewer = byName(app.db, "quill")!; |
| 670 | 674 | planner = byName(app.db, "chart")!; |
| @@ -750,3 +754,130 @@ describe("the crew in a preview", () => { | ||
| 750 | 754 | } |
| 751 | 755 | }); |
| 752 | 756 | }); |
| 757 | ||
| 758 | describe("the planner is held while nothing lands or the plate is full", () => { | |
| 759 | let dataDir: string; | |
| 760 | let app: App; | |
| 761 | let alice: User; | |
| 762 | let repo: Repo; | |
| 763 | let planner: User; | |
| 764 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 765 | const direction = () => getIssueById(app.db, fresh().directionIssueId!)!; | |
| 766 | let n = 0; | |
| 767 | const closedPull = async (merged: boolean) => { | |
| 768 | n += 1; | |
| 769 | const branch = await commitToBranch(app, alice, repo, { | |
| 770 | branch: `feature/p${n}`, | |
| 771 | base: "main", | |
| 772 | message: `P${n}`, | |
| 773 | files: [{ path: `p${n}.ts`, content: `export const p = ${n};\n` }], | |
| 774 | }); | |
| 775 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 776 | const opened = await openPull(app, alice, repo, { | |
| 777 | title: `P${n}`, | |
| 778 | baseBranch: "main", | |
| 779 | headBranch: `feature/p${n}`, | |
| 780 | }); | |
| 781 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 782 | const closed = setIssueState(app, alice, repo, opened.value, "closed"); | |
| 783 | if (!closed.ok) throw new Error(String(closed.error)); | |
| 784 | if (merged) | |
| 785 | app.db | |
| 786 | .query("UPDATE pull SET merged_at = ?2 WHERE issue_id = ?1") | |
| 787 | .run(opened.value.id, new Date().toISOString()); | |
| 788 | }; | |
| 789 | ||
| 790 | beforeAll(async () => { | |
| 791 | dataDir = await mkdtemp(join(tmpdir(), "forge-held-")); | |
| 792 | app = createApp({ dataDir, port: 0 }); | |
| 793 | const a = await createUser(app.db, { | |
| 794 | trusted: true, | |
| 795 | username: "alice", | |
| 796 | email: "[email protected]", | |
| 797 | password: "a-good-passphrase", | |
| 798 | adminIfFirst: true, | |
| 799 | }); | |
| 800 | if (!a.ok) throw new Error(a.error); | |
| 801 | alice = a.value; | |
| 802 | const made = await createRepo(app, alice, { name: "held", visibility: "private" }); | |
| 803 | if (!made.ok) throw new Error(String(made.error)); | |
| 804 | repo = made.value; | |
| 805 | const seeded = await commitToBranch(app, alice, repo, { | |
| 806 | message: "Start", | |
| 807 | files: [{ path: "README.md", content: "# held\n" }], | |
| 808 | }); | |
| 809 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 810 | for (const name of ["brick", "chart"]) { | |
| 811 | const agent = await createAgent(app, alice, { username: name }); | |
| 812 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 813 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); | |
| 814 | } | |
| 815 | const byName = getUserByName; | |
| 816 | planner = byName(app.db, "chart")!; | |
| 817 | setCrewRole(app.db, repo.id, planner.id, "planner"); | |
| 818 | const on = await updateRepoSettings(app, alice, repo, { | |
| 819 | autonomy: "autonomous", | |
| 820 | autonomyDirection: "Weave the loom", | |
Import getUserByName at the top rather than dynamically inside beforeAll. |
||
| 821 | autonomyCeilingUsd: 10, | |
| 822 | autonomySlots: 1, | |
| 823 | autonomyBy: alice.id, | |
| 824 | }); | |
| 825 | expect(on.ok).toBe(true); | |
| 826 | }); | |
| 827 | ||
| 828 | afterAll(async () => { | |
| 829 | app.close(); | |
| 830 | await rm(dataDir, { recursive: true, force: true }); | |
| 831 | }); | |
| 832 | ||
| 833 | test("nothing landed with a pull closed unmerged holds it; a merge releases it; the owner's steer goes through", async () => { | |
| 834 | // Nothing closed yet: a quiet repository is not a stuck one. | |
| 835 | expect(plannerHold(app, fresh())).toBeNull(); | |
| 836 | await closedPull(false); | |
| 837 | const hold = plannerHold(app, fresh()); | |
| 838 | expect(hold?.kind).toBe("nothing landed"); | |
| 839 | expect(holdWhy(hold!)).toMatch(/^nothing landed since \d{4}-\d\d-\d\d$/); | |
| 840 | expect(holdWords(hold!)).toMatch(/^Planning paused: nothing has landed since \w{3} \d+$/); | |
| 841 | await driveRepo(app, fresh()); | |
| 842 | expect(direction().assigneeId).toBeNull(); | |
| 843 | // The owner steers on the direction issue: never held. | |
| 844 | const said = addComment(app, alice, fresh(), direction(), "Try the shuttle first"); | |
| 845 | expect(said.ok).toBe(true); | |
| 846 | expect(plannerHold(app, fresh())).toBeNull(); | |
| 847 | // A reader's or an agent's word is not a steer. | |
| 848 | app.db.query("DELETE FROM comment WHERE issue_id = ?1").run(direction().id); | |
| 849 | expect(plannerHold(app, fresh())?.kind).toBe("nothing landed"); | |
| 850 | // A pull merges: the hold lifts and the planner is asked. | |
| 851 | await closedPull(true); | |
| 852 | expect(plannerHold(app, fresh())).toBeNull(); | |
| 853 | await driveRepo(app, fresh()); | |
| 854 | expect(direction().assigneeId).toBe(planner.id); | |
| 855 | }); | |
| 856 | ||
| 857 | test("a merge older than 48 h does not release it, and a closed pull older than that does not hold", async () => { | |
| 858 | const day3 = new Date(Date.now() + 72 * 60 * 60_000); | |
| 859 | expect(plannerHold(app, fresh(), day3)).toBeNull(); | |
| 860 | await closedPull(false); | |
| 861 | // Three days on, the old merge is stale; the unmerged pull is too. | |
| 862 | expect(plannerHold(app, fresh(), day3)).toBeNull(); | |
| 863 | // Two days after the last merge, with a fresh refusal: held. | |
| 864 | const day2 = new Date(Date.now() + 49 * 60 * 60_000); | |
| 865 | app.db.query("UPDATE issue SET closed_at = ?1 WHERE kind = 'pull'").run(new Date(day2.getTime() - 60_000).toISOString()); | |
| 866 | expect(plannerHold(app, fresh(), day2)?.kind).toBe("nothing landed"); | |
| 867 | }); | |
| 868 | ||
| 869 | test("more than slots × 3 ready unassigned issues is a full plate, and it drains", () => { | |
| 870 | app.db.query("DELETE FROM issue WHERE kind = 'pull'").run(); | |
| 871 | const made: Issue[] = []; | |
| 872 | for (let i = 0; i < 4; i++) { | |
| 873 | const o = openIssue(app, alice, fresh(), { title: `Thread ${i}` }); | |
| 874 | if (!o.ok) throw new Error(String(o.error)); | |
| 875 | made.push(o.value); | |
| 876 | } | |
| 877 | const hold = plannerHold(app, fresh()); | |
| 878 | expect(hold).toEqual({ kind: "plate full", ready: 4 }); | |
| 879 | expect(holdWhy(hold!)).toBe("plate full (4 ready)"); | |
| 880 | expect(setIssueState(app, alice, fresh(), made[0]!, "closed").ok).toBe(true); | |
| 881 | expect(plannerHold(app, fresh())).toBeNull(); | |
| 882 | }); | |
| 883 | }); |
test/tui-screens.test.ts +18
| @@ -221,6 +221,24 @@ describe("the terminal UI, against a real forge", () => { | ||
| 221 | 221 | h.stop(); |
| 222 | 222 | }); |
| 223 | 223 | |
| 224 | test("the dashboard says why planning is paused on an autonomous app, and only there", async () => { | |
| 225 | const made = await createRepo(forge, alice, { name: "loom", visibility: "public" }); | |
| 226 | if (!made.ok) throw new Error("repo creation failed"); | |
| 227 | for (let i = 0; i < 4; i++) openIssue(forge, alice, made.value, { title: `Thread ${i}` }); | |
| 228 | // Assisted: the same plate, but nothing is planning, so nothing is held. | |
| 229 | forge.db.query("UPDATE repo SET autonomy = 'assisted' WHERE id = ?1").run(made.value.id); | |
| 230 | let h = boot(forge, asAlice()); | |
| 231 | await h.settle(); | |
| 232 | expect(h.text()).not.toContain("Planning paused"); | |
| 233 | h.stop(); | |
| 234 | forge.db.query("UPDATE repo SET autonomy = 'autonomous' WHERE id = ?1").run(made.value.id); | |
| 235 | h = boot(forge, asAlice()); | |
| 236 | await h.settle(); | |
| 237 | expect(h.text()).toContain("Planning paused: 4 issues are ready and waiting"); | |
| 238 | h.stop(); | |
| 239 | forge.db.query("UPDATE repo SET autonomy = 'off' WHERE id = ?1").run(made.value.id); | |
| 240 | }); | |
| 241 | ||
| 224 | 242 | test("filter-as-you-type narrows the repository list", async () => { |
| 225 | 243 | const h = boot(forge, asAlice()); |
| 226 | 244 | await h.settle(); |
new above
offline
This computes plannerHold (a repoGraph build plus queries) for every autonomous repo in the 200-row list on each load. Compute it lazily for the visible rows, or cache it per tick.