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

A from-app issue is assigned on arrival, and the flow alarm fires when one waits #10612

Merged opened this

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

Merged

mason/forge:mason/10563-a-from-app-issue-is-assigned-on-arrival → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/10563-a-from-app-issue-is-assigned-on-arrival was merged into main as 5fdc942.

Review

Sign in to review this change.
7 files changed +300 −5
docs/features/from-app-alarm.md added +18
@@ -0,0 +1,18 @@
1 # The from_app alarm (#10563)
2
3 A note a signed-in person files from an app becomes a `from-app` issue, assigned on arrival to a free builder of the repo's crew. With every builder busy, or the assignment refused, it stays unassigned. A stranger's note is only a suggestion and is never counted here; hidden notes are left out.
4
5 ## When the alarm fires
6
7 The `from_app` flow alarm (`src/flow/alarm_from_app.ts`) fires when an open person's note has:
8
9 - sat **unassigned for 15 minutes**, or
10 - had **no crew comment for 1 hour** (counted from arrival until the crew first speaks, then from the last crew comment).
11
12 It names the issue that has waited longest past its bound. Both doors show it: the Home and `/boards` pages, and the TUI flow screen, which draws every alarm `firedAlarms` returns. `GET /api/v1/flow` lists it too.
13
14 ## The planner hold
15
16 While a person's note in a repository has had no crew comment, that repository's **planner** (the agent with the planner seat) is refused `openPlan` with `from_app_waiting`, naming the issue. Builders, reviewers and people are not held.
17
18 The hold lifts as soon as any crew member comments on the note, or the issue is closed, or the note is hidden. A builder that never comments keeps it held; the alarm fires at the hour mark so a person sees it and can comment, reassign or close the issue.
src/actions/app_notes.ts +20 −3
@@ -9,18 +9,19 @@ import { dropHiddenPictures } from "../core/note_pictures.ts";
99 import type { App } from "../app.ts";
1010 import { banVisitor, bannedNotes, unbanVisitor, markSender, getNote, hideNote, insertNote, isBanned, notesOf, notesOfApp, type AppNote } from "../core/app_notes.ts";
1111 import type { AppRecord } from "../core/apps.ts";
12 import { createLabel, getIssue, getLabel } from "../core/issues.ts";
12 import { createLabel, getIssue, getLabel, type Issue } from "../core/issues.ts";
1313 import { mergedPullClosing } from "../core/pulls.ts";
1414 import { releaseName, releaseOfPull } from "../release/record.ts";
1515 import { RateLimiter } from "../core/ratelimit.ts";
1616 import { canAdmin, type Repo } from "../core/repos.ts";
1717 import { plainText } from "../core/reports.ts";
18 import { workingAgentRuns } from "../core/runs.ts";
18 import { listActiveRuns, workingAgentRuns } from "../core/runs.ts";
1919 import { getUserById, isActive, type User } from "../core/users.ts";
20 import { crewWithRole } from "../core/agents.ts";
2021 import { unadoptedAmong } from "../crew/direct.ts";
2122 import { NOTE_EMPTY } from "../apps/note_words.ts";
outdated

Second import from ../core/runs.ts; merge with workingAgentRuns on line 19.

2223 import { err, ok, type Result } from "../kernel/result.ts";
23 import { openIssueThrough, setIssueLabelsThrough, setIssueStateThrough } from "./issues.ts";
24 import { assignIssue, openIssueThrough, setIssueLabelsThrough, setIssueStateThrough } from "./issues.ts";
2425
2526 export { FROM_APP_LABEL } from "../core/from_app.ts";
2627 import { FROM_APP_LABEL } from "../core/from_app.ts";
@@ -241,9 +242,25 @@ export async function sendNote(
241242 visitor: sender.key,
242243 name: sender.name,
243244 });
245 if (sender.person) assignOnArrival(app, owner, repo, opened.value);
244246 return ok(view(app, record, repo, forgeUrl, note)!);
245247 }
246248
249 /**
250 * A person's note goes to a builder when it is filed (#10563): the first of
251 * the repo's builders with no run going, in name order, so it does not wait
252 * for the planner. With every builder busy, or a refusal (an unverified owner,
253 * an archived repo), it stays unassigned and the flow alarm names it at 15
254 * minutes.
255 */
256 function assignOnArrival(app: App, owner: User, repo: Repo, issue: Issue): void {
257 const busy = new Set(listActiveRuns(app.db, [repo.id]).flatMap((r) => (r.actorId === null ? [] : [r.actorId])));
258 const free = (crewWithRole(app.db, [repo.id], "builder").get(repo.id) ?? []).find((a) => !busy.has(a.user.id));
259 if (free === undefined) return;
260 const done = assignIssue(app, owner, repo, issue, free.user);
261 if (!done.ok) console.error(`app note: issue #${issue.number} could not be assigned to ${free.user.username}: ${done.error}`);
262 }
263
247264 /** The owner's: hide a note (closing its issue), or ban its sender too. */
248265 export async function moderateNote(
249266 app: App,
src/actions/plan.ts +13 −1
@@ -12,7 +12,7 @@
1212
1313 import { sealedScope } from "../core/sealed.ts";
1414 import type { App } from "../app.ts";
15 import { isCrew } from "../core/agents.ts";
15 import { crewRole, isCrew } from "../core/agents.ts";
1616 import { mapHeldFor, type HoldName } from "../crew/held.ts";
1717 import { addDependency, repoGraph, readyNow } from "../core/dependencies.ts";
1818 import { findPillar, pillarsOf, type Pillar } from "../core/pillars.ts";
@@ -23,6 +23,7 @@ import {
2323 MAX_TITLE,
2424 type Issue,
2525 } from "../core/issues.ts";
26 import { unansweredFromApp } from "../core/from_app.ts";
2627 import { canRead, canWrite, type Repo } from "../core/repos.ts";
2728 import type { User } from "../core/users.ts";
2829 import { transact } from "../kernel/db.ts";
@@ -227,6 +228,17 @@ function vet(
227228 "this repository is mirrored; plan at its origin",
228229 );
229230
231 // A person's note from an app is owed an answer before the planner adds to the pile (#10563).
232 // Only the repo's planner is held; it lifts when a crew member comments, or the issue is closed or hidden.
233 if (actor.kind === "agent" && crewRole(app.db, repo.id, actor.id) === "planner") {
outdated

Holds all agents, not just the planner; narrow it or document it, and test that a person's plan is not held.

234 const waiting = unansweredFromApp(app.db, repo.id);
235 if (waiting !== undefined)
236 return refuse(
237 "from_app_waiting",
238 `#${waiting.number} was sent by a person from the app and no crew member has answered it: the planner opens no new issues until one does`,
239 );
240 }
241
230242 const keys = new Set(items.map((i) => i.key));
231243 const existing = new Map<number, Issue>();
232244 for (const item of items)
src/core/from_app.ts +54
@@ -23,3 +23,57 @@ export function fromAppIssues(
2323 out.add(id);
2424 return out;
2525 }
26
27 /** A `from-app` issue a person with an account sent that is still open. */
28 export interface WaitingFromApp {
29 readonly repoId: string;
30 /** `owner/name`. */
31 readonly repo: string;
32 readonly number: number;
33 readonly createdAt: string;
34 readonly assigned: boolean;
35 /** The newest comment by a crew member, or null when none has spoken. */
36 readonly lastCrewAt: string | null;
37 }
38
39 /**
40 * The open `from-app` issues sent by a person with an account (the note's
41 * sender key starts `u:`), oldest first, hidden notes left out. A stranger's
42 * note stays a suggestion until a writer adopts it (`crew/direct.ts`); only a
43 * person's is owed an answer. `repoId` narrows to one repository.
44 */
45 export function waitingFromApp(db: Database, repoId?: string): WaitingFromApp[] {
46 const rows = db
47 .query<
48 { repo_id: string; repo: string; number: number; created_at: string; assignee_id: string | null; last_crew: string | null },
49 [string | null, string]
50 >(
51 `SELECT i.repo_id, o.username || '/' || r.name AS repo, i.number, i.created_at, i.assignee_id,
52 (SELECT MAX(c.created_at) FROM comment c JOIN user u ON u.id = c.author_id
53 WHERE c.issue_id = i.id AND u.is_agent = 1) AS last_crew
54 FROM issue i
55 JOIN repo r ON r.id = i.repo_id
56 JOIN user o ON o.id = r.owner_id
57 JOIN app_note n ON n.repo_id = i.repo_id AND n.issue_number = i.number
58 JOIN issue_label il ON il.issue_id = i.id
59 JOIN label l ON l.id = il.label_id AND l.name = ?2 COLLATE NOCASE
60 WHERE i.kind = 'issue' AND i.state = 'open' AND n.hidden = 0 AND n.visitor LIKE 'u:%'
61 AND (?1 IS NULL OR i.repo_id = ?1)

Do not splice the label constant into SQL; bind it as a parameter.

62 GROUP BY i.id
63 ORDER BY i.created_at, i.id`,
64 )
65 .all(repoId ?? null, FROM_APP_LABEL);
66 return rows.map((r) => ({
67 repoId: r.repo_id,
68 repo: r.repo,
69 number: r.number,
70 createdAt: r.created_at,
71 assigned: r.assignee_id !== null,
72 lastCrewAt: r.last_crew,
73 }));
74 }
75
76 /** Of a repository's waiting `from-app` issues, the oldest no crew member has answered; none when all were. */
77 export function unansweredFromApp(db: Database, repoId: string): WaitingFromApp | undefined {
78 return waitingFromApp(db, repoId).find((w) => w.lastCrewAt === null);
79 }
src/flow/alarm_from_app.ts added +46
@@ -0,0 +1,46 @@
1 /**
2 * The "person waiting" flow alarm (#10563): an issue a person sent from an
3 * app that has sat unassigned for 15 minutes, or has had no crew comment for
4 * an hour (counted from its arrival until the crew first speaks). It names
5 * the issue, the one that has waited longest past its bound. Home and
6 * /boards draw it.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { waitingFromApp } from "../core/from_app.ts";
11
12 const MINUTE_MS = 60_000;
13 export const UNASSIGNED_AFTER_MS = 15 * MINUTE_MS;
14 export const UNANSWERED_AFTER_MS = 60 * MINUTE_MS;
15
16 export interface FromAppAlarm {
17 readonly fired: boolean;
18 readonly since?: string;
19 readonly repo?: string;
20 readonly number?: number;
21 readonly says: string;
22 }
23
24 export function alarmFromApp(db: Database, now: Date): FromAppAlarm {
25 let worst: { since: number; repo: string; number: number; why: string } | undefined;
26 for (const w of waitingFromApp(db)) {
27 const born = Date.parse(w.createdAt);
28 const spoke = w.lastCrewAt === null ? born : Date.parse(w.lastCrewAt);
29 const breaches: { since: number; why: string }[] = [];
30 if (!w.assigned && now.getTime() - born >= UNASSIGNED_AFTER_MS)
31 breaches.push({ since: born + UNASSIGNED_AFTER_MS, why: "sat unassigned for 15 minutes" });
32 if (now.getTime() - spoke >= UNANSWERED_AFTER_MS)
33 breaches.push({ since: spoke + UNANSWERED_AFTER_MS, why: "had no crew comment for an hour" });
34 const first = breaches.sort((a, b) => a.since - b.since)[0];
35 if (first !== undefined && (worst === undefined || first.since < worst.since))
36 worst = { ...first, repo: w.repo, number: w.number };
37 }
38 if (worst === undefined) return { fired: false, says: "No person's note from an app is waiting." };
39 return {
40 fired: true,
41 since: new Date(worst.since).toISOString(),
42 repo: worst.repo,
43 number: worst.number,
44 says: `${worst.repo}#${worst.number}, sent by a person from an app, has ${worst.why}.`,
45 };
46 }
src/flow/alarms.ts +4 −1
@@ -12,6 +12,7 @@ import { redHref, redMain } from "../checks/red_main.ts";
1212 import { getRepo } from "../core/repos.ts";
1313 import { ownRepoPath } from "../kernel/own_repo.ts";
1414 import { bugClockAlarm } from "./bugs.ts";
15 import { alarmFromApp } from "./alarm_from_app.ts";
1516 import { alarmIdleCrew } from "./alarm_idle_crew.ts";
1617 import { noReleaseAlarm } from "./alarm_no_release.ts";
1718 import { redMainAlarm } from "./alarm_red_main.ts";
@@ -20,7 +21,7 @@ import { alarmSilent } from "./alarm_silent.ts";
2021 import { alarmStuck } from "./alarm_stuck.ts";
2122
2223 export interface FiredAlarm {
23 readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "bug_clock" | "silent" | "repeat" | "unreachable";
24 readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "bug_clock" | "silent" | "repeat" | "from_app" | "unreachable";
2425 readonly says: string;
2526 readonly since: string;
2627 /** A same-origin path to the item the alarm names. */
@@ -55,6 +56,8 @@ export function firedAlarms(
5556 if (silent.fired) out.push({ key: "silent", says: silent.says, since: silent.since!, href: `/${silent.repo}/runs/${silent.runId}` });
5657 const repeat = alarmRepeat(db);
5758 if (repeat.fired) out.push({ key: "repeat", says: repeat.says, since: repeat.since!, href: `/${repeat.repo}/issues/${repeat.number}` });
59 const fromApp = alarmFromApp(db, now);
60 if (fromApp.fired) out.push({ key: "from_app", says: fromApp.says, since: fromApp.since!, href: `/${fromApp.repo}/issues/${fromApp.number}` });
5861 const idle = alarmIdleCrew(db, now, { ceilingUsd: opts.ceilingUsd ?? null });
5962 if (idle.fired) out.push({ key: "idle_crew", says: idle.says, since: idle.since!, href: "/boards" });
6063 }
test/alarm_from_app.test.ts added +145
@@ -0,0 +1,145 @@
1 /**
2 * A note from a person is assigned to a builder on arrival, a flow alarm
3 * names one that waits, and the planner opens nothing while one is
4 * unanswered (#10563).
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { sendNote, type Sender } from "../src/actions/app_notes.ts";
12 import { newApp } from "../src/actions/apps.ts";
13 import { openPlan } from "../src/actions/plan.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { addCrew, createAgentRow } from "../src/core/agents.ts";
16 import { getAppBySlug } from "../src/core/apps.ts";
17 import { hideNote, notesOfApp } from "../src/core/app_notes.ts";
18 import { createRun } from "../src/core/runs.ts";
19 import { addComment, getIssue } from "../src/core/issues.ts";
20 import { getRepo, type Repo } from "../src/core/repos.ts";
21 import { createUser, type User } from "../src/core/users.ts";
22 import { alarmFromApp } from "../src/flow/alarm_from_app.ts";
23 import { firedAlarms } from "../src/flow/alarms.ts";
24 import { quietLogs } from "./helpers/globals.ts";
25
26 quietLogs();
27
28 const input = { text: "the button is too small", page: "", viewport: "390x844" };
29 const MIN = 60_000;
30
31 describe("a person's note from an app", () => {
32 let dataDir = "";
33 let app: App;
34 let owner: User;
35 let builder: User;
36 let planner: User;
37 let repo: Repo;
38 let seq = 0;
39 const person = (tag = ""): Sender => ({ key: `u:${owner.id}${tag}`, name: owner.username, person: true });
40 const stranger: Sender = { key: "v:abc", name: "visitor-abc", person: false };
41
42 const send = async (who: Sender) => {
43 const record = getAppBySlug(app.db, "shop")!;
44 const sent = await sendNote(app, record, repo, "production", undefined, who, input, `10.8.0.${++seq}`);
45 if (!sent.ok) throw new Error(sent.error.message);
46 return app.db.query<{ number: number }, [string]>("SELECT MAX(number) AS number FROM issue WHERE repo_id = ?1").get(repo.id)!.number;
47 };
48 const close = (n: number) => app.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1 AND number = ?2").run(repo.id, n);
49
50 beforeAll(async () => {
51 dataDir = await mkdtemp(join(tmpdir(), "forge-alarm-from-app-"));
52 app = createApp({ dataDir, port: 0, host: "forge.test" });
53 const mk = async (username: string, agent: boolean) => {
54 const made = await createUser(app.db, {
55 username,
56 email: `${username}@forge.test`,
57 ...(agent ? { agent: true, origin: "agent" as const } : { password: "a-good-passphrase", origin: "seed" as const, trusted: true, adminIfFirst: true }),
58 });
59 if (!made.ok) throw new Error(String(made.error));
60 if (agent) createAgentRow(app.db, { userId: made.value.id, roles: [username === "plan" ? "planner" : "builder"] });
61 return made.value;
62 };
63 owner = await mk("alice", false);
64 builder = await mk("build", true);
65 planner = await mk("plan", true);
66 const made = await newApp(app, owner, { name: "shop", description: "Words", visibility: "public" });
67 if (!made.ok) throw new Error(String(made.error));
68 repo = getRepo(app.db, "alice", "shop")!;
69 addCrew(app.db, repo.id, builder.id, owner.id, "builder");
70 addCrew(app.db, repo.id, planner.id, owner.id, "planner");
71 });
72
73 afterAll(async () => {
74 app.close();
75 await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
76 });
77
78 test("is assigned to a builder when it is filed; a stranger's is not", async () => {
79 const n = await send(person());
80 expect(getIssue(app.db, repo.id, n)!.assigneeName).toBe("build");
81 const s = await send(stranger);
82 expect(getIssue(app.db, repo.id, s)!.assigneeName).toBeNull();
83 close(n);
84 close(s);
85 });
86
87 test("the alarm fires at 15 minutes unassigned, or an hour without a crew comment, and names the issue", async () => {
88 const n = await send(person());
89 app.db.query("UPDATE issue SET assignee_id = NULL WHERE repo_id = ?1 AND number = ?2").run(repo.id, n);
90 const at = Date.now();
91 expect(alarmFromApp(app.db, new Date(at + 14 * MIN)).fired).toBe(false);
92 const unassigned = alarmFromApp(app.db, new Date(at + 16 * MIN));
93 expect(unassigned.fired).toBe(true);
94 expect(unassigned.says).toContain(`alice/shop#${n}`);
95 expect(unassigned.says).toContain("unassigned");
96 expect(firedAlarms(app.db, new Date(at + 16 * MIN)).map((a) => a.key)).toContain("from_app");
97 // Assigned: quiet until the hour.
98 app.db.query("UPDATE issue SET assignee_id = ?3 WHERE repo_id = ?1 AND number = ?2").run(repo.id, n, builder.id);
99 expect(alarmFromApp(app.db, new Date(at + 59 * MIN)).fired).toBe(false);
100 const silent = alarmFromApp(app.db, new Date(at + 61 * MIN));
101 expect(silent.fired).toBe(true);
102 expect(silent.says).toContain("no crew comment");
103 // A crew comment restarts the hour.
104 addComment(app.db, { issueId: getIssue(app.db, repo.id, n)!.id, authorId: builder.id, body: "on it" });
105 expect(alarmFromApp(app.db, new Date(Date.now() + 30 * MIN)).fired).toBe(false);
106 expect(alarmFromApp(app.db, new Date(Date.now() + 61 * MIN)).fired).toBe(true);
107 close(n);
108 expect(alarmFromApp(app.db, new Date(Date.now() + 5 * 60 * MIN)).fired).toBe(false);
109 });
110
111 test("the planner opens no issues in the repo while one is unanswered, and says why", async () => {
112 const items = [{ key: "a", title: "Plan item", body: "x", blockedBy: [] }];
113 const n = await send(person());
114 const refused = openPlan(app, planner, repo, items);
115 expect(refused.ok).toBe(false);
116 if (!refused.ok) {
117 expect(refused.error.error).toBe("from_app_waiting");
118 expect(refused.error.message).toContain(`#${n}`);
119 }
120 // A person's plan is not held, nor is a builder's; the crew answering lifts the planner's.
121 expect(openPlan(app, owner, repo, [{ key: "p", title: "Person item", body: "x", blockedBy: [] }]).ok).toBe(true);
122 const asBuilder = openPlan(app, builder, repo, [{ key: "b", title: "Builder item", body: "x", blockedBy: [] }]);
123 expect(asBuilder.ok).toBe(true);
124 addComment(app.db, { issueId: getIssue(app.db, repo.id, n)!.id, authorId: builder.id, body: "looking" });
125 expect(openPlan(app, planner, repo, items).ok).toBe(true);
126 });
127
128 test("with every builder busy the note stays unassigned", async () => {
129 const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: builder.id, reason: "test" });
130 const n = await send(person("-b"));
131 expect(getIssue(app.db, repo.id, n)!.assigneeName).toBeNull();
132 app.db.query("UPDATE run SET state = 'ok' WHERE id = ?1").run(run.id);
133 close(n);
134 });
135
136 test("a hidden note fires no alarm and holds no planner", async () => {
137 const n = await send(person("-c"));
138 const record = getAppBySlug(app.db, "shop")!;
139 for (const note of notesOfApp(app.db, record.id)) hideNote(app.db, note.id);
140 app.db.query("UPDATE issue SET assignee_id = NULL WHERE repo_id = ?1 AND number = ?2").run(repo.id, n);
141 expect(alarmFromApp(app.db, new Date(Date.now() + 5 * 60 * MIN)).fired).toBe(false);
142 expect(openPlan(app, planner, repo, [{ key: "h", title: "After hidden", body: "x", blockedBy: [] }]).ok).toBe(true);
143 close(n);
144 });
145 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs