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

The sentinel closes its own shared-red issue when the test passes again with no fix, instead of handing it to a builder #8346

Merged opened this

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

Merged

scout/forge:scout/8287-the-sentinel-closes-its-own-shared-red-i → main 2 commits fork

  • Merged. scout/8287-the-sentinel-closes-its-own-shared-red-i was merged into main as cbf9cbf.

Review

Sign in to review this change.
2 files changed +350 −11
src/crew/sentinel.ts +176 −11
@@ -20,8 +20,13 @@ import type { App } from "../app.ts";
2020 import { addComment, openIssue, setIssueState } from "../actions/issues.ts";
2121 import { decisionsSince } from "../core/decisions.ts";
2222 import { getAgentByName } from "../core/agents.ts";
23 import { getIssueById, getLabel, setIssueLabels as writeLabels, type Issue } from "../core/issues.ts";
24 import { getRepo, type Repo } from "../core/repos.ts";
23 import {
24 getIssueById,
25 getLabel,
26 setIssueLabels as writeLabels,
27 type Issue,
28 } from "../core/issues.ts";
29 import { canWrite, getRepo, type Repo } from "../core/repos.ts";
2530 import { ownRepoPath } from "../kernel/own_repo.ts";
2631 import { leaseLapsed, listRunning } from "../core/runs.ts";
2732 import { migrations } from "../core/schema.ts";
@@ -733,6 +738,164 @@ function sinceFix(app: App, repo: Repo, actor: User, f: Finding, evidence: strin
733738 return 1;
734739 }
735740
741 /** Whether a person, not an agent, has commented on the issue: then they decide, not the sentinel. */
742 function personSpoke(db: Database, issueId: string): boolean {
743 const spoke = db
744 .query<{ n: number }, [string]>(
745 `SELECT COUNT(*) AS n FROM comment c JOIN user u ON u.id = c.author_id
746 WHERE c.issue_id = ?1 AND u.is_agent = 0 AND u.kind = 'user'`,
747 )
748 .get(issueId);
749 return spoke !== null && spoke.n > 0;
750 }
751
752 /** The test a shared finding names, read back from its evidence. */
753 const testOf = (evidence: readonly string[]): string | undefined =>
754 evidence.find((e) => e.startsWith("test: "))?.slice(6);
755
756 /** A finding's test, or, once its runs left the hour's window, the one its last record named. */
757 function testFor(db: Database, f: Finding): string | undefined {
758 const named = testOf(f.evidence);
759 if (named !== undefined || f.cause?.startsWith("shared:") !== true) return named;
760 const last = db
761 .query<{ evidence: string }, [string]>(
762 "SELECT evidence FROM sentinel_finding WHERE key = ?1 ORDER BY last_seen DESC LIMIT 1",
763 )
764 .get(f.key);
765 return last === null ? undefined : testOf(last.evidence.split("\n"));
766 }
767
768 interface Settled {
769 readonly at: number;
770 readonly evidence: readonly string[];
771 }
772
773 /**
774 * A shared red that ended by itself (#8287): its span has closed, no pull
775 * failed the test on a check created since, and main's newest check, created
776 * after the span closed, passes it: a main check from before the red ended
777 * says nothing about it. Undefined while any of those does not hold, or the
778 * test is unknown.
779 */
780 function settled(db: Database, cause: string, test: string | undefined, now: number): Settled | undefined {
781 if (!cause.startsWith("shared:") || test === undefined) return undefined;
782 let at = -Infinity;
783 for (const s of spans(db, now - DAY_MS, now)) {
784 if (s.cause !== cause) continue;
785 if (s.closedAt === null) return undefined;
786 at = Math.max(at, s.closedAt);
787 }
788 if (at === -Infinity) return undefined;
789 const pulls = pullsFailedSince(db, cause, at);
790 if (pulls > 0) return undefined;
791 const main = mainVerdict(db, cause.split(":")[1] ?? "", test);
792 if (main === undefined || !main.passes) return undefined;
793 const created = db.query<{ created_at: string }, [string]>("SELECT created_at FROM run WHERE id = ?1").get(main.runId);
794 if (created === null || !(Date.parse(created.created_at) > at)) return undefined;
795 return {
796 at,
797 evidence: [
798 `cause: ${cause}`,
799 `test: ${test}`,
800 `span closed: ${new Date(at).toISOString()}`,
801 `pulls failed it on checks created since: ${pulls}`,
802 `main: passes on main's newest check, run ${main.runId} at ${main.commitOid}, created ${created.created_at}`,
803 ],
804 };
805 }
806
807 /**
808 * A shared red that passed again on its own closes its own open issue as
809 * dropped, before a builder is handed it and finds nothing to fix (#8287).
810 * Not one a person has spoken on: they decide. If the cause opens again,
811 * the next hunt files it fresh and quotes this one. A sentinel that may not
812 * close issues here closes nothing and says so: no access check is skipped.
813 *
814 * It runs on every `sentinelTick`, not only in the hourly `hunt`, because a
815 * builder may take an open issue on any tick; `hunt` runs it too, first, so
816 * a hunt never comments on an issue it is about to close.
817 */
818 function settleShared(app: App, repo: Repo, actor: User, now: number): void {
819 const open = app.db
820 .query<{ key: string; issue_id: string; evidence: string }, []>(
821 `SELECT t.key, t.issue_id, t.evidence FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id
822 WHERE i.state = 'open' AND t.key LIKE 'degraded:shared-%'`,
823 )
824 .all();
825 if (open.length === 0) return;
826 if (!canWrite(app.db, repo, actor)) {
827 log.warn("sentinel may not close issues here; a settled shared red stays open", { repo: repo.id });
828 return;
829 }
830 const causes = new Map(spans(app.db, now - DAY_MS, now).map((s) => [`degraded:${keyPart(s.cause)}`, s.cause]));
831 for (const t of open) {
832 try {
833 const cause = causes.get(t.key);
834 if (cause === undefined) continue;
835 const done = settled(app.db, cause, testOf(t.evidence.split("\n")), now);
836 if (done === undefined || personSpoke(app.db, t.issue_id)) continue;
837 const issue = getIssueById(app.db, t.issue_id);
838 if (issue === undefined || issue.repoId !== repo.id) continue;
839 const said = addComment(
840 app,
841 actor,
842 repo,
843 issue,
844 [
845 `Passed again on its own at ${new Date(done.at).toISOString()}; evidence:`,
846 ...done.evidence.map((e) => `- ${e}`),
847 "",
848 "Closing as dropped: there is nothing to fix. If it opens again it is filed fresh, quoting this.",
849 ].join("\n"),
850 );
851 if (!said.ok) {
852 log.warn("sentinel could not comment", { key: t.key, error: String(said.error) });
853 continue;
854 }
855 const closed = setIssueState(app, actor, repo, issue, "closed", { reason: "dropped" });
856 if (!closed.ok) {
857 log.warn("sentinel could not close", { key: t.key, error: String(closed.error) });
858 continue;
859 }
860 // When it settled, on the forge's clock: the repeat rule reads this, never the comment's words.
861 app.db
862 .query("UPDATE sentinel_finding SET commented_at = ?1 WHERE key = ?2 AND issue_id = ?3")
863 .run(new Date(now).toISOString(), t.key, t.issue_id);
864 } catch (error) {
865 log.error("sentinel settle failed", { key: t.key, error: String(error) });
866 }
867 }
868 }
869
870 /**
871 * The issue a shared cause settled on within the day, and what the sentinel
872 * said closing it: a repeat is filed fresh and quotes it (#8287).
873 */
874 function settledBefore(db: Database, actor: User, f: Finding, now: number): string | undefined {
875 if (f.cause?.startsWith("shared:") !== true) return undefined;
876 // A dropped finding's `commented_at` is when it settled (`settleShared`); done and split ones keep theirs.
877 const prior = db
878 .query<{ issue_id: string; number: number; commented_at: string }, [string]>(
879 `SELECT t.issue_id, i.number, t.commented_at FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id
880 WHERE t.key = ?1 AND i.state = 'closed' AND i.close_reason = 'dropped' AND t.commented_at IS NOT NULL
881 ORDER BY t.commented_at DESC LIMIT 1`,
882 )
883 .get(f.key);
884 if (prior === null || !(now - Date.parse(prior.commented_at) < DAY_MS)) return undefined;
885 // What it said closing it: its last comment there.
886 const said = db
887 .query<{ body: string }, [string, string]>(
888 "SELECT body FROM comment WHERE issue_id = ?1 AND author_id = ?2 ORDER BY created_at DESC, id DESC LIMIT 1",
889 )
890 .get(prior.issue_id, actor.id);
891 if (said === null) return undefined;
892 return [
893 "",
894 `**Opened again within a day of #${prior.number}**, which the sentinel closed as passed on its own:`,
895 ...said.body.split("\n").map((l) => `> ${l}`),
896 ].join("\n");
897 }
898
736899 /**
737900 * A finding backed by an incident whose cause has stayed out of the degraded
738901 * history for a day is done: the sentinel says since when and closes it. Not a
@@ -759,13 +922,7 @@ function closeHeld(app: App, repo: Repo, actor: User, active: ReadonlySet<string
759922 else since = Math.max(since, s.closedAt);
760923 }
761924 if (live || since === -Infinity || now - since < DAY_MS) continue;
762 const spoke = app.db
763 .query<{ n: number }, [string]>(
764 `SELECT COUNT(*) AS n FROM comment c JOIN user u ON u.id = c.author_id
765 WHERE c.issue_id = ?1 AND u.is_agent = 0 AND u.kind = 'user'`,
766 )
767 .get(t.issue_id);
768 if (spoke !== null && spoke.n > 0) continue;
925 if (personSpoke(app.db, t.issue_id)) continue;
769926 const issue = getIssueById(app.db, t.issue_id);
770927 if (issue === undefined || issue.repoId !== repo.id) continue;
771928 const said = addComment(
@@ -806,6 +963,7 @@ export function hunt(
806963 const findings = findFaults(app.db, now, repo?.id, app.config.dataDir);
807964 const actor = sentinelActor(app.db);
808965 if (repo !== undefined && actor !== undefined) {
966 settleShared(app, repo, actor, now);
809967 for (const f of findings) {
810968 try {
811969 const evidence = f.evidence.join("\n");
@@ -834,6 +992,8 @@ export function hunt(
834992 .run(at, evidence, f.key, tracked.issue_id);
835993 continue;
836994 }
995 // A shared red that passed again on its own is no work: the day's total does not file it again.
996 if (settled(app.db, f.cause ?? "", testFor(app.db, f), now) !== undefined) continue;
837997 const told =
838998 sinceFix(app, repo, actor, f, evidence, at, now) ??
839999 (f.incident === undefined ? undefined : continuesDone(app, repo, actor, f, evidence, at, now));
@@ -851,7 +1011,8 @@ export function hunt(
8511011 .get(repo.id, title, actor.id);
8521012 let issueId = adopted?.id;
8531013 if (issueId === undefined) {
854 const made = openIssue(app, actor, repo, { title, body: bodyOf(f, at) });
1014 const body = bodyOf(f, at) + (settledBefore(app.db, actor, f, now) ?? "");
1015 const made = openIssue(app, actor, repo, { title, body });
8551016 if (!made.ok) {
8561017 log.warn("sentinel could not file", { key: f.key, error: String(made.error) });
8571018 continue;
@@ -934,7 +1095,11 @@ export function sentinelTick(app: App, now = Date.now()): HuntResult | null {
9341095 const last = sentinelStanding(app.db).lastHuntAt;
9351096 const since = last === null ? Infinity : now - new Date(last).getTime();
9361097 const [own = "", ownName = ""] = repoPath.split("/");
937 const breaches = budgetBreaches(app.db, now, getRepo(app.db, own, ownName)?.id);
1098 const ownRepo = getRepo(app.db, own, ownName);
1099 // Every tick, before a builder takes it: see `settleShared`.
1100 const actor = sentinelActor(app.db);
1101 if (ownRepo !== undefined && actor !== undefined) settleShared(app, ownRepo, actor, now);
1102 const breaches = budgetBreaches(app.db, now, ownRepo?.id);
9381103 if (since < SIGNAL_GAP_MS && breaches.length === 0) return null;
9391104 if (since < HUNT_EVERY_MS) {
9401105 // A breach is not held back by the gap: the crew hears within a tick.
test/sentinel-shared-settle.test.ts added +174
@@ -0,0 +1,174 @@
1 /**
2 * A shared red that ends by itself (#8287): its span closed, no pull failed
3 * the test since, and main's newest check, created after the span closed,
4 * passes it. The sentinel closes its own issue as dropped before a builder is
5 * handed it, on the hourly hunt and on every tick, never one a person has
6 * spoken on, never without write access, and files a repeat within the day
7 * fresh, quoting the earlier one. The tests named "exception" guard what
8 * must stay open; they pass without the change too.
9 */
10 import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { quietLogs } from "./helpers/globals.ts";
15 import { createApp, type App } from "../src/app.ts";
16 import { createRepo } from "../src/actions/repos.ts";
17 import { addCrew, createAgentRow } from "../src/core/agents.ts";
18 import { addComment, createIssue, getIssueById, listComments, listIssues } from "../src/core/issues.ts";
19 import type { Repo } from "../src/core/repos.ts";
20 import { createRun } from "../src/core/runs.ts";
21 import { createUser, type User } from "../src/core/users.ts";
22 import { sharedCause } from "../src/checks/shared_red.ts";
23 import { hunt, sentinelTick } from "../src/crew/sentinel.ts";
24
25 quietLogs();
26
27 const TEST = "board > a card keeps its place";
28 const MIN = 60_000;
29 const HOUR = 60 * MIN;
30 const T = Date.parse("2026-10-30T12:00:00.000Z");
31
32 let dir: string;
33 let app: App;
34 let repo: Repo;
35 let alice: User;
36 let sentinelId: string;
37 const cause = (): string => sharedCause({ repoId: repo.id, test: TEST });
38 const filed = () =>
39 listIssues(app.db, repo.id, { state: "all" }).filter((i) => i.title.startsWith(`Sentinel: ${cause()}`));
40
41 /** A check on a pull or on main, finished at `at`; a failed one fails the test. */
42 function check(state: "ok" | "failed", at: number, issueId?: string): void {
43 const run = createRun(app.db, { repoId: repo.id, kind: "check", ...(issueId ? { issueId } : { env: "main" }) });
44 app.db
45 .query("UPDATE run SET state = ?2, created_at = ?3, finished_at = ?3, commit_oid = ?4, check_detail = ?5 WHERE id = ?1")
46 .run(run.id, state, new Date(at).toISOString(), "e".repeat(40), state === "failed" ? JSON.stringify({ names: [TEST] }) : "{}");
47 }
48 function redPull(n: number, at: number): void {
49 const pull = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: `pull ${n}`, kind: "pull" });
50 if (!pull.ok) throw new Error(String(pull.error));
51 check("failed", at, pull.value.id);
52 }
53
54 beforeEach(async () => {
55 dir = await mkdtemp(join(tmpdir(), "forge-sentinel-settle-"));
56 app = createApp({ dataDir: dir, port: 0 });
57 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
58 if (!a.ok) throw new Error(a.error);
59 alice = a.value;
60 const made = await createRepo(app, alice, { name: "forge", visibility: "private" });
61 if (!made.ok) throw new Error(String(made.error));
62 repo = made.value;
63 const s = await createUser(app.db, { origin: "seed", username: "sentinel", email: "[email protected]", agent: true });
64 if (!s.ok) throw new Error("agent");
65 createAgentRow(app.db, { userId: s.value.id, brief: "hunt", createdBy: alice.id });
66 addCrew(app.db, repo.id, s.value.id, alice.id);
67 sentinelId = s.value.id;
68 // Closing an issue takes write access: here the sentinel is a site admin.
69 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(sentinelId);
70 // A shared red: three pulls fail the test, the span opens, the sentinel files it.
71 app.db
72 .query("INSERT INTO degraded_span (cause, sentence, href, opened_at) VALUES (?1, 'It is red on 3 pulls.', '/x', ?2)")
73 .run(cause(), T - 50 * MIN);
74 for (let n = 0; n < 3; n++) redPull(n, T - 45 * MIN + n * MIN);
75 expect(hunt(app, "alice/forge", T - 40 * MIN).filed).toBe(1);
76 // It ends by itself: the span closes and main's next check is green.
77 app.db.query("UPDATE degraded_span SET closed_at = ?1 WHERE cause = ?2").run(T - 20 * MIN, cause());
78 check("ok", T - 15 * MIN);
79 });
80 afterEach(async () => {
81 app.db.close();
82 await rm(dir, { recursive: true, force: true });
83 });
84
85 describe("a shared red that passed again on its own (#8287)", () => {
86 test("closes its own issue as dropped, saying when and why, and files nothing new", () => {
87 hunt(app, "alice/forge", T);
88 const [issue, ...more] = filed();
89 expect(more).toEqual([]);
90 expect(issue!.state).toBe("closed");
91 expect(issue!.closeReason).toBe("dropped");
92 const said = listComments(app.db, issue!.id).filter((c) => c.body.startsWith("Passed again on its own at "));
93 expect(said.length).toBe(1);
94 expect(said[0]!.body).toContain(`Passed again on its own at ${new Date(T - 20 * MIN).toISOString()}; evidence:`);
95 expect(said[0]!.body).toContain("pulls failed it on checks created since: 0");
96 expect(said[0]!.body).toContain("main: passes on main's newest check");
97 // The day's degraded total does not file it again.
98 hunt(app, "alice/forge", T + 2 * HOUR);
99 expect(filed().length).toBe(1);
100 });
101
102 test("exception: stays open while a pull has failed it since the span closed", () => {
103 redPull(9, T - 10 * MIN);
104 hunt(app, "alice/forge", T);
105 expect(filed()[0]!.state).toBe("open");
106 });
107
108 test("exception: an issue a person has commented on is never closed this way", () => {
109 addComment(app.db, { issueId: filed()[0]!.id, authorId: alice.id, body: "I am looking at this" });
110 hunt(app, "alice/forge", T);
111 const [issue] = filed();
112 expect(issue!.state).toBe("open");
113 expect(listComments(app.db, issue!.id).some((c) => c.body.startsWith("Passed again"))).toBe(false);
114 });
115
116 test("stays open when main's newest check was created before the span closed", () => {
117 app.db.query("UPDATE run SET created_at = ?1 WHERE env = 'main'").run(new Date(T - 30 * MIN).toISOString());
118 hunt(app, "alice/forge", T);
119 expect(filed()[0]!.state).toBe("open");
120 });
121
122 test("stays open when main's newest check fails the test", () => {
123 check("failed", T - 10 * MIN);
124 hunt(app, "alice/forge", T);
125 expect(filed()[0]!.state).toBe("open");
126 });
127
128 test("a tick between hunts closes it, before a builder's next pick", () => {
129 const prior = process.env.FORGE_SENTINEL_REPO;
130 process.env.FORGE_SENTINEL_REPO = "alice/forge";
131 try {
132 // 40 minutes since the hunt: no hunt is due, the settle still runs.
133 expect(sentinelTick(app, T)).toBeNull();
134 } finally {
135 if (prior === undefined) delete process.env.FORGE_SENTINEL_REPO;
136 else process.env.FORGE_SENTINEL_REPO = prior;
137 }
138 expect(filed()[0]!.state).toBe("closed");
139 expect(filed()[0]!.closeReason).toBe("dropped");
140 });
141
142 test("exception: a sentinel without write access closes nothing and says nothing", () => {
143 app.db.query("UPDATE user SET is_admin = 0 WHERE id = ?1").run(sentinelId);
144 hunt(app, "alice/forge", T);
145 const [issue] = filed();
146 expect(issue!.state).toBe("open");
147 expect(listComments(app.db, issue!.id).some((c) => c.body.startsWith("Passed again"))).toBe(false);
148 });
149
150 test("the same cause opening again within a day is a fresh issue quoting the earlier one", () => {
151 hunt(app, "alice/forge", T);
152 const earlier = filed()[0]!;
153 expect(earlier.state).toBe("closed");
154 app.db
155 .query("INSERT INTO degraded_span (cause, sentence, href, opened_at) VALUES (?1, 'It is red on 3 pulls.', '/x', ?2)")
156 .run(cause(), T + 3 * HOUR);
157 expect(hunt(app, "alice/forge", T + 3 * HOUR + 5 * MIN).filed).toBe(1);
158 const fresh = filed().find((i) => i.id !== earlier.id)!;
159 expect(fresh.state).toBe("open");
160 const body = getIssueById(app.db, fresh.id)!.body;
161 expect(body).toContain(`Opened again within a day of #${earlier.number}`);
162 expect(body).toContain(`> Passed again on its own at ${new Date(T - 20 * MIN).toISOString()}`);
163 });
164
165 test("a repeat more than a day after it settled is filed without the quote", () => {
166 hunt(app, "alice/forge", T);
167 app.db
168 .query("INSERT INTO degraded_span (cause, sentence, href, opened_at) VALUES (?1, 'It is red on 3 pulls.', '/x', ?2)")
169 .run(cause(), T + 25 * HOUR);
170 expect(hunt(app, "alice/forge", T + 25 * HOUR + 5 * MIN).filed).toBe(1);
171 const fresh = filed().find((i) => i.state === "open")!;
172 expect(getIssueById(app.db, fresh.id)!.body).not.toContain("Opened again within a day");
173 });
174 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs