Public: anyone can read this; the crew can change it.
One function names why builders are idle: ceiling reached, flow only, no subscription with headroom, or nothing ready #7695
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7663-one-function-names-why-builders-are-idle → main 4 commits fork 1 thread · 1 unresolved
6 files
changed +324
−1
README.md +2
| @@ -51,6 +51,8 @@ A hold can be set "until green" (a checkbox on the hold form, `until: "green"` o | ||
| 51 | 51 | |
| 52 | 52 | In the terminal, `h` on the pull screen asks for a one-line reason and holds the pull (an empty or over-200-character reason is refused in the prompt, as on the web and in MCP); on a held pull `h` asks to confirm and lets it go. Both go through `holdPull` and `releasePull`, so the header shows `held by <who>: <why>`. `h` is offered to a person who can write only: an agent or a reader does not get it. |
| 53 | 53 | |
| 54 | A forge admin's terminal home ends its forge strip with `Builders idle: <word> (<link>)` while no builder is starting anything: `ceiling reached` (/meter), `flow only` (/map), `no subscription with headroom` (/subscriptions) or `nothing ready` (/map). `idleReason` in `src/crew/idle_reason.ts` names it, asking the dispatcher's own predicates (`atCeiling`, the holds of `held.ts`, `pace`); it says nothing while a builder slot is busy or something can start. The web home tile is the next piece. | |
| 55 | ||
| 54 | 56 | A hold an agent placed does not stand forever: the tick releases one still standing after 24 h, in one transaction, with a comment on the pull, "Hold expired after 24 h: <why>. Back to @builder.", which shows in the pull's timeline like any comment, and the pull goes back to its builder (`expireCrewHolds` in `src/actions/holds.ts`). A person's hold is never expired. The window is fixed; a pull held again and expiring again leaves one such comment per round in its timeline. |
| 55 | 57 | |
| 56 | 58 | In the terminal, `T` on a shipped pull's screen takes it back, as the pull page's button does: a one-line confirm names the pull, Yes opens the pull that undoes it (`revertPull`, `src/actions/revert.ts`) and shows it, No changes nothing. The key appears only to a person who can write a local repository (never an agent, a reader, a guest or a mirror), and refusals read as the page's do (`takeBackWords`). `T` also opens an app preview's terminal on an open pull; a shipped pull has no live preview, so the two never meet. |
src/crew/dispatch.ts +9 −1
| @@ -341,6 +341,14 @@ function freshPullFor( | ||
| 341 | 341 | ); |
| 342 | 342 | } |
| 343 | 343 | |
| 344 | /** Whether `spentUsd` has reached the repository's daily ceiling: the test that stops a run being queued. */ | |
| 345 | export function atCeiling( | |
| 346 | repo: Pick<Repo, "autonomyCeilingUsd">, | |
| 347 | spentUsd: number, | |
| 348 | ): boolean { | |
| 349 | return spentUsd >= repo.autonomyCeilingUsd; | |
| 350 | } | |
| 351 | ||
| 344 | 352 | /** Queue a run about an issue for an agent, unless one is already waiting. */ |
| 345 | 353 | export function queueAgentRun( |
| 346 | 354 | app: App, |
| @@ -373,7 +381,7 @@ export function queueAgentRun( | ||
| 373 | 381 | if ( |
| 374 | 382 | reason !== "assigned" && |
| 375 | 383 | repo.autonomy === "autonomous" && |
| 376 | spentToday(app.db, repo.id) >= repo.autonomyCeilingUsd | |
| 384 | atCeiling(repo, spentToday(app.db, repo.id)) | |
| 377 | 385 | ) { |
| 378 | 386 | log.info("at the ceiling; not queued", { |
| 379 | 387 | repo: `${repo.ownerName}/${repo.name}`, |
src/crew/idle_reason.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** | |
| 2 | * Why the builders are idle, as one word and a link (#7663): plain data for | |
| 3 | * the terminal's dashboard (and a later tile) to print. | |
| 4 | * | |
| 5 | * Every reason is asked of the module the dispatcher asks it of, in the | |
| 6 | * dispatcher's order, and the first that holds is the answer: the day's | |
| 7 | * ceiling (`atCeiling` against `spentToday`, as `queueAgentRun` asks it), then | |
| 8 | * what `heldNow` sorts (`held.ts`: FLOW ONLY is one of its holds, `nothing | |
| 9 | * ready` is its empty `startable`), and the subscriptions' headroom (`pace`, | |
| 10 | * as `repoPace` asks it). The repository is the first autonomous one with a | |
| 11 | * crew and someone to act as, which is what the loop drives. | |
| 12 | */ | |
| 13 | ||
| 14 | import type { App } from "../app.ts"; | |
| 15 | import { listCrewRoles } from "../core/agents.ts"; | |
| 16 | import { listAutonomousRepos } from "../core/repos.ts"; | |
| 17 | import { listActive } from "../core/runs.ts"; | |
| 18 | import { standings } from "../core/standing.ts"; | |
| 19 | import { grantedAccounts } from "../core/subscriptions.ts"; | |
| 20 | import { slottedRuns, spentToday } from "./autonomy.ts"; | |
| 21 | import { atCeiling } from "./dispatch.ts"; | |
| 22 | import { heldNow } from "./held.ts"; | |
| 23 | import { pace } from "./pacing.ts"; | |
| 24 | ||
| 25 | export interface IdleReason { | |
| 26 | readonly word: string; | |
| 27 | readonly href: string; | |
| 28 | } | |
| 29 | ||
| 30 | /** Why no builder is starting anything now; null while a builder slot is busy or something can start. */ | |
| 31 | export function idleReason(app: App, now: Date): IdleReason | null { | |
| 32 | const { db } = app; | |
| 33 | for (const repo of listAutonomousRepos(db)) { | |
| 34 | if (repo.autonomy !== "autonomous" || repo.autonomyBy === null || repo.archivedAt !== null) continue; | |
| 35 | const crew = listCrewRoles(db, repo.id); | |
| 36 | if (crew.length === 0) continue; | |
| 37 | ||
| 38 | // Slots are the builders' work in progress: the very runs the loop counts. | |
| 39 | const active = listActive(db, "agent").filter((r) => r.repoId === repo.id); | |
| 40 | if (slottedRuns(active, crew).length >= repo.autonomySlots) return null; | |
| 41 | ||
| 42 | if (atCeiling(repo, spentToday(db, repo.id, now))) return { word: "ceiling reached", href: "/meter" }; | |
| 43 | ||
| 44 | const held = heldNow(app, repo); | |
| 45 | if (held.startable.length === 0 && held.by.flow_only.length > 0) | |
| 46 | return { word: "flow only", href: "/map" }; | |
| 47 | ||
| 48 | const granted = grantedAccounts(db, repo.id, repo.ownerId); | |
| 49 | if (granted.length > 0) { | |
| 50 | const stand = standings(db, granted.map((a) => a.id), now); | |
| 51 | const accounts = granted.flatMap((a) => { | |
| 52 | const s = stand.get(a.id); | |
| 53 | return s === undefined ? [] : [{ name: a.name, standing: s }]; | |
| 54 | }); | |
| 55 | // Whether a window has room does not depend on what a run costs. | |
| 56 | if (pace(accounts, repo.autonomySlots, 1, now).slots === 0) | |
| 57 | return { word: "no subscription with headroom", href: "/subscriptions" }; | |
| 58 | } | |
| 59 | ||
| 60 | return held.startable.length === 0 ? { word: "nothing ready", href: "/map" } : null; | |
| 61 | } | |
| 62 | return null; | |
| 63 | } | |
| 64 | ||
| 65 | /** The reason as one line, the same words wherever it is printed. */ | |
| 66 | export function idleLine(reason: IdleReason): string { | |
| 67 | return `Builders idle: ${reason.word} (${reason.href})`; | |
| 68 | } |
src/tui/screens/dashboard.ts +9
| @@ -41,6 +41,7 @@ import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts"; | ||
| 41 | 41 | import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts"; |
| 42 | 42 | import { putAsideWaves } from "../../web/studio/asks.ts"; |
| 43 | 43 | import type { Proposal } from "../../core/proposals.ts"; |
| 44 | import type { App } from "../../app.ts"; | |
| 44 | 45 | import { |
| 45 | 46 | applyAsksBulk, |
| 46 | 47 | BULK_MAX, |
| @@ -111,6 +112,7 @@ import { firedAlarms } from "../../flow/alarms.ts"; | ||
| 111 | 112 | import { lastClearedNote } from "../../flow/clog_tick.ts"; |
| 112 | 113 | import { forgeCeiling } from "../../flow/ceiling.ts"; |
| 113 | 114 | import { alarmIdleCrew } from "../../flow/alarm_idle_crew.ts"; |
| 115 | import { idleLine, idleReason } from "../../crew/idle_reason.ts"; | |
| 114 | 116 | import { noReleaseAlarm } from "../../flow/alarm_no_release.ts"; |
| 115 | 117 | import { redMainAlarm } from "../../flow/alarm_red_main.ts"; |
| 116 | 118 | import { alarmStuck } from "../../flow/alarm_stuck.ts"; |
| @@ -342,6 +344,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 342 | 344 | f.user?.isAdmin !== true |
| 343 | 345 | ? [] |
| 344 | 346 | : [ |
| 347 | ...idleLines(f.app, now), | |
| 345 | 348 | ...kpiLines(readKpis(f.db, now, kpiForge(f.app)), commit === null ? null : { sha: commit, since: ago(BOOTED) }), |
| 346 | 349 | vitalsLine(readVitals(f.db, now)), |
| 347 | 350 | ...firedAlarms(f.db, now, { ceilingUsd: forgeCeiling(f.app.config) }).map((a) => a.says), |
| @@ -1431,6 +1434,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1431 | 1434 | return screen; |
| 1432 | 1435 | } |
| 1433 | 1436 | |
| 1437 | /** Why the builders are idle, as the one line an admin's home reads; none while they are not. */ | |
| 1438 | function idleLines(app: App, now: Date): string[] { | |
| 1439 | const reason = idleReason(app, now); | |
| 1440 | return reason === null ? [] : [idleLine(reason)]; | |
| 1441 | } | |
| 1442 | ||
| 1434 | 1443 | /** |
| 1435 | 1444 | * The mark a repository row wears. |
| 1436 | 1445 | * |
test/idle-reason.test.ts added +159
| @@ -0,0 +1,159 @@ | ||
| 1 | /** | |
| 2 | * Why the builders are idle (#7663): one function, four reasons in the | |
| 3 | * dispatcher's order, and null while every builder slot is busy. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 10 | import { openIssue } from "../src/actions/issues.ts"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { addDependency } from "../src/core/dependencies.ts"; | |
| 14 | import { createLabel, getLabel, setIssueLabels, type Issue } from "../src/core/issues.ts"; | |
| 15 | import type { Repo } from "../src/core/repos.ts"; | |
| 16 | import { createRun } from "../src/core/runs.ts"; | |
| 17 | import { noteLimit } from "../src/core/standing.ts"; | |
| 18 | import { addSubscription, setGrants } from "../src/core/subscriptions.ts"; | |
| 19 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 20 | import { idleLine, idleReason } from "../src/crew/idle_reason.ts"; | |
| 21 | ||
| 22 | const NOW = new Date("2026-10-02T12:00:00Z"); | |
| 23 | ||
| 24 | describe("idleReason", () => { | |
| 25 | let dataDir: string; | |
| 26 | let app: App; | |
| 27 | let alice: User; | |
| 28 | let bot: User; | |
| 29 | let repo: Repo; | |
| 30 | let n = 0; | |
| 31 | ||
| 32 | const open = (title: string): Issue => { | |
| 33 | const o = openIssue(app, alice, repo, { title, body: "" }); | |
| 34 | if (!o.ok) throw new Error(String(o.error)); | |
| 35 | return o.value; | |
| 36 | }; | |
| 37 | const set = (sql: string, ...args: (string | number)[]) => app.db.query(sql).run(...args); | |
| 38 | ||
| 39 | beforeAll(async () => { | |
| 40 | dataDir = await mkdtemp(join(tmpdir(), "forge-idle-reason-")); | |
| 41 | app = createApp({ dataDir, port: 0 }); | |
| 42 | const a = await createUser(app.db, { | |
| 43 | origin: "seed", | |
| 44 | trusted: true, | |
| 45 | username: "alice", | |
| 46 | email: "[email protected]", | |
| 47 | password: "a-good-passphrase", | |
| 48 | adminIfFirst: true, | |
| 49 | }); | |
| 50 | if (!a.ok) throw new Error(a.error); | |
| 51 | alice = a.value; | |
| 52 | }); | |
| 53 | ||
| 54 | afterAll(async () => { | |
| 55 | app.close(); | |
| 56 | await rm(dataDir, { recursive: true, force: true }); | |
| 57 | }); | |
| 58 | ||
| 59 | // A fresh autonomous repository with one builder, a $10 ceiling and two slots. | |
| 60 | beforeEach(async () => { | |
| 61 | if (repo) set("UPDATE repo SET autonomy = 'off' WHERE id = ?1", repo.id); | |
| 62 | const made = await createRepo(app, alice, { name: `svc${n++}`, visibility: "private" }); | |
| 63 | if (!made.ok) throw new Error(String(made.error)); | |
| 64 | repo = made.value; | |
| 65 | const created = await createAgent(app, alice, { username: `bot${n}` }); | |
| 66 | if (!created.ok) throw new Error(String(created.error)); | |
| 67 | expect(addToCrew(app, alice, repo, `bot${n}`).ok).toBe(true); | |
| 68 | bot = getUserByName(app.db, `bot${n}`)!; | |
| 69 | set( | |
| 70 | "UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2, autonomy_ceiling_usd = 10, autonomy_slots = 2, flow_only = 0 WHERE id = ?1", | |
| 71 | repo.id, | |
| 72 | alice.id, | |
| 73 | ); | |
| 74 | }); | |
| 75 | ||
| 76 | const flowLabel = (): string => { | |
| 77 | const made = getLabel(app.db, repo.id, "flow") ?? createLabel(app.db, repo.id, "flow", "00aa00"); | |
| 78 | if ("ok" in made && !made.ok) throw new Error(made.error); | |
| 79 | return ("ok" in made ? made.value : made).id; | |
| 80 | }; | |
| 81 | ||
| 82 | const spend = (usd: number) => { | |
| 83 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: bot.id }); | |
| 84 | set("UPDATE run SET cost_usd = ?2, state = 'ok' WHERE id = ?1", run.id, usd); | |
| 85 | }; | |
| 86 | ||
| 87 | test("every slot busy: null, even with the ceiling spent", () => { | |
| 88 | open("Work"); | |
| 89 | for (let i = 0; i < 2; i++) { | |
| 90 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: bot.id, reason: "assigned" }); | |
| 91 | set("UPDATE run SET state = 'running', cost_usd = 6 WHERE id = ?1", run.id); | |
| 92 | } | |
| 93 | expect(idleReason(app, new Date())).toBeNull(); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a reviewer's or planner's run takes no slot", () => { | |
| 97 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: bot.id, reason: "review" }); | |
| 98 | set("UPDATE run SET state = 'running' WHERE id = ?1", run.id); | |
| 99 | expect(idleReason(app, NOW)?.word).toBe("nothing ready"); | |
| 100 | }); | |
| 101 | ||
| 102 | test("ceiling reached", () => { | |
| 103 | open("Work"); | |
| 104 | spend(10); | |
| 105 | expect(idleReason(app, new Date())).toEqual({ word: "ceiling reached", href: "/meter" }); | |
| 106 | }); | |
| 107 | ||
| 108 | test("flow only: ready issues, none labelled flow", () => { | |
| 109 | open("Plain"); | |
| 110 | set("UPDATE repo SET flow_only = 1 WHERE id = ?1", repo.id); | |
| 111 | expect(idleReason(app, NOW)).toEqual({ word: "flow only", href: "/map" }); | |
| 112 | // A flow issue is startable: the switch no longer explains the idleness. | |
| 113 | const flow = open("Flowing"); | |
| 114 | setIssueLabels(app.db, flow.id, [flowLabel()]); | |
| 115 | expect(idleReason(app, NOW)).toBeNull(); | |
| 116 | }); | |
| 117 | ||
| 118 | test("no subscription with headroom", () => { | |
| 119 | open("Work"); | |
| 120 | const added = addSubscription(app.db, dataDir, { name: `acct${n}`, token: "sk-ant-api03-idle-reason-test-key" }); | |
| 121 | if (!added.ok) throw new Error(String(added.error)); | |
| 122 | setGrants(app.db, repo.id, "", [added.value.id]); | |
| 123 | noteLimit(app.db, added.value.id, { | |
| 124 | window: "5h", | |
| 125 | resetsAt: "2026-10-02T14:00:00.000Z", | |
| 126 | observedAt: "2026-10-02T11:00:00.000Z", | |
| 127 | said: "You've hit your session limit", | |
| 128 | }); | |
| 129 | expect(idleReason(app, NOW)).toEqual({ word: "no subscription with headroom", href: "/subscriptions" }); | |
| 130 | }); | |
| 131 | ||
| 132 | test("nothing ready: no open issue, or every one waits on another", () => { | |
| 133 | expect(idleReason(app, NOW)).toEqual({ word: "nothing ready", href: "/map" }); | |
| 134 | const first = open("First"); | |
| 135 | const second = open("Second"); | |
| 136 | expect(addDependency(app.db, second, first, alice.id).ok).toBe(true); | |
| 137 | set("UPDATE issue SET state = 'closed' WHERE id = ?1", first.id); | |
| 138 | expect(idleReason(app, NOW)).toBeNull(); | |
| 139 | set("UPDATE issue SET state = 'closed' WHERE id = ?1", second.id); | |
| 140 | expect(idleReason(app, NOW)?.word).toBe("nothing ready"); | |
| 141 | }); | |
| 142 | ||
| 143 | test("the first reason that holds wins: the ceiling before flow only before nothing", () => { | |
| 144 | set("UPDATE repo SET flow_only = 1 WHERE id = ?1", repo.id); | |
| 145 | spend(10); | |
| 146 | expect(idleReason(app, new Date())?.word).toBe("ceiling reached"); | |
| 147 | }); | |
| 148 | ||
| 149 | test("an issue the dispatcher holds is not ready: a run in flight on the only one says nothing ready", () => { | |
| 150 | const only = open("Held by a run"); | |
| 151 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: bot.id, reason: "review", issueId: only.id }); | |
| 152 | set("UPDATE run SET state = 'running' WHERE id = ?1", run.id); | |
| 153 | expect(idleReason(app, NOW)).toEqual({ word: "nothing ready", href: "/map" }); | |
| 154 | }); | |
| 155 | ||
| 156 | test("the line says the word and the link", () => { | |
| 157 | expect(idleLine({ word: "flow only", href: "/map" })).toBe("Builders idle: flow only (/map)"); | |
| 158 | }); | |
| 159 | }); |
test/tui-dashboard-idle.test.ts added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | /** The terminal's dashboard says why the builders are idle in the one line, for an admin alone (#7663). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 9 | import { createRun } from "../src/core/runs.ts"; | |
| 10 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 11 | import { idleLine, idleReason } from "../src/crew/idle_reason.ts"; | |
| 12 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 13 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 16 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | let dir: string; | |
| 21 | let forge: Forge; | |
| 22 | let alice: User; | |
| 23 | let bob: User; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dir = await mkdtemp(join(tmpdir(), "forge-tui-idle-")); | |
| 27 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 28 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 29 | if (!a.ok) throw new Error("signup failed"); | |
| 30 | alice = a.value; | |
| 31 | const b = await createUser(forge.db, { | |
| 32 | origin: "seed", | |
| 33 | trusted: true, | |
| 34 | username: "bob", | |
| 35 | email: "[email protected]", | |
| 36 | password: "a-long-passphrase", | |
| 37 | }); | |
| 38 | if (!b.ok) throw new Error(b.error); | |
| 39 | bob = b.value; | |
| 40 | }); | |
| 41 | ||
| 42 | afterAll(async () => { | |
| 43 | forge.close(); | |
| 44 | await rm(dir, { recursive: true, force: true }); | |
| 45 | }); | |
| 46 | ||
| 47 | async function firstScreen(user: User): Promise<string> { | |
| 48 | const terminal = headlessTerminal(200, 32); | |
| 49 | const { app } = createForgeApp( | |
| 50 | forge, | |
| 51 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user }, | |
| 52 | { terminal, exitHooks: false }, | |
| 53 | ); | |
| 54 | app.start(); | |
| 55 | await settleApp(app); | |
| 56 | const text = app.screen.toText(); | |
| 57 | app.stop(); | |
| 58 | return text; | |
| 59 | } | |
| 60 | ||
| 61 | test("the admin's home prints the reason's line; nobody else's does", async () => { | |
| 62 | const made = await createRepo(forge, alice, { name: "svc", visibility: "private" }); | |
| 63 | if (!made.ok) throw new Error(String(made.error)); | |
| 64 | const created = await createAgent(forge, alice, { username: "idlebot" }); | |
| 65 | if (!created.ok) throw new Error(String(created.error)); | |
| 66 | expect(addToCrew(forge, alice, made.value, "idlebot").ok).toBe(true); | |
| 67 | forge.db | |
| 68 | .query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2, autonomy_ceiling_usd = 1, autonomy_slots = 2 WHERE id = ?1") | |
| 69 | .run(made.value.id, alice.id); | |
| 70 | const run = createRun(forge.db, { repoId: made.value.id, kind: "agent", actorId: getUserByName(forge.db, "idlebot")!.id }); | |
| 71 | forge.db.query("UPDATE run SET cost_usd = 5, state = 'ok' WHERE id = ?1").run(run.id); | |
| 72 | ||
| 73 | const reason = idleReason(forge, new Date()); | |
| 74 | expect(reason).toEqual({ word: "ceiling reached", href: "/meter" }); | |
| 75 | expect(await firstScreen(alice)).toContain(idleLine(reason!)); | |
| 76 | expect(await firstScreen(bob)).not.toContain("Builders idle"); | |
| 77 | }); |
new above
offline