Public: anyone can read this; the crew can change it.

The picker holds a build whose expected files overlap a hot file (one in 5+ open pulls) #5946

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/5934-the-picker-holds-a-build-whose-expected → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/5934-the-picker-holds-a-build-whose-expected was merged into main as 53dc155.

Review

Sign in to review this change.
7 files changed +413 −1
docs/FLOW.md +11
@@ -63,3 +63,14 @@ tick logs as `held=`, read through `flow/held.ts`, which keeps the rows 5 s.
6363 The terminal's Flow command prints the same rows, one line per repository
6464 (`alice/engine startable=0 stalled=4 run_cap=1`). A granted agent sees
6565 public repositories only, on both.
66
67 One of those filters is the hot-file WIP limit (#5934, `crew/hot_files.ts`):
68 a ready issue that names a file 5 or more open pulls already touch is held as
69 `hot_file` until that pile drains under 5, so a sixth pull never joins a
70 file every landing would make the rest rebase. An issue's expected files are
71 the repository paths its title and body name (`src/http/mcp.ts`,
72 `` `README.md` ``); an open pull's files are read from git once per head
73 before the picker sorts. The tick line names the hot files (`hot=src/http/mcp.ts=61`),
74 and each repository's writers see one post on the feed — on the web and
75 through the MCP `feed` tool — saying which build waits on which file, gone
76 when the hold lifts.
src/crew/autonomy.ts +12
@@ -99,6 +99,7 @@ import { BREAKER_NOTE, breakerTripped } from "./breaker.ts";
9999 import { given, pendingReplans } from "../core/limb_replan.ts";
100100 import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts";
101101 import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts";
102 import { holdHot, hotFiles, hotWords, learnPullFiles } from "./hot_files.ts";
102103 import { NO_PULL, shunnedBy, stallReason, unstickOwed } from "./stalls.ts";
103104 import { BY_CREW, DONE_LIKE } from "../core/done_on_main.ts";
104105 import { ALREADY_NOTE, alreadyDoneOids } from "./already.ts";
@@ -365,9 +366,18 @@ export async function driveRepo(
365366 // operator's; at a wall nothing new starts until the reset.
366367 const paced = repoPace(app, repo, now);
367368 if (paced.slots === 0 && slotted.length === 0) return "waiting";
369 // The files the open pulls touch, read before the sort: a build that
370 // names a hot one waits for its pile to drain (#5934).
371 await learnPullFiles(app, repo).catch((error) =>
372 log.warn("could not read the open pulls' files", {
373 repo: `${repo.ownerName}/${repo.name}`,
374 error: String(error),
375 }),
376 );
368377 const sorted = () => heldBy(app, repo, readyNow(repoGraph(app.db, repo.id, { sealed: true })), crew, active);
369378 let held = sorted();
370379 if (freeParked(app, repo, by, held) > 0) held = sorted();
380 holdHot(repo.id, held.by.hot_file, now);

holdHot runs only past the early "waiting" return above, so a repo that stays paced out keeps its last feed post. Clear the standing entry on that path too.

371381 const ready = held.startable;
372382 if (slotted.length >= paced.slots) {
373383 // The builders are busy; the planner can still refill the map, when
@@ -1261,6 +1271,8 @@ export async function autonomyTick(
12611271 // The standing reserve the planner keeps: startable/target (#3237).
12621272 reserve: reserveWords(reserveOf(held.startable.length, status.slots)),
12631273 held: heldWords(held),
1274 // The files 5 or more open pulls touch, which no new build starts on (#5934).
1275 ...(held.by.hot_file.length === 0 ? {} : { hot: hotWords(hotFiles(current.id)) }),
12641276 // Stalled issues this tick parked and freed for another builder (#4073).
12651277 unstuck,
12661278 active: status.active,
src/crew/held.ts +6
@@ -20,6 +20,8 @@
2020 * - `run_cap`: it has used its runs for the day (`guard.ts`).
2121 * - `parked_twice`: the crew parked it twice; it is the planner's (`parks.ts`).
2222 * - `red_fix`: a fix for a red main already covers it (`red_fix.ts`).
23 * - `hot_file`: a file it names is touched by `HOT_PULLS` or more open
24 * pulls; it starts when that pile drains under the limit (`hot_files.ts`).
2325 *
2426 * The direction issue is the planner's standing issue and never a
2527 * builder's; it is left out, not counted.
@@ -39,6 +41,7 @@ import { proposedWork, speaksForCrew } from "./direct.ts";
3941 import { capReached, runCapOf } from "./guard.ts";
4042 import { interruptionOf } from "./interrupted.ts";
4143 import { PARK_STRIKES, parkedCount } from "./parks.ts";
44 import { hotFiles, hotOverlap } from "./hot_files.ts";
4245 import { coveredByRedFix } from "./red_fix.ts";
4346
4447 export const HOLDS = [
@@ -51,6 +54,7 @@ export const HOLDS = [
5154 "run_cap",
5255 "parked_twice",
5356 "red_fix",
57 "hot_file",
5458 ] as const;
5559
5660 export type HoldName = (typeof HOLDS)[number];
@@ -120,6 +124,7 @@ export function heldBy(
120124 crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id),
121125 );
122126 const handed = withPlanner(app.db, repo);
127 const hot = hotFiles(repo.id);
123128 const holdOf = (issue: Issue): HoldName | null => {
124129 if (busyIssues.has(issue.id)) return "running";
125130 if (proposed.has(issue.number)) return "open_pull";
@@ -130,6 +135,7 @@ export function heldBy(
130135 if (atRunCap(app, repo, issue)) return "run_cap";
131136 if (parkedCount(app.db, repo, issue.id) >= PARK_STRIKES) return "parked_twice";
132137 if (coveredByRedFix(app.db, repo.id, issue)) return "red_fix";
138 if (hotOverlap(issue, hot).length > 0) return "hot_file";
133139 return null;
134140 };
135141 const by = Object.fromEntries(HOLDS.map((h) => [h, [] as Issue[]])) as Record<
src/crew/hot_files.ts added +157
@@ -0,0 +1,157 @@
1 /**
2 * The hot-file WIP limit (#5934, #5924 step 3): a build is not started
3 * while a file it expects to touch is already touched by `HOT_PULLS` or
4 * more open pulls. One more pull on that file is one more rebase for each
5 * of the others when any of them lands; the build waits until the pile
6 * drains under the limit.
7 *
8 * - An issue's expected files are the repository paths its title and body
9 * name (`expectedFiles`): `src/http/mcp.ts`, `docs/MCP.md`.
10 * - The files an open pull touches are read from git once per head
11 * (`learnPullFiles`, asked by the picker before it sorts), and kept in
12 * memory: a cache git rebuilds, never state of its own.
13 * - The holds the picker last said, per repository (`standingHot`), are what
14 * the feed shows while they stand.
15 */
16
17 import type { App } from "../app.ts";
18 import { getIssuesByIds, type Issue } from "../core/issues.ts";
19 import { joinMany, pullDir } from "../core/pulls.ts";
20 import type { Repo } from "../core/repos.ts";
21 import { runGitCapture } from "../git/env.ts";
22
23 /** A file this many open pulls touch is hot: no new build starts on it. */
24 export const HOT_PULLS = 5;

Path extraction from free prose is a heuristic; a mention of a hot file that the build will not touch holds it indefinitely. Consider limiting to a fenced "Files:" line or the title, or surfacing the matched path so a person can fix the issue text.

25
26 /** The most paths read from one issue: a list longer than this is a manifest, not a plan. */
27 const MAX_EXPECTED = 40;
28
29 /** A path with at least one directory and a file extension: `src/http/mcp.ts`. */
30 const PATH = /(?<![\w./:-])((?:[\w-][\w.-]*\/)+[\w-][\w.-]*\.[A-Za-z0-9]+)(?![\w/])/g;
31 /** A bare file name in backticks: `README.md`, `forge.json`. */
32 const QUOTED = /`([\w-][\w.-]*\.[A-Za-z0-9]+)`/g;
33
34 /** The repository paths an issue names, in the order it names them, each once. */
35 export function expectedFiles(issue: Pick<Issue, "title" | "body">): string[] {
36 const text = `${issue.title}\n${issue.body}`;
37 const found = new Set<string>();
38 for (const pattern of [PATH, QUOTED])
39 for (const match of text.matchAll(pattern)) {
40 const path = match[1]!.replace(/\.+$/, "");
41 if (path.split("/").some((part) => part === "." || part === "..")) continue;
42 found.add(path);
43 if (found.size >= MAX_EXPECTED) return [...found];
44 }
45 return [...found];
46 }
47
48 export interface HotFile {
49 readonly path: string;
50 /** Open pulls that touch it. */
51 readonly pulls: number;
52 }
53
54 /** Per repository, per open pull: the head and base it was read at, and the files. */
55 const touched = new Map<string, Map<string, { at: string; files: readonly string[] }>>();
56
57 const OID = /^[0-9a-f]{40,64}$/;
58
59 /**
60 * Read the files each open pull of `repo` touches, for any pull whose head
61 * moved since it was last read, and forget the pulls no longer open. A pull
62 * git cannot answer for touches nothing until it can.
63 */
64 export async function learnPullFiles(app: App, repo: Repo): Promise<void> {
65 const ids = app.db
66 .query<{ id: string }, [string]>(
67 `SELECT p.issue_id AS id FROM pull p JOIN issue i ON i.id = p.issue_id
68 WHERE i.repo_id = ?1 AND i.state = 'open' AND p.merged_at IS NULL`,
69 )
70 .all(repo.id)
71 .map((r) => r.id);
72 const pulls = joinMany(app.db, [...getIssuesByIds(app.db, ids).values()]);
73 const before = touched.get(repo.id) ?? new Map();
74 const after = new Map<string, { at: string; files: readonly string[] }>();
75 for (const pull of pulls) {
76 const at = `${pull.baseOid}...${pull.headOid}`;
77 const known = before.get(pull.id);
78 if (known !== undefined && known.at === at) {
79 after.set(pull.id, known);
80 continue;
81 }
82 if (!OID.test(pull.baseOid) || !OID.test(pull.headOid)) continue;
83 const dir = await pullDir(app.db, app.reposRoot, repo, pull);
84 const out = await runGitCapture(["diff", "--name-only", "-z", at], { cwd: dir });
85 if (out.code !== 0) continue;
86 after.set(pull.id, { at, files: out.stdout.split("\0").filter((f) => f !== "") });
87 }
88 touched.set(repo.id, after);
89 }
90
91 /** Every file of `repo` that `HOT_PULLS` or more open pulls touch, with how many. */
92 export function hotFiles(repoId: string): Map<string, number> {
93 const counts = new Map<string, number>();
94 for (const { files } of touched.get(repoId)?.values() ?? [])
95 for (const file of files) counts.set(file, (counts.get(file) ?? 0) + 1);
96 for (const [file, n] of counts) if (n < HOT_PULLS) counts.delete(file);
97 return counts;
98 }
99
100 /** The hot files `issue` expects to touch, hottest first. */
101 export function hotOverlap(
102 issue: Pick<Issue, "title" | "body">,
103 hot: ReadonlyMap<string, number>,
104 ): HotFile[] {
105 if (hot.size === 0) return [];
106 return expectedFiles(issue)
107 .flatMap((path) => {
108 const pulls = hot.get(path);
109 return pulls === undefined ? [] : [{ path, pulls }];
110 })
111 .sort((a, b) => b.pulls - a.pulls || a.path.localeCompare(b.path));
112 }
113
114 /** A held build as the feed says it. */
115 export interface HotHold {
116 readonly number: number;
117 readonly title: string;
118 readonly files: readonly HotFile[];
119 }
120
121 /** What the picker last held on hot files, per repository, and when. */
122 const standing = new Map<string, { at: string; holds: readonly HotHold[] }>();
123
124 /** Remember the builds the picker holds on hot files in `repoId` now; none clears it. */
125 export function holdHot(repoId: string, issues: readonly Issue[], at = new Date()): void {
126 if (issues.length === 0) {
127 standing.delete(repoId);
128 return;
129 }
130 const hot = hotFiles(repoId);
131 standing.set(repoId, {
132 at: at.toISOString(),
133 holds: issues.map((i) => ({ number: i.number, title: i.title, files: hotOverlap(i, hot) })),
134 });
135 }
136
137 /** The builds held on hot files in `repoId` as the picker last said, if any. */
138 export function standingHot(repoId: string): { at: string; holds: readonly HotHold[] } | undefined {
139 return standing.get(repoId);
140 }
141
142 /** The hot files as the tick line says them: `src/http/mcp.ts=61 docs/MCP.md=54`, hottest first. */
143 export function hotWords(hot: ReadonlyMap<string, number>): string {
144 return [...hot]
145 .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
146 .map(([path, n]) => `${path}=${n}`)
147 .join(" ");
148 }
149
150 /** One sentence for a held build: `#12 waits: src/http/mcp.ts is in 6 open pulls (hot at 5).` */
151 export function hotSays(hold: HotHold): string {
152 const files = hold.files
153 .slice(0, 3)
154 .map((f) => `${f.path} is in ${f.pulls} open pulls`)
155 .join(", ");
156 return `#${hold.number} waits: ${files === "" ? "a file it names is hot" : files} (hot at ${HOT_PULLS}).`;
157 }
src/feed/items.ts +31
@@ -27,6 +27,7 @@ import {
2727 import { getUserById, type User } from "../core/users.ts";
2828 import { watchedBy } from "../core/watches.ts";
2929 import { chooseCandidates } from "./choose.ts";
30 import { hotSays, standingHot } from "../crew/hot_files.ts";
3031 import { ago } from "./rank.ts";
3132 import { askLinesFor } from "../web/studio/asks.ts";
3233
@@ -197,6 +198,35 @@ function frozenCandidates(
197198 return out;
198199 }
199200
201 /**
202 * Builds the picker holds because a file they name is in 5 or more open
203 * pulls (#5934): one post per repository the viewer writes to, read from
204 * what the picker said last, so it goes when the pile drains.
205 */
206 function hotCandidates(writable: readonly Repo[]): Candidate[] {
207 const out: Candidate[] = [];
208 for (const repo of writable) {
209 const standing = standingHot(repo.id);
210 if (standing === undefined) continue;
211 const n = standing.holds.length;
212 const first = standing.holds[0]!;
213 const what = `${n === 1 ? "1 build waits" : `${n} builds wait`} on hot files: ${hotSays(first)}${n > 1 ? ` And ${n - 1} more.` : ""}`;
214 out.push({
215 id: `hot:${repo.id}`,
216 kind: "map",
217 repoId: repo.id,
218 app: slug(repo),
219 actorId: null,
220 actor: null,
221 at: standing.at,
222 link: `${base(repo)}/map`,
223 what: clip(what),
224 urgency: 0,
225 });
226 }
227 return out;
228 }
229
200230 export function candidatesFor(
201231 db: Database,
202232 viewer: User,
@@ -297,6 +327,7 @@ export function candidatesFor(
297327 out.push(...watchedCandidates(db, viewer, now));
298328 out.push(...frozenCandidates(db, viewer, now));
299329 out.push(...chooseCandidates(db, writable));
330 out.push(...hotCandidates(writable));
300331 for (const d of decided) {
301332 const repo = repos.get(d.repoId);
302333 if (!repo) continue;
src/web/pages/boards-held.ts +1 −1
@@ -32,7 +32,7 @@ export function heldCard(rows: readonly HeldRow[]): Html {
3232 <p class="muted small">
3333 Each ready issue a builder could start, or the first filter that holds
3434 it: a run on it, an open pull, the planner, stalled, its run cap,
35 parked twice, a red-main fix. When nothing can
35 parked twice, a red-main fix, a file 5 or more open pulls touch. When nothing can
3636 start and builders are free, the planner is asked to re-plan what is
3737 stalled or capped.
3838 </p>
test/crew-hot-files.test.ts added +195
@@ -0,0 +1,195 @@
1 /**
2 * The hot-file WIP limit (#5934): with five open pulls on one file, a sixth
3 * build naming that file waits while a build on a cold file starts, and
4 * the hold is said where the picker says why it skipped — the held counts,
5 * the feed, and the feed through the MCP. The hold lifts when the pile
6 * drains under five.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { addToCrew, createAgent } from "../src/actions/crew.ts";
14 import { openIssue, setIssueState } from "../src/actions/issues.ts";
15 import { openPull } from "../src/actions/pulls.ts";
16 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
17 import { setCrewRole } from "../src/core/agents.ts";
18 import { getIssueById, type Issue } from "../src/core/issues.ts";
19 import type { Pull } from "../src/core/pulls.ts";
20 import { getRepoById, type Repo } from "../src/core/repos.ts";
21 import { createToken } from "../src/core/tokens.ts";
22 import { createUser, getUserByName, type User } from "../src/core/users.ts";
23 import { driveRepo } from "../src/crew/autonomy.ts";
24 import { heldNow, heldWords } from "../src/crew/held.ts";
25 import {
26 expectedFiles,
27 HOT_PULLS,
28 hotFiles,
29 hotOverlap,
30 learnPullFiles,
31 standingHot,
32 } from "../src/crew/hot_files.ts";
33 import { readFeed } from "../src/feed/feed.ts";
34 import { serve, type Servers } from "../src/http/server.ts";
35
36 describe("expected files", () => {
37 test("are the repository paths an issue names, each once", () => {
38 expect(
39 expectedFiles({
40 title: "Split src/http/mcp.ts into one file per tool",
41 body: "Touches `docs/MCP.md`, `README.md` and src/http/mcp.ts again; see https://example.com/a/b.html and ../up/x.ts.",
42 }),
43 ).toEqual(["src/http/mcp.ts", "docs/MCP.md", "README.md"]);
44 expect(expectedFiles({ title: "Make the home page faster", body: "" })).toEqual([]);
45 });
46
47 test("overlap a hot file only when it is named", () => {
48 const hot = new Map([["src/a.ts", 7], ["src/b.ts", 5]]);
49 expect(hotOverlap({ title: "src/b.ts and src/a.ts", body: "" }, hot)).toEqual([
50 { path: "src/a.ts", pulls: 7 },
51 { path: "src/b.ts", pulls: 5 },
52 ]);
53 expect(hotOverlap({ title: "src/c.ts", body: "" }, hot)).toEqual([]);
54 });
55 });
56
57 describe("the picker holds a build on a hot file", () => {
58 let dataDir: string;
59 let app: App;
60 let alice: User;
61 let servers: Servers;
62
63 beforeAll(async () => {
64 dataDir = await mkdtemp(join(tmpdir(), "forge-hot-"));
65 app = createApp({ dataDir, port: 0 });
66 const a = await createUser(app.db, {
67 origin: "seed",
68 trusted: true,
69 username: "alice",
70 email: "[email protected]",
71 password: "a-good-passphrase",
72 adminIfFirst: true,
73 });
74 if (!a.ok) throw new Error(a.error);
75 alice = a.value;
76 servers = await serve(app);
77 });
78
79 afterAll(async () => {
80 servers.stop();
81 app.close();
82 await rm(dataDir, { recursive: true, force: true });
83 });
84
85 const fresh = (repo: Repo) => getRepoById(app.db, repo.id)!;
86
87 function issue(repo: Repo, title: string): Issue {
88 const opened = openIssue(app, alice, fresh(repo), { title, body: "" });
89 if (!opened.ok) throw new Error(String(opened.error));
90 return opened.value;
91 }
92
93 async function pullOn(repo: Repo, n: number, path: string): Promise<Pull> {
94 const branch = await commitToBranch(app, alice, fresh(repo), {
95 branch: `feature/${n}`,
96 base: "main",
97 message: `Change ${n}`,
98 files: [{ path, content: `export const n = ${n};\n` }],
99 });
100 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
101 const pull = await openPull(app, alice, fresh(repo), {
102 title: `Change ${n}`,
103 body: "",
104 baseBranch: "main",
105 headBranch: `feature/${n}`,
106 });
107 if (!pull.ok) throw new Error(String(pull.error));
108 return pull.value;
109 }
110
111 test("a sixth build on a hot file waits while a build on a cold file starts", async () => {
112 const made = await createRepo(app, alice, { name: "engine", visibility: "private" });
113 if (!made.ok) throw new Error(String(made.error));
114 for (const agent of ["atlas", "mason"]) {
115 const created = await createAgent(app, alice, { username: agent });
116 if (!created.ok) throw new Error(String(created.error));
117 expect(addToCrew(app, alice, made.value, agent).ok).toBe(true);
118 }
119 setCrewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner");
120 const set = await updateRepoSettings(app, alice, made.value, {
121 autonomy: "autonomous",
122 autonomyDirection: "Keep the engine small.",
123 autonomyCeilingUsd: 100,
124 autonomySlots: 1,
125 autonomyBy: alice.id,
126 });
127 if (!set.ok) throw new Error(JSON.stringify(set.error));
128 const repo = fresh(made.value);
129 const seeded = await commitToBranch(app, alice, repo, {
130 message: "Start",
131 files: [
132 { path: "src/hot.ts", content: "export const n = 0;\n" },
133 { path: "src/cold.ts", content: "export const n = 0;\n" },
134 ],
135 });
136 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
137 const pulls: Pull[] = [];
138 for (let n = 1; n <= HOT_PULLS; n++) pulls.push(await pullOn(repo, n, "src/hot.ts"));
139
140 // First on the map, so only the hold keeps it from being picked.
141 const hotWork = issue(repo, "Add a flag to src/hot.ts");
142 const coldWork = issue(repo, "Tidy src/cold.ts");
143
144 expect(await driveRepo(app, fresh(repo))).toBe("picked");
145 const mason = getUserByName(app.db, "mason")!;
146 expect(getIssueById(app.db, coldWork.id)!.assigneeId).toBe(mason.id);
147 expect(getIssueById(app.db, hotWork.id)!.assigneeId).toBeNull();
148 expect(hotFiles(repo.id)).toEqual(new Map([["src/hot.ts", HOT_PULLS]]));
149
150 // Said where the picker says why it skipped: the held counts the tick
151 // line and /flow read…
152 const held = heldNow(app, fresh(repo));
153 expect(held.by.hot_file.map((i) => i.number)).toEqual([hotWork.number]);
154 expect(heldWords(held)).toContain("hot_file=1");
155 expect(standingHot(repo.id)?.holds).toEqual([
156 { number: hotWork.number, title: hotWork.title, files: [{ path: "src/hot.ts", pulls: HOT_PULLS }] },
157 ]);
158
159 // …the feed…
160 const said = `#${hotWork.number} waits: src/hot.ts is in ${HOT_PULLS} open pulls (hot at ${HOT_PULLS}).`;
161 const feed = readFeed(app.db, alice, { limit: 50 });
162 const post = feed.items.find((i) => i.id === `hot:${repo.id}`);
163 expect(post?.what).toBe(`1 build waits on hot files: ${said}`);
164 expect(post?.link).toBe(`/alice/engine/map`);
165
166 // …and the feed through the MCP.
167 const token = createToken(app.db, alice.id, "t", "ro").token;
168 const res = await fetch(`${servers.url}/mcp`, {
169 method: "POST",
170 headers: {
171 authorization: `Bearer ${token}`,
172 "content-type": "application/json",
173 accept: "application/json, text/event-stream",
174 },
175 body: JSON.stringify({
176 jsonrpc: "2.0",
177 id: 1,
178 method: "tools/call",
179 params: { name: "feed", arguments: { limit: 50 } },
180 }),
181 });
182 const body = (await res.json()) as { result: { content: { text: string }[] } };
183 expect(body.result.content[0]!.text).toContain(said);
184
185 // The pile drains under the limit: the hold lifts, and the post goes.
186 const closed = setIssueState(app, alice, fresh(repo), getIssueById(app.db, pulls[0]!.id)!, "closed");
187 expect(closed.ok).toBe(true);
188 await learnPullFiles(app, fresh(repo));
189 expect(hotFiles(repo.id).size).toBe(0);
190 expect(heldNow(app, fresh(repo)).by.hot_file).toEqual([]);
191 await driveRepo(app, fresh(repo));
192 expect(standingHot(repo.id)).toBeUndefined();
193 expect(readFeed(app.db, alice, { limit: 50 }).items.some((i) => i.id === `hot:${repo.id}`)).toBe(false);
194 });
195 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs