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

Merged opened this

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

Merged

scout/forge:scout/7806-a-person-s-this-first-on-a-ready-issue-m → main 2 commits fork

  • Merged. scout/7806-a-person-s-this-first-on-a-ready-issue-m was merged into main as 567bcc1.

Review

Sign in to review this change.
8 files changed +215 −5
src/actions/issues.ts +2
@@ -68,6 +68,7 @@ import {
6868 } from "../origins/import.ts";
6969 import { createLog } from "../kernel/log.ts";
7070 import { err, ok, type Result } from "../kernel/result.ts";
71 import { clearFirst } from "../core/first.ts";
7172 import { inheritPillar } from "./pillars.ts";
7273 import {
7374 requireParticipant,
@@ -471,6 +472,7 @@ export function setIssueState(
471472 : [];
472473 const changed = app.db.transaction(() => {
473474 const written = writeState(app.db, issue.id, state, reason);
475 if (written && state === "closed") clearFirst(app.db, issue.id);
474476 if (written && doneBy.length > 0)
475477 insertComment(app.db, {
476478 issueId: issue.id,
src/core/dependencies.ts +18 −4
@@ -15,6 +15,7 @@ import { pillarsOf, structuralIds, type Pillars } from "./pillars.ts";
1515 import { getRepoById, type Repo } from "./repos.ts";
1616 import { unadoptedAmong } from "../crew/direct.ts";
1717 import { raisedAt, raisedInRepo } from "./thought_links.ts";
18 import { firstAt, firstInRepo } from "./first.ts";
1819 import { limbRanks, limbTree, readPlaces } from "./limb_order.ts";
1920
2021 export type DependencyError = "self" | "cross_repo" | "cycle" | "not_an_issue";
@@ -249,6 +250,8 @@ export interface Graph {
249250 * ready ahead of the rest, the latest raise first.
250251 */
251252 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>;
252255 }
253256
254257 /**
@@ -449,6 +452,10 @@ export function repoGraph(
449452 whole === undefined
450453 ? raisedAt(db, issues.map((i) => i.id))
451454 : raisedInRepo(db, repoId),
455 first:
456 whole === undefined
457 ? firstAt(db, issues.map((i) => i.id))
458 : firstInRepo(db, repoId),
452459 issues,
453460 unadopted:
454461 repo === undefined ? new Set() : unadoptedAmong(db, repo, issues),
@@ -655,10 +662,17 @@ export function readyNow(graph: Graph): Ranked[] {
655662 const of = (r: Ranked) => rank?.get(r.issue.id) ?? Number.MAX_SAFE_INTEGER;
656663 if (rank !== undefined) ready.sort((a, b) => of(a) - of(b));
657664 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)));
662676 }
663677
664678 /**
src/core/event_audience.ts +1
@@ -36,6 +36,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([
3636 // the admins' (`repo.upstream_follow`).
3737 "repo.following",
3838 "issue.labeled",
39 "issue.first",
3940 "issue.closed",
4041 "issue.commented",
4142 "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 {
4545 crewRole,
4646 } from "../core/agents.ts";
4747 import { pickBuilder, routeIssue } from "./route.ts";
48 import { firstAt } from "../core/first.ts";
4849 import { ensurePlanner } from "./planner.ts";
4950 import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts";
5051 import {
@@ -376,11 +377,16 @@ function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: re
376377 const issues = ready.map((r) => r.issue);
377378 const small = smallIssues(app.db, issues);
378379 const roadmap = roadmapIssues(app.db, repo, issues.map((i) => i.id));
380 const first = firstAt(app.db, issues.map((i) => i.id));
379381 const raised = raisedAt(app.db, issues.map((i) => i.id));
380382 const running = smallOfIds(app.db, slotted.flatMap((r) => (r.issueId === null ? [] : [r.issueId])));
381383 return slotOrder(
382384 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 }),
384390 running,
385391 smallCap(slots, smallShare(app.db)),
386392 );
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs