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

The studio home answers without waiting on the decider #38

Merged opened this

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

Merged

scout/forge:scout/34-the-studio-home-answers-without-waiting → main 1 commit fork

  • Merged. scout/34-the-studio-home-answers-without-waiting was merged into main as 7a24055.

Review

Sign in to review this change.
5 files changed +430 −38
docs/STUDIO.md +9
@@ -60,6 +60,15 @@ the green ones, close the stopped ones — as one decision (`POST
6060 never leaves the page: the row looks busy until it lands, then goes; one
6161 that could not be done stays, with the reason on it.
6262
63 When a decider is configured, an ask card's own option can carry a **lean**
64 — a small word (_likely_, _probably_, _maybe_) beside the option a careful
65 owner would pick, from asking the decider the same question a person is
66 looking at. The lean is never why the page waits: a card renders with
67 whatever lean is already known, and a card with none yet is asked for after
68 the page is on its way to the browser, so the answer is there on a later
69 render, not this one. A forge with no decider configured shows no lean and
70 asks nothing, exactly as before deciders existed.
71
6372 **Now** lists what was told and not yet picked up, every open pull with its
6473 state, and the last runs, each in one of the five words with who and when.
6574 A run in flight carries _Stop_ (running) or _Cancel_ (waiting) for anyone
src/core/decisions.ts +41 −1
@@ -9,7 +9,10 @@
99 import type { Database } from "bun:sqlite";
1010 import { id as newId } from "../kernel/id.ts";
1111
12 export type DecisionKind = "next" | "land" | "retry";
12 /** `lean` is which option on a studio ask card a careful owner would pick
13 * — see `web/pages/studio.ts`, which stores `{ card, head }` in `state` so
14 * `latestLean` can tell a fresh answer from one about a head that moved. */
15 export type DecisionKind = "next" | "land" | "retry" | "lean";
1316
1417 export interface Decision {
1518 readonly id: string;
@@ -143,6 +146,43 @@ export function resolveOpen(
143146 return true;
144147 }
145148
149 /**
150 * The newest `lean` decision still about the same head: the option a
151 * decider offered for one studio ask card, good only as long as the head it
152 * was asked about. `head` is a pull's head oid, or `""` for a card about a
153 * repository rather than a pull. A handful of rows back covers a card whose
154 * kind changed (the same issue asked as `stopped`, then as `look`) without
155 * a join on anything but what is already indexed.
156 */
157 export function latestLean(
158 db: Database,
159 repoId: string,
160 issueId: string | null,
161 card: string,
162 head: string,
163 ): Decision | undefined {
164 const rows =
165 issueId === null
166 ? db
167 .query<
168 Row,
169 [string]
170 >("SELECT * FROM decision WHERE kind = 'lean' AND issue_id IS NULL AND repo_id = ?1 ORDER BY created_at DESC, id DESC LIMIT 10")
171 .all(repoId)
172 : db
173 .query<
174 Row,
175 [string]
176 >("SELECT * FROM decision WHERE kind = 'lean' AND issue_id = ?1 ORDER BY created_at DESC, id DESC LIMIT 10")
177 .all(issueId);
178 return rows.map(toDecision).find((d) => {
179 const s = d.state as { card?: unknown; head?: unknown } | null;
180 return (
181 typeof s === "object" && s !== null && s.card === card && s.head === head
182 );
183 });
184 }
185
146186 export function listDecisions(
147187 db: Database,
148188 repoId: string,
src/web/pages/studio.ts +105 −36
@@ -49,11 +49,16 @@ import {
4949 } from "../../core/runs.ts";
5050 import { getUserById, getUsersByIds, type User } from "../../core/users.ts";
5151 import { autonomyStatus } from "../../crew/autonomy.ts";
52 import { latestDecision } from "../../core/decisions.ts";
52 import {
53 latestDecision,
54 latestLean,
55 recordDecision,
56 } from "../../core/decisions.ts";
5357 import { routeIssue } from "../../crew/route.ts";
54 import { certain, decideOr } from "../../crew/decide.ts";
58 import { certain, decideOr, type Decider } from "../../crew/decide.ts";
5559 import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts";
5660 import type { Ctx } from "../../http/context.ts";
61 import { createLog } from "../../kernel/log.ts";
5762 import {
5863 crumbsFor,
5964 loadRepo,
@@ -124,28 +129,46 @@ const CARD_OPTIONS: Record<DecisionKind, Readonly<Record<string, string>>> = {
124129 paused: { settings: "Raise the ceiling.", wait: "Wait for midnight." },
125130 };
126131
132 const log = createLog("studio");
133
134 /** A pull's head oid, or "" for a card with no commit to key a lean on. */
135 const headOf = (d: Decision): string => d.pull?.headOid ?? "";
136
137 /** One background ask per card+head, so a page rendered by many people at
138 * once never fans out past one call per card. Cleared once answered. */
139 const leanInFlight = new Map<string, Promise<void>>();
140
141 /** A pause before asking, long enough that the response for the render
142 * which triggered the ask is on its way out first — the point is that the
143 * decider is never on the request's critical path, not that the ask is
144 * instant. */
145 const LEAN_ASK_DELAY_MS = 50;
146
127147 /**
128 * Ask the decider which option on each card is likely right, so the card
129 * can lead with it. Bounded to a short wait: a page is being drawn.
148 * Ask the decider which option on a card is likely right, off the request
149 * path — after this turn of the event loop, never inside it — and remember
150 * the answer keyed to the head it was about. A rule's answer is not worth
151 * remembering: it is what the page would have shown anyway.
130152 */
131 export async function withLikely(
132 ctx: Ctx,
133 decisions: readonly Decision[],
134 ): Promise<Decision[]> {
135 const decider = ctx.app.config.decider;
136 if (decider === undefined || decisions.length === 0) return [...decisions];
137 return Promise.all(
138 decisions.map(async (d) => {
139 const options = Object.keys(CARD_OPTIONS[d.kind]);
140 const decided = await decideOr(
153 function scheduleLean(ctx: Ctx, d: Decision, decider: Decider): void {
154 const head = headOf(d);
155 const key = `${d.id}@${head}`;
156 if (leanInFlight.has(key)) return;
157 const options = Object.keys(CARD_OPTIONS[d.kind]);
158 const state = {
159 card: d.kind,
160 head,
161 repository: `${d.repo.ownerName}/${d.repo.name}`,
162 situation: d.says,
163 ...(d.pull === undefined
164 ? {}
165 : { change: d.pull.title, description: d.pull.body.slice(0, 800) }),
166 };
167 const asked = new Promise<void>((resolve) => {
168 setTimeout(() => {
169 decideOr(
141170 decider,
142 {
143 repository: `${d.repo.ownerName}/${d.repo.name}`,
144 situation: d.says,
145 ...(d.pull === undefined
146 ? {}
147 : { change: d.pull.title, description: d.pull.body.slice(0, 800) }),
148 },
171 state,
149172 {
150173 option: {
151174 options,
@@ -154,19 +177,65 @@ export async function withLikely(
154177 },
155178 },
156179 () => ({ option: certain(options, options[0]!) }),
157 800,
158 );
159 return decided.by === "rule"
160 ? d
161 : {
162 ...d,
163 likely: {
164 option: decided.answers.option.choice,
165 p: decided.answers.option.p,
166 },
167 };
168 }),
169 );
180 )
181 .then((decided) => {
182 if (decided.by === "rule") return;
183 recordDecision(ctx.app.db, {
184 kind: "lean",
185 repoId: d.repo.id,
186 issueId: d.pull?.id,
187 state,
188 answer: decided.answers.option.choice,
189 p: decided.answers.option.p,
190 by: decided.by,
191 ms: decided.ms,
192 });
193 })
194 .catch((error) =>
195 log.warn("lean not recorded", { id: d.id, error: String(error) }),
196 )
197 .finally(() => {
198 leanInFlight.delete(key);
199 resolve();
200 });
201 }, LEAN_ASK_DELAY_MS);
202 });
203 leanInFlight.set(key, asked);
204 }
205
206 /** Test hook: every ask scheduled so far, answered or not. The page itself
207 * never waits on this — only a test that wants the next render to have it. */
208 export function pendingLeans(): Promise<unknown> {
209 return Promise.allSettled([...leanInFlight.values()]);
210 }
211
212 /**
213 * Every card with the lean already known about it, read with no wait; a
214 * card whose lean is unknown, or whose head has moved past the one it was
215 * asked about, is asked for off the request path (`scheduleLean`) so a
216 * later render has it. A forge with no decider does nothing here, exactly
217 * as before.
218 */
219 export function withLikely(
220 ctx: Ctx,
221 decisions: readonly Decision[],
222 ): Decision[] {
223 const decider = ctx.app.config.decider;
224 if (decider === undefined) return [...decisions];
225 return decisions.map((d) => {
226 const lean = latestLean(
227 ctx.app.db,
228 d.repo.id,
229 d.pull?.id ?? null,
230 d.kind,
231 headOf(d),
232 );
233 if (lean === undefined) {
234 scheduleLean(ctx, d, decider);
235 return d;
236 }
237 return { ...d, likely: { option: lean.answer, p: lean.p } };
238 });
170239 }
171240
172241 /** When a person last spoke on a conversation, or its start. */
@@ -471,7 +540,7 @@ export async function studioApp(
471540 const i = getIssueById(db, r.issueId);
472541 if (i) issues.set(r.issueId, i);
473542 }
474 const decisions = await withLikely(ctx, decisionsFor(ctx, [repo]));
543 const decisions = withLikely(ctx, decisionsFor(ctx, [repo]));
475544 const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 });
476545 const pullChecks = latestRunsForIssues(
477546 db,
@@ -850,7 +919,7 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
850919 .map((r) => r.actorId)
851920 .filter((id): id is string => id !== null),
852921 );
853 const decisions = await withLikely(ctx, decisionsFor(ctx, appRepos));
922 const decisions = withLikely(ctx, decisionsFor(ctx, appRepos));
854923 const asksByRepo = new Map<string, number>();
855924 for (const d of decisions)
856925 asksByRepo.set(d.repo.id, (asksByRepo.get(d.repo.id) ?? 0) + 1);
test/decide.test.ts +138 −1
@@ -11,10 +11,13 @@ import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1313 import { openIssue } from "../src/actions/issues.ts";
14 import { createRepo } from "../src/actions/repos.ts";
14 import { openPull } from "../src/actions/pulls.ts";
15 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1516 import { listCrewRoles, setCrewRole } from "../src/core/agents.ts";
1617 import { getIssueById } from "../src/core/issues.ts";
18 import type { Pull } from "../src/core/pulls.ts";
1719 import type { Repo } from "../src/core/repos.ts";
20 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
1821 import { createUser, getUserByName, type User } from "../src/core/users.ts";
1922 import {
2023 certain,
@@ -25,6 +28,11 @@ import {
2528 type Decider,
2629 } from "../src/crew/decide.ts";
2730 import { kindByWords, routeIssue, sizeByWords } from "../src/crew/route.ts";
31 import {
32 decisionsFor,
33 pendingLeans,
34 withLikely,
35 } from "../src/web/pages/studio.ts";
2836
2937 const Q = {
3038 builder: { options: ["mason", "scout"], about: "who" },
@@ -350,3 +358,132 @@ describe("routing an issue", () => {
350358 withJev.close();
351359 });
352360 });
361
362 describe("a studio card's lean", () => {
363 let dataDir: string;
364 let app: App;
365 let alice: User;
366 let repo: Repo;
367 let pull: Pull;
368 let calls: number;
369
370 beforeAll(async () => {
371 dataDir = await mkdtemp(join(tmpdir(), "forge-lean-"));
372 calls = 0;
373 const decider: Decider = {
374 name: "fake",
375 async decide(_state, questions) {
376 calls++;
377 const options = questions["option"]!.options;
378 return {
379 option: {
380 choice: options[0]!,
381 p: 0.9,
382 probabilities: Object.fromEntries(
383 options.map((o, i) => [o, i === 0 ? 0.9 : 0.05]),
384 ),
385 },
386 } as never;
387 },
388 };
389 app = createApp({ dataDir, port: 0, decider });
390 const a = await createUser(app.db, {
391 username: "alice",
392 email: "[email protected]",
393 password: "a-good-passphrase",
394 adminIfFirst: true,
395 });
396 if (!a.ok) throw new Error(a.error);
397 alice = a.value;
398 const made = await createRepo(app, alice, {
399 name: "leans",
400 visibility: "private",
401 });
402 if (!made.ok) throw new Error(String(made.error));
403 repo = made.value;
404 const seeded = await commitToBranch(app, alice, repo, {
405 message: "Start",
406 files: [{ path: "README.md", content: "hi\n" }],
407 });
408 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
409 const scoutAgent = await createAgent(app, alice, { username: "scout" });
410 if (!scoutAgent.ok) throw new Error(String(scoutAgent.error));
411 expect(addToCrew(app, alice, repo, "scout").ok).toBe(true);
412 const scout = getUserByName(app.db, "scout")!;
413 const branch = await commitToBranch(app, alice, repo, {
414 branch: "feature/x",
415 base: "main",
416 message: "Add x",
417 files: [{ path: "x.md", content: "x\n" }],
418 });
419 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
420 const opened = await openPull(app, scout, repo, {
421 title: "Add x",
422 body: "",
423 baseBranch: "main",
424 headBranch: "feature/x",
425 });
426 if (!opened.ok) throw new Error(String(opened.error));
427 pull = opened.value;
428 const run = createRun(app.db, {
429 repoId: repo.id,
430 kind: "check",
431 issueId: pull.id,
432 env: `pull-${pull.number}`,
433 commitOid: pull.headOid,
434 reason: "pull",
435 });
436 claimRun(app.db, run.id);
437 finishRun(app.db, run.id, { state: "ok", summary: "check passed" });
438 });
439
440 afterAll(async () => {
441 app.close();
442 await rm(dataDir, { recursive: true, force: true });
443 });
444
445 const ctx = () => ({ app, auth: { user: alice, via: "session" } }) as never;
446
447 test("reading the card makes no decider call; the answer it asked for shows on the next read", async () => {
448 const first = withLikely(ctx(), decisionsFor(ctx(), [repo]));
449 const card = first.find((d) => d.pull?.id === pull.id);
450 expect(card).toBeDefined();
451 expect(card?.likely).toBeUndefined();
452 expect(calls).toBe(0);
453 await pendingLeans();
454 expect(calls).toBe(1);
455 const second = withLikely(ctx(), decisionsFor(ctx(), [repo]));
456 expect(second.find((d) => d.pull?.id === pull.id)?.likely).toEqual({
457 option: "ship",
458 p: 0.9,
459 });
460 expect(calls).toBe(1);
461 });
462
463 test("a lean is dropped once the pull's head moves, and asked for again", async () => {
464 app.db
465 .query("UPDATE pull SET head_oid = ?2 WHERE issue_id = ?1")
466 .run(pull.id, "0".repeat(40));
467 const leaned = withLikely(ctx(), decisionsFor(ctx(), [repo]));
468 const card = leaned.find((d) => d.pull?.id === pull.id);
469 expect(card).toBeDefined();
470 expect(card?.likely).toBeUndefined();
471 await pendingLeans();
472 expect(calls).toBe(2);
473 });
474
475 test("no decider configured: no lean, ever, and nothing asked", async () => {
476 const withoutDecider = createApp({ dataDir, port: 0 });
477 const anon = {
478 app: withoutDecider,
479 auth: { user: alice, via: "session" },
480 } as never;
481 const asks = withLikely(anon, decisionsFor(anon, [repo]));
482 const card = asks.find((d) => d.pull?.id === pull.id);
483 expect(card).toBeDefined();
484 expect(card?.likely).toBeUndefined();
485 await pendingLeans();
486 expect(calls).toBe(2);
487 withoutDecider.close();
488 });
489 });
test/studio.test.ts +137
@@ -16,8 +16,10 @@ import { getPull, type Pull } from "../src/core/pulls.ts";
1616 import type { Repo } from "../src/core/repos.ts";
1717 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
1818 import { createUser, getUserByName, type User } from "../src/core/users.ts";
19 import type { Decider } from "../src/crew/decide.ts";
1920 import { CHECK_FAILED_LIMIT } from "../src/crew/dispatch.ts";
2021 import { serve, type Servers } from "../src/http/server.ts";
22 import { pendingLeans } from "../src/web/pages/studio.ts";
2123
2224 describe("the studio", () => {
2325 let dataDir: string;
@@ -369,3 +371,138 @@ describe("the studio", () => {
369371 expect(after).not.toContain('action="/studio/asks"');
370372 });
371373 });
374
375 describe("the studio home does not wait on the decider", () => {
376 const DECIDER_MS = 800;
377 let dataDir: string;
378 let app: App;
379 let servers: Servers;
380 let base: string;
381 let alice: User;
382 let repo: Repo;
383 let cookie: string;
384 let calls: number;
385
386 const get = (path: string) =>
387 fetch(`${base}${path}`, {
388 headers: { cookie: `${cookie}; forge_mode=studio` },
389 redirect: "manual",
390 });
391
392 beforeAll(async () => {
393 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-lean-"));
394 calls = 0;
395 const decider: Decider = {
396 name: "slow",
397 async decide(_state, questions) {
398 calls++;
399 await Bun.sleep(DECIDER_MS);
400 const options = questions["option"]!.options;
401 return {
402 option: {
403 choice: options[0]!,
404 p: 0.9,
405 probabilities: Object.fromEntries(
406 options.map((o, i) => [o, i === 0 ? 0.9 : 0.05]),
407 ),
408 },
409 } as never;
410 },
411 };
412 app = createApp({ dataDir, port: 0, decider });
413 servers = await serve(app);
414 base = servers.url;
415 const a = await createUser(app.db, {
416 username: "alice",
417 email: "[email protected]",
418 password: "a-good-passphrase",
419 adminIfFirst: true,
420 });
421 if (!a.ok) throw new Error(a.error);
422 alice = a.value;
423 cookie = (
424 (
425 await fetch(`${base}/signin`, {
426 method: "POST",
427 body: new URLSearchParams({
428 username: "alice",
429 password: "a-good-passphrase",
430 }),
431 redirect: "manual",
432 })
433 ).headers.get("set-cookie") ?? ""
434 ).split(";")[0]!;
435 const made = await createRepo(app, alice, {
436 name: "leans",
437 visibility: "private",
438 description: "for the lean",
439 });
440 if (!made.ok) throw new Error(String(made.error));
441 repo = made.value;
442 const seeded = await commitToBranch(app, alice, repo, {
443 message: "Start",
444 files: [
445 {
446 path: "package.json",
447 content: '{"name":"leans","scripts":{"start":"bun run server.ts"}}\n',
448 },
449 {
450 path: "server.ts",
451 content:
452 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
453 },
454 ],
455 });
456 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
457 const enabled = await enableApp(app, alice, repo, { slug: "leans" });
458 if (!enabled.ok) throw new Error(String(enabled.error));
459 const scout = await createAgent(app, alice, { username: "scout" });
460 if (!scout.ok) throw new Error(String(scout.error));
461 expect(addToCrew(app, alice, repo, "scout").ok).toBe(true);
462 const scoutUser = getUserByName(app.db, "scout")!;
463 const branch = await commitToBranch(app, alice, repo, {
464 branch: "feature/x",
465 base: "main",
466 message: "Add x",
467 files: [{ path: "x.md", content: "x\n" }],
468 });
469 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
470 const opened = await openPull(app, scoutUser, repo, {
471 title: "Add x",
472 body: "",
473 baseBranch: "main",
474 headBranch: "feature/x",
475 });
476 if (!opened.ok) throw new Error(String(opened.error));
477 const pull = opened.value;
478 const run = createRun(app.db, {
479 repoId: repo.id,
480 kind: "check",
481 issueId: pull.id,
482 env: `pull-${pull.number}`,
483 commitOid: pull.headOid,
484 reason: "pull",
485 });
486 claimRun(app.db, run.id);
487 finishRun(app.db, run.id, { state: "ok", summary: "check passed" });
488 });
489
490 afterAll(async () => {
491 servers.stop();
492 app.close();
493 await rm(dataDir, { recursive: true, force: true });
494 });
495
496 test("answers well under the decider's own time, with no call made while rendering, and the second render carries the lean the first one asked for", async () => {
497 const started = Date.now();
498 const home = await (await get("/")).text();
499 expect(Date.now() - started).toBeLessThan(DECIDER_MS / 2);
500 expect(calls).toBe(0);
501 expect(home).toContain("Ship");
502 expect(home).not.toContain("by the decider");
503 await pendingLeans();
504 expect(calls).toBe(1);
505 const again = await (await get("/")).text();
506 expect(again).toContain("by the decider");
507 });
508 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs