Public: anyone can read this; the crew can change it.
A person's "this first" on a ready issue makes the dispatcher start it before the rest #7816
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7806-a-person-s-this-first-on-a-ready-issue-m → main 2 commits fork
8 files
changed +215
−5
src/actions/issues.ts +2
| @@ -68,6 +68,7 @@ import { | ||
| 68 | 68 | } from "../origins/import.ts"; |
| 69 | 69 | import { createLog } from "../kernel/log.ts"; |
| 70 | 70 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 71 | import { clearFirst } from "../core/first.ts"; | |
| 71 | 72 | import { inheritPillar } from "./pillars.ts"; |
| 72 | 73 | import { |
| 73 | 74 | requireParticipant, |
| @@ -471,6 +472,7 @@ export function setIssueState( | ||
| 471 | 472 | : []; |
| 472 | 473 | const changed = app.db.transaction(() => { |
| 473 | 474 | const written = writeState(app.db, issue.id, state, reason); |
| 475 | if (written && state === "closed") clearFirst(app.db, issue.id); | |
| 474 | 476 | if (written && doneBy.length > 0) |
| 475 | 477 | insertComment(app.db, { |
| 476 | 478 | issueId: issue.id, |
src/core/dependencies.ts +18 −4
| @@ -15,6 +15,7 @@ import { pillarsOf, structuralIds, type Pillars } from "./pillars.ts"; | ||
| 15 | 15 | import { getRepoById, type Repo } from "./repos.ts"; |
| 16 | 16 | import { unadoptedAmong } from "../crew/direct.ts"; |
| 17 | 17 | import { raisedAt, raisedInRepo } from "./thought_links.ts"; |
| 18 | import { firstAt, firstInRepo } from "./first.ts"; | |
| 18 | 19 | import { limbRanks, limbTree, readPlaces } from "./limb_order.ts"; |
| 19 | 20 | |
| 20 | 21 | export type DependencyError = "self" | "cross_repo" | "cycle" | "not_an_issue"; |
| @@ -249,6 +250,8 @@ export interface Graph { | ||
| 249 | 250 | * ready ahead of the rest, the latest raise first. |
| 250 | 251 | */ |
| 251 | 252 | readonly raised?: ReadonlyMap<string, string>; |
| 253 | /** When a writer put an issue first (#7806): ready ahead of everything else. */ | |
| 254 | readonly first?: ReadonlyMap<string, string>; | |
| 252 | 255 | } |
| 253 | 256 | |
| 254 | 257 | /** |
| @@ -449,6 +452,10 @@ export function repoGraph( | ||
| 449 | 452 | whole === undefined |
| 450 | 453 | ? raisedAt(db, issues.map((i) => i.id)) |
| 451 | 454 | : raisedInRepo(db, repoId), |
| 455 | first: | |
| 456 | whole === undefined | |
| 457 | ? firstAt(db, issues.map((i) => i.id)) | |
| 458 | : firstInRepo(db, repoId), | |
| 452 | 459 | issues, |
| 453 | 460 | unadopted: |
| 454 | 461 | repo === undefined ? new Set() : unadoptedAmong(db, repo, issues), |
| @@ -655,10 +662,17 @@ export function readyNow(graph: Graph): Ranked[] { | ||
| 655 | 662 | const of = (r: Ranked) => rank?.get(r.issue.id) ?? Number.MAX_SAFE_INTEGER; |
| 656 | 663 | if (rank !== undefined) ready.sort((a, b) => of(a) - of(b)); |
| 657 | 664 | const raised = graph.raised; |
| 658 | if (raised === undefined || raised.size === 0) return ready; | |
| 659 | // What the owner said again comes first (stable: the rest keep their order). | |
| 660 | const at = (r: Ranked) => raised.get(r.issue.id) ?? ""; | |
| 661 | return ready.sort((a, b) => at(b).localeCompare(at(a))); | |
| 665 | if (raised !== undefined && raised.size > 0) { | |
| 666 | // What the owner said again comes first (stable: the rest keep their order). | |
| 667 | const at = (r: Ranked) => raised.get(r.issue.id) ?? ""; | |
| 668 | ready.sort((a, b) => at(b).localeCompare(at(a))); | |
| 669 | } | |
| 670 | // What a person put first comes before all of it, the latest first. It only | |
| 671 | // orders what is already ready: no rank lifts the Later gate or a blocker. | |
| 672 | const first = graph.first; | |
| 673 | if (first === undefined || first.size === 0) return ready; | |
| 674 | const when = (r: Ranked) => first.get(r.issue.id) ?? ""; | |
| 675 | return ready.sort((a, b) => when(b).localeCompare(when(a))); | |
| 662 | 676 | } |
| 663 | 677 | |
| 664 | 678 | /** |
src/core/event_audience.ts +1
| @@ -36,6 +36,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([ | ||
| 36 | 36 | // the admins' (`repo.upstream_follow`). |
| 37 | 37 | "repo.following", |
| 38 | 38 | "issue.labeled", |
| 39 | "issue.first", | |
| 39 | 40 | "issue.closed", |
| 40 | 41 | "issue.commented", |
| 41 | 42 | "usage.delta", |
src/core/first.ts added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | /** | |
| 2 | * "This first" (#7806): a writer puts an open, ready issue ahead of the rest. | |
| 3 | * The dispatcher starts ranked issues before the others, the latest rank | |
| 4 | * first; a rank never lifts the Later gate or a blocker, because it only | |
| 5 | * orders what is already ready (`readyNow`). The rank is cleared when the | |
| 6 | * issue closes. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import type { App } from "../app.ts"; | |
| 11 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 12 | import { readyNow, repoGraph } from "./dependencies.ts"; | |
| 13 | import type { Issue } from "./issues.ts"; | |
| 14 | import { canWrite, type Repo } from "./repos.ts"; | |
| 15 | import type { User } from "./users.ts"; | |
| 16 | ||
| 17 | /** Put an issue first. `invalid` unless it is an open issue nothing stands in front of. */ | |
| 18 | export function putFirst( | |
| 19 | app: App, | |
| 20 | actor: User, | |
| 21 | repo: Repo, | |
| 22 | issue: Issue, | |
| 23 | ): Result<{ at: string }, "forbidden" | "not_found" | "invalid"> { | |
| 24 | if (issue.repoId !== repo.id || issue.kind !== "issue") return err("not_found"); | |
| 25 | if (actor.kind === "agent" || !canWrite(app.db, repo, actor)) return err("forbidden"); | |
| 26 | if (issue.state !== "open") return err("invalid"); | |
| 27 | const ready = readyNow(repoGraph(app.db, repo.id, { sealed: true })); | |
| 28 | if (!ready.some((r) => r.issue.id === issue.id)) return err("invalid"); | |
| 29 | const at = new Date().toISOString(); | |
| 30 | app.db | |
| 31 | .query( | |
| 32 | `INSERT INTO issue_first (issue_id, actor_id, at) VALUES (?1, ?2, ?3) | |
| 33 | ON CONFLICT(issue_id) DO UPDATE SET actor_id = excluded.actor_id, at = excluded.at`, | |
| 34 | ) | |
| 35 | .run(issue.id, actor.id, at); | |
| 36 | app.bus.append({ | |
| 37 | kind: "issue.first", | |
| 38 | repoId: repo.id, | |
| 39 | actorId: actor.id, | |
| 40 | payload: { number: issue.number }, | |
| 41 | }); | |
| 42 | return ok({ at }); | |
| 43 | } | |
| 44 | ||
| 45 | /** Forget the rank: the issue closed. */ | |
| 46 | export function clearFirst(db: Database, issueId: string): void { | |
| 47 | db.query("DELETE FROM issue_first WHERE issue_id = ?1").run(issueId); | |
| 48 | } | |
| 49 | ||
| 50 | /** When each of these issues was put first, for the ones that were. */ | |
| 51 | export function firstAt(db: Database, issueIds: readonly string[]): Map<string, string> { | |
| 52 | const out = new Map<string, string>(); | |
| 53 | for (let i = 0; i < issueIds.length; i += 500) { | |
| 54 | const batch = issueIds.slice(i, i + 500); | |
| 55 | for (const r of db | |
| 56 | .query<{ issue_id: string; at: string }, string[]>( | |
| 57 | `SELECT issue_id, at FROM issue_first WHERE issue_id IN (${batch.map(() => "?").join(",")})`, | |
| 58 | ) | |
| 59 | .all(...batch)) | |
| 60 | out.set(r.issue_id, r.at); | |
| 61 | } | |
| 62 | return out; | |
| 63 | } | |
| 64 | ||
| 65 | /** Every rank in one repository. */ | |
| 66 | export function firstInRepo(db: Database, repoId: string): Map<string, string> { | |
| 67 | const out = new Map<string, string>(); | |
| 68 | for (const r of db | |
| 69 | .query<{ issue_id: string; at: string }, [string]>( | |
| 70 | `SELECT f.issue_id, f.at FROM issue_first f JOIN issue i ON i.id = f.issue_id WHERE i.repo_id = ?1`, | |
| 71 | ) | |
| 72 | .all(repoId)) | |
| 73 | out.set(r.issue_id, r.at); | |
| 74 | return out; | |
| 75 | } |
src/core/migration-pins/20261101130000-issue-first.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 01c88a62c64765094ad1f20d5deb7616f40d3cb5993412e88c99b760ffa4f933 |
src/core/migrations/20261101130000-issue-first.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | // A person's "this first" (#7806): a writer put an open, ready issue ahead of | |
| 2 | // the rest. One row per issue; it is deleted when the issue closes. | |
| 3 | export default ` | |
| 4 | CREATE TABLE IF NOT EXISTS issue_first ( | |
| 5 | issue_id TEXT PRIMARY KEY REFERENCES issue(id) ON DELETE CASCADE, | |
| 6 | actor_id TEXT NOT NULL, | |
| 7 | at TEXT NOT NULL | |
| 8 | ); | |
| 9 | `; |
src/crew/autonomy.ts +7 −1
| @@ -45,6 +45,7 @@ import { | ||
| 45 | 45 | crewRole, |
| 46 | 46 | } from "../core/agents.ts"; |
| 47 | 47 | import { pickBuilder, routeIssue } from "./route.ts"; |
| 48 | import { firstAt } from "../core/first.ts"; | |
| 48 | 49 | import { ensurePlanner } from "./planner.ts"; |
| 49 | 50 | import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts"; |
| 50 | 51 | import { |
| @@ -376,11 +377,16 @@ function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: re | ||
| 376 | 377 | const issues = ready.map((r) => r.issue); |
| 377 | 378 | const small = smallIssues(app.db, issues); |
| 378 | 379 | const roadmap = roadmapIssues(app.db, repo, issues.map((i) => i.id)); |
| 380 | const first = firstAt(app.db, issues.map((i) => i.id)); | |
| 379 | 381 | const raised = raisedAt(app.db, issues.map((i) => i.id)); |
| 380 | 382 | const running = smallOfIds(app.db, slotted.flatMap((r) => (r.issueId === null ? [] : [r.issueId]))); |
| 381 | 383 | return slotOrder( |
| 382 | 384 | ready, |
| 383 | (r) => ({ raised: raised.has(r.issue.id), roadmap: roadmap.has(r.issue.id), small: small.has(r.issue.id) }), | |
| 385 | (r) => ({ | |
| 386 | raised: raised.has(r.issue.id) || first.has(r.issue.id), | |
| 387 | roadmap: roadmap.has(r.issue.id), | |
| 388 | small: small.has(r.issue.id) && !first.has(r.issue.id), | |
| 389 | }), | |
| 384 | 390 | running, |
| 385 | 391 | smallCap(slots, smallShare(app.db)), |
| 386 | 392 | ); |
test/map-first.test.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * "This first" (#7806): a writer's rank puts a ready issue ahead of the rest | |
| 3 | * for the dispatcher; it never lifts a blocker, and it ends when the issue closes. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, 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 { createApp, type App } from "../src/app.ts"; | |
| 10 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 11 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; | |
| 12 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 13 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 14 | import { addDependency, readyNow, repoGraph } from "../src/core/dependencies.ts"; | |
| 15 | import { firstAt, putFirst } from "../src/core/first.ts"; | |
| 16 | import { getIssueById, type Issue } from "../src/core/issues.ts"; | |
| 17 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 18 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 19 | import { driveRepo } from "../src/crew/autonomy.ts"; | |
| 20 | ||
| 21 | describe("this first", () => { | |
| 22 | let dataDir: string; | |
| 23 | let app: App; | |
| 24 | let alice: User; | |
| 25 | let repo: Repo; | |
| 26 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 27 | ||
| 28 | beforeAll(async () => { | |
| 29 | dataDir = await mkdtemp(join(tmpdir(), "forge-first-")); | |
| 30 | app = createApp({ dataDir, port: 0 }); | |
| 31 | const a = await createUser(app.db, { | |
| 32 | origin: "seed", | |
| 33 | trusted: true, | |
| 34 | username: "alice", | |
| 35 | email: "[email protected]", | |
| 36 | password: "a-good-passphrase", | |
| 37 | adminIfFirst: true, | |
| 38 | }); | |
| 39 | if (!a.ok) throw new Error(a.error); | |
| 40 | alice = a.value; | |
| 41 | const made = await createRepo(app, alice, { name: "plan", visibility: "private" }); | |
| 42 | if (!made.ok) throw new Error(String(made.error)); | |
| 43 | for (const agent of ["atlas", "mason"]) { | |
| 44 | const created = await createAgent(app, alice, { username: agent }); | |
| 45 | if (!created.ok) throw new Error(String(created.error)); | |
| 46 | expect(addToCrew(app, alice, made.value, agent).ok).toBe(true); | |
| 47 | } | |
| 48 | setCrewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 49 | const set = await updateRepoSettings(app, alice, made.value, { | |
| 50 | autonomy: "autonomous", | |
| 51 | autonomyDirection: "Keep the engine small.", | |
| 52 | autonomyCeilingUsd: 100, | |
| 53 | autonomySlots: 1, | |
| 54 | autonomyBy: alice.id, | |
| 55 | }); | |
| 56 | if (!set.ok) throw new Error(JSON.stringify(set.error)); | |
| 57 | repo = getRepoById(app.db, made.value.id)!; | |
| 58 | }); | |
| 59 | ||
| 60 | afterAll(async () => { | |
| 61 | app.close(); | |
| 62 | await rm(dataDir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | const make = (title: string): Issue => { | |
| 66 | const opened = openIssue(app, alice, fresh(), { title }); | |
| 67 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 68 | return opened.value; | |
| 69 | }; | |
| 70 | const readyNumbers = () => readyNow(repoGraph(app.db, repo.id)).map((r) => r.issue.number); | |
| 71 | ||
| 72 | test("with A and B ready, ranking B makes autonomy assign B next", async () => { | |
| 73 | const a = make("Alpha work"); | |
| 74 | const b = make("Beta work"); | |
| 75 | expect(readyNumbers()).toEqual([a.number, b.number]); | |
| 76 | const ranked = putFirst(app, alice, fresh(), b); | |
| 77 | expect(ranked.ok).toBe(true); | |
| 78 | expect(readyNumbers()).toEqual([b.number, a.number]); | |
| 79 | expect(await driveRepo(app, fresh())).toBe("picked"); | |
| 80 | expect(getIssueById(app.db, b.id)!.assigneeId).toBe(getUserByName(app.db, "mason")!.id); | |
| 81 | expect(getIssueById(app.db, a.id)!.assigneeId).toBeNull(); | |
| 82 | const event = app.db | |
| 83 | .query<{ actor_id: string }, []>("SELECT actor_id FROM event WHERE kind = 'issue.first'") | |
| 84 | .get(); | |
| 85 | expect(event?.actor_id).toBe(alice.id); | |
| 86 | }); | |
| 87 | ||
| 88 | test("a blocked, closed or agent-ranked issue is refused, and closing clears the rank", () => { | |
| 89 | const blocker = make("Blocker"); | |
| 90 | const waiter = make("Waiter"); | |
| 91 | addDependency(app.db, waiter, blocker, alice.id); | |
| 92 | expect(putFirst(app, alice, fresh(), waiter)).toEqual({ ok: false, error: "invalid" }); | |
| 93 | expect(readyNumbers()).not.toContain(waiter.number); | |
| 94 | const mason = getUserByName(app.db, "mason")!; | |
| 95 | expect(putFirst(app, mason, fresh(), blocker)).toEqual({ ok: false, error: "forbidden" }); | |
| 96 | expect(putFirst(app, alice, fresh(), blocker).ok).toBe(true); | |
| 97 | expect(firstAt(app.db, [blocker.id]).size).toBe(1); | |
| 98 | expect(setIssueState(app, alice, fresh(), getIssueById(app.db, blocker.id)!, "closed").ok).toBe(true); | |
| 99 | expect(firstAt(app.db, [blocker.id]).size).toBe(0); | |
| 100 | expect(putFirst(app, alice, fresh(), getIssueById(app.db, blocker.id)!)).toEqual({ ok: false, error: "invalid" }); | |
| 101 | }); | |
| 102 | }); |
new above
offline