Public: anyone can read this; the crew can change it.
Below the ranked rows, the crew picks in the order Next up shows, so the page is the truth #8840
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8826-below-the-ranked-rows-the-crew-picks-in → main 4 commits fork
8 files
changed +172
−88
docs/CREW.md +5 −6
| @@ -282,14 +282,13 @@ the planner's, found done on main and waiting for a person to close it | ||
| 282 | 282 | unticked and waiting for a person to answer (`landed`, `crew/landed.ts`), stalled, at its run cap, parked twice, covered by a red-main |
| 283 | 283 | fix, or a setting's finding. The flow panel on `/boards` draws the same per autonomous app as one |
| 284 | 284 | bar, and the terminal's Flow command prints it as a line per app |
| 285 | (`flow/held.ts`, kept 5 s). Of the startable, what the owner raised again | |
| 286 | stays on top, then the roadmap is picked | |
| 287 | (#7571, `crew/small_share.ts`): an issue under an open epic or a P0/P1 pillar, | |
| 288 | then other work, then small issues — a title like "A test pins …" or | |
| 289 | "Tests: …", or a label `test`, `tests`, `tests-only` or `small` — and small issues hold | |
| 285 | (`flow/held.ts`, kept 5 s). Of the startable, the picker takes the order Next up shows | |
| 286 | (#8826, `pickOrder` in `core/next_up.ts`): the owner's ranked issues by place, | |
| 287 | then the ready order, what the owner raised again first. Small issues — a title like "A test pins …" or | |
| 288 | "Tests: …", or a label `test`, `tests`, `tests-only` or `small` (#7571, `crew/small_share.ts`) — hold | |
| 290 | 289 | at most `slots.smallShare` percent of the builder slots (default 30, rounded up: |
| 291 | 290 | 2 of 5; an admin sets it on `/settings/capacity`). At the cap a small issue is |
| 292 | skipped, not waited on, raised or not. The tick line's `small: 1/2`, the | |
| 291 | skipped, not waited on, unless the owner ranked it. The tick line's `small: 1/2`, the | |
| 293 | 292 | Flow command's `small 1/2`, the app's row in Today on `/boards` (`small |
| 294 | 293 | issues: 1 of 2 slots`) and the studio's crew panel show small issues in |
| 295 | 294 | flight of what they may hold. When nothing is startable while builder slots are free and some of the |
src/core/capacity.ts +1 −1
| @@ -114,7 +114,7 @@ export const CAPACITY: Record<CapacityKey, Definition> = { | ||
| 114 | 114 | env: "FORGE_SLOTS_SMALL_SHARE", |
| 115 | 115 | kind: { type: "count", min: 0, max: 100 }, |
| 116 | 116 | fallback: "30", |
| 117 | help: "The most of a repository's builder slots that tests-only and small-addition issues (\"A test pins …\", or a label test, tests, tests-only or small) hold at once, as a percent rounded up: 30 lets 2 of 5 slots go to them. At the cap a small issue is skipped and the slot goes to roadmap work; roadmap work is picked first either way. 100 lifts the cap; 0 starts none. Read on every pick.", | |
| 117 | help: "The most of a repository's builder slots that tests-only and small-addition issues (\"A test pins …\", or a label test, tests, tests-only or small) hold at once, as a percent rounded up: 30 lets 2 of 5 slots go to them. At the cap a small issue is skipped and the slot goes to the next issue in the Next up order; a ranked small issue is still taken. 100 lifts the cap; 0 starts none. Read on every pick.", | |
| 118 | 118 | }, |
| 119 | 119 | }; |
| 120 | 120 |
src/core/next_up.ts +20 −4
| @@ -85,6 +85,24 @@ export function roleOf( | ||
| 85 | 85 | }; |
| 86 | 86 | } |
| 87 | 87 | |
| 88 | /** | |
| 89 | * The whole order, once (#8826): the ranked issues by place, then the ready | |
| 90 | * ones nobody ranked, in the ready order. Next up shows it and the picker | |
| 91 | * takes from it; neither keeps an order of its own. `blocked` is the open | |
| 92 | * ranked issues that are not ready: the page shows them in their place, and | |
| 93 | * the picker, which can only start what is ready, passes none. | |
| 94 | */ | |
| 95 | export function pickOrder<T>( | |
| 96 | ready: readonly T[], | |
| 97 | ranks: ReadonlyMap<string, number>, | |
| 98 | idOf: (t: T) => string, | |
| 99 | blocked: readonly T[] = [], | |
| 100 | ): T[] { | |
| 101 | const place = (t: T) => ranks.get(idOf(t)); | |
| 102 | const ranked = [...ready, ...blocked].filter((t) => place(t) !== undefined).sort((a, b) => place(a)! - place(b)!); | |
| 103 | return [...ranked, ...ready.filter((t) => place(t) === undefined)]; | |
| 104 | } | |
| 105 | ||
| 88 | 106 | /** The list for one repository; `scope` is what `sealedScope` says the viewer may see. */ |
| 89 | 107 | export function nextUp( |
| 90 | 108 | db: Database, |
| @@ -95,9 +113,6 @@ export function nextUp( | ||
| 95 | 113 | const ranks = graph.ranked ?? new Map<string, number>(); |
| 96 | 114 | const ready = readyNow(graph).map((r) => r.issue); |
| 97 | 115 | const readyIds = new Set(ready.map((i) => i.id)); |
| 98 | const ranked = graph.issues | |
| 99 | .filter((i) => i.state === "open" && ranks.has(i.id)) | |
| 100 | .sort((a, b) => ranks.get(a.id)! - ranks.get(b.id)!); | |
| 101 | 116 | // Who is on an issue: the crew running it right now, else whoever it is assigned to. |
| 102 | 117 | const crew = new Map<string, string>(); |
| 103 | 118 | for (const run of listActiveRuns(db, [repo.id])) { |
| @@ -113,7 +128,8 @@ export function nextUp( | ||
| 113 | 128 | onIt: crew.get(issue.id) ?? issue.assigneeName, |
| 114 | 129 | why: readyIds.has(issue.id) ? null : whyNot(graph, issue), |
| 115 | 130 | }); |
| 116 | const rows = [...ranked, ...ready.filter((i) => !ranks.has(i.id))].map(rowOf); | |
| 131 | const blocked = graph.issues.filter((i) => i.state === "open" && ranks.has(i.id) && !readyIds.has(i.id)); | |
| 132 | const rows = pickOrder(ready, ranks, (i) => i.id, blocked).map(rowOf); | |
| 117 | 133 | const listed = new Set(rows.map((r) => r.issue.id)); |
| 118 | 134 | const away = graph.issues.filter( |
| 119 | 135 | (i) => i.state === "open" && graph.deferred.has(i.id) && !listed.has(i.id) && !graph.structural.has(i.id), |
src/crew/autonomy.ts +6 −7
| @@ -31,7 +31,7 @@ import { addLearned } from "../checks/learned.ts"; | ||
| 31 | 31 | import { batchWords, dropFromLine, enqueue, type GateCandidate, type Queued, type QueueOptions, queueClashes, queueWords, waitingPulls } from "../checks/queue.ts"; |
| 32 | 32 | import { checksFor } from "../checks/service.ts"; |
| 33 | 33 | import { queueBatch, queueDepth, smallShare } from "../core/capacity.ts"; |
| 34 | import { roadmapIssues, shareWords, slotOrder, smallCap, smallIssues, smallOfIds } from "./small_share.ts"; | |
| 34 | import { shareWords, slotOrder, smallCap, smallIssues, smallOfIds } from "./small_share.ts"; | |
| 35 | 35 | import { backedBetOf, betsInUse } from "../core/bets.ts"; |
| 36 | 36 | import { mayPick } from "./bets.ts"; |
| 37 | 37 | import { raisedAt } from "../core/thought_links.ts"; |
| @@ -48,6 +48,7 @@ import { | ||
| 48 | 48 | } from "../core/agents.ts"; |
| 49 | 49 | import { pickBuilder, routeIssue } from "./route.ts"; |
| 50 | 50 | import { firstAt } from "../core/first.ts"; |
| 51 | import { pickOrder } from "../core/next_up.ts"; | |
| 51 | 52 | import { ranksInRepo } from "../core/rank.ts"; |
| 52 | 53 | import { ensurePlanner } from "./planner.ts"; |
| 53 | 54 | import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts"; |
| @@ -404,11 +405,10 @@ function freeSlots(app: App, repo: Repo, now: Date): number { | ||
| 404 | 405 | return Math.max(0, repoPace(app, repo, now).slots - slotted.length); |
| 405 | 406 | } |
| 406 | 407 | |
| 407 | /** The pickable issues in slot order: the owner's raised on top, then the roadmap, small last and none past their share (#7571). */ | |
| 408 | function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: readonly Run[], slots: number, picked: ReadonlySet<string> = new Set()): Ranked[] { | |
| 408 | /** The pickable issues in slot order: the order Next up shows, small work held to its share (#7571). */ | |
| 409 | export function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: readonly Run[], slots: number, picked: ReadonlySet<string> = new Set()): Ranked[] { | |
| 409 | 410 | const issues = ready.map((r) => r.issue); |
| 410 | 411 | const small = smallIssues(app.db, issues); |
| 411 | const roadmap = roadmapIssues(app.db, repo, issues.map((i) => i.id)); | |
| 412 | 412 | const first = firstAt(app.db, issues.map((i) => i.id)); |
| 413 | 413 | const ranked = ranksInRepo(app.db, repo.id); |
| 414 | 414 | const raised = raisedAt(app.db, issues.map((i) => i.id)); |
| @@ -426,12 +426,11 @@ function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: re | ||
| 426 | 426 | bet: bets.get(r.issue.id), |
| 427 | 427 | }), |
| 428 | 428 | ); |
| 429 | // The order is the one Next up shows (#8826): ranked by place, then the ready order. | |
| 429 | 430 | return slotOrder( |
| 430 | allowed, | |
| 431 | pickOrder(allowed, ranked, (r) => r.issue.id), | |
| 431 | 432 | (r) => ({ |
| 432 | 433 | ranked: ranked.has(r.issue.id), |
| 433 | raised: raised.has(r.issue.id) || first.has(r.issue.id), | |
| 434 | roadmap: roadmap.has(r.issue.id), | |
| 435 | 434 | small: small.has(r.issue.id) && !first.has(r.issue.id), |
| 436 | 435 | }), |
| 437 | 436 | running, |
test/mcp-next-up.test.ts +44 −2
| @@ -8,7 +8,10 @@ import { openIssue } from "../src/actions/issues.ts"; | ||
| 8 | 8 | import { createRepo } from "../src/actions/repos.ts"; |
| 9 | 9 | import { createApp as insertApp } from "../src/core/apps.ts"; |
| 10 | 10 | import { createToken } from "../src/core/tokens.ts"; |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { readyNow, repoGraph } from "../src/core/dependencies.ts"; | |
| 12 | import type { Repo } from "../src/core/repos.ts"; | |
| 13 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import { inSlotOrder } from "../src/crew/autonomy.ts"; | |
| 12 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 13 | 16 | |
| 14 | 17 | describe("next_up over MCP", () => { |
| @@ -19,6 +22,7 @@ describe("next_up over MCP", () => { | ||
| 19 | 22 | let ro: string; |
| 20 | 23 | let id = 1; |
| 21 | 24 | const numbers: number[] = []; |
| 25 | let alice: User; | |
| 22 | 26 | |
| 23 | 27 | const tool = async (token: string, args: unknown) => { |
| 24 | 28 | const res = await fetch(`${servers.url}/mcp`, { |
| @@ -48,7 +52,7 @@ describe("next_up over MCP", () => { | ||
| 48 | 52 | adminIfFirst: true, |
| 49 | 53 | }); |
| 50 | 54 | if (!made.ok) throw new Error(made.error); |
| 51 | const alice = made.value; | |
| 55 | alice = made.value; | |
| 52 | 56 | const repo = await createRepo(app, alice, { name: "game", visibility: "public" }); |
| 53 | 57 | if (!repo.ok) throw new Error(String(repo.error)); |
| 54 | 58 | insertApp(app.db, { repoId: repo.value.id, slug: "game", access: "private" }); |
| @@ -81,4 +85,42 @@ describe("next_up over MCP", () => { | ||
| 81 | 85 | expect((await tool(rw, { app: "alice/game", issue: numbers[0], to: "sideways" })).error).toBeDefined(); |
| 82 | 86 | expect((await tool(rw, { app: "alice/game", issue: 9999, to: "top" })).error).toBeDefined(); |
| 83 | 87 | }); |
| 88 | ||
| 89 | /** A repository of its own, so this order touches no rank the tests above rely on. */ | |
| 90 | async function orderRepo(): Promise<{ repo: Repo; numbers: number[] }> { | |
| 91 | const made = await createRepo(app, alice, { name: "order", visibility: "public" }); | |
| 92 | if (!made.ok) throw new Error(String(made.error)); | |
| 93 | insertApp(app.db, { repoId: made.value.id, slug: "order", access: "private" }); | |
| 94 | const numbers: number[] = []; | |
| 95 | for (const title of ["One", "Two", "Three", "Four", "A test pins five"]) { | |
| 96 | const o = openIssue(app, alice, made.value, { title }); | |
| 97 | if (!o.ok) throw new Error(String(o.error)); | |
| 98 | numbers.push(o.value.number); | |
| 99 | } | |
| 100 | return { repo: made.value, numbers }; | |
| 101 | } | |
| 102 | ||
| 103 | const listed = async (): Promise<number[]> => { | |
| 104 | const answer = await tool(rw, { app: "alice/order", all: true }); | |
| 105 | return (answer.value.next as { issue: number }[]).map((r) => r.issue); | |
| 106 | }; | |
| 107 | ||
| 108 | const picked = (repo: Repo, slots: number): number[] => | |
| 109 | inSlotOrder(app, repo, readyNow(repoGraph(app.db, repo.id, { sealed: true })), [], slots).map((r) => r.issue.number); | |
| 110 | ||
| 111 | test("the list lists the order the picker starts in, ranked first and then the rest, and a ranked small issue survives a full small share", async () => { | |
| 112 | const { repo, numbers } = await orderRepo(); | |
| 113 | // Rank two (the fourth ends on top, the third below it); three stay unranked. | |
| 114 | await tool(rw, { app: "alice/order", issue: numbers[2], to: "top" }); | |
| 115 | await tool(rw, { app: "alice/order", issue: numbers[3], to: "top" }); | |
| 116 | const before = await listed(); | |
| 117 | expect(before.slice(0, 2)).toEqual([numbers[3]!, numbers[2]!]); | |
| 118 | expect(picked(repo, 5)).toEqual(before); | |
| 119 | // With no small slot at all, the unranked small issue is dropped; ranking it keeps it, in its place. | |
| 120 | expect(picked(repo, 0)).toEqual(before.filter((n) => n !== numbers[4])); | |
| 121 | await tool(rw, { app: "alice/order", issue: numbers[4], to: "top" }); | |
| 122 | const after = await listed(); | |
| 123 | expect(after[0]).toBe(numbers[4]!); | |
| 124 | expect(picked(repo, 0)).toEqual(after); | |
| 125 | }); | |
| 84 | 126 | }); |
test/next-up.test.ts +47 −5
| @@ -261,14 +261,56 @@ describe("the ranked list is bounded", () => { | ||
| 261 | 261 | }); |
| 262 | 262 | |
| 263 | 263 | describe("the picker's order", () => { |
| 264 | test("a ranked issue comes before raised and roadmap work, and small work at its cap is still taken when ranked", async () => { | |
| 264 | test("at the small cap a ranked small issue survives and keeps its place; an unranked one is left out", async () => { | |
| 265 | 265 | const { slotOrder } = await import("../src/crew/small_share.ts"); |
| 266 | 266 | const items = [ |
| 267 | { id: "roadmap", raised: false, roadmap: true, small: false }, | |
| 268 | { id: "raised", raised: true, roadmap: false, small: false }, | |
| 269 | { id: "ranked-small", ranked: true, raised: false, roadmap: false, small: true }, | |
| 267 | { id: "a", small: false }, | |
| 268 | { id: "ranked-small", ranked: true, small: true }, | |
| 269 | { id: "b", small: true }, | |
| 270 | 270 | ]; |
| 271 | 271 | const picks = slotOrder(items, (t) => t, 1, 1).map((t) => t.id); |
| 272 | expect(picks).toEqual(["ranked-small", "raised", "roadmap"]); | |
| 272 | expect(picks).toEqual(["a", "ranked-small"]); | |
| 273 | }); | |
| 274 | }); | |
| 275 | ||
| 276 | describe("the picker follows the page (#8826)", () => { | |
| 277 | test("with two ranked and three unranked issues, five ticks start the page's rows top to bottom", async () => { | |
| 278 | const { inSlotOrder } = await import("../src/crew/autonomy.ts"); | |
| 279 | const dir = await mkdtemp(join(tmpdir(), "forge-next-order-")); | |
| 280 | const app = createApp({ dataDir: dir, port: 0 }); | |
| 281 | try { | |
| 282 | const made = await createUser(app.db, { | |
| 283 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 284 | password: "a-good-passphrase", adminIfFirst: true, | |
| 285 | }); | |
| 286 | if (!made.ok) throw new Error(made.error); | |
| 287 | const alice = made.value; | |
| 288 | const r = await createRepo(app, alice, { name: "plan", visibility: "public" }); | |
| 289 | if (!r.ok) throw new Error(String(r.error)); | |
| 290 | const repo = r.value; | |
| 291 | const num: number[] = []; | |
| 292 | for (const t of ["one", "two", "three", "four", "five"]) { | |
| 293 | const o = openIssue(app, alice, repo, { title: `Work ${t}` }); | |
| 294 | if (!o.ok) throw new Error(String(o.error)); | |
| 295 | num.push(o.value.number); | |
| 296 | } | |
| 297 | // Rank the fifth, then the third above it; the rest stay unranked. | |
| 298 | expect(rankIssue(app, alice, repo, num[4]!, placeOf("top")!).ok).toBe(true); | |
| 299 | expect(rankIssue(app, alice, repo, num[2]!, placeOf("top")!).ok).toBe(true); | |
| 300 | const page = nextUp(app.db, repo).rows.map((x) => x.issue.number); | |
| 301 | expect(page.slice(0, 2)).toEqual([num[2]!, num[4]!]); | |
| 302 | const started: number[] = []; | |
| 303 | for (let tick = 0; tick < 5; tick++) { | |
| 304 | const ready = readyNow(repoGraph(app.db, repo.id, { sealed: true })).filter( | |
| 305 | (x) => !started.includes(x.issue.number), | |
| 306 | ); | |
| 307 | const next = inSlotOrder(app, repo, ready, [], 5)[0]!; | |
| 308 | started.push(next.issue.number); | |
| 309 | } | |
| 310 | expect(started).toEqual(page); | |
| 311 | } finally { | |
| 312 | app.db.close(); | |
| 313 | await rm(dir, { recursive: true, force: true }); | |
| 314 | } | |
| 273 | 315 | }); |
| 274 | 316 | }); |
new above
offline