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

The sentinel closes its own finding as done once what it watched has stayed healthy for a day #2636

Merged opened this

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

Merged

scout/forge:scout/2635-the-sentinel-closes-its-own-finding-as-d → main 1 commit fork

  • Merged. scout/2635-the-sentinel-closes-its-own-finding-as-d was merged into main as 52d81f7.

Review

Sign in to review this change.
2 files changed +120 −1
src/crew/sentinel.ts +57 −1
@@ -15,7 +15,7 @@
1515
1616 import { Database } from "bun:sqlite";
1717 import type { App } from "../app.ts";
18 import { addComment, openIssue } from "../actions/issues.ts";
18 import { addComment, openIssue, setIssueState } from "../actions/issues.ts";
1919 import {
2020 CAPACITY_KEYS,
2121 capacityDefault,
@@ -666,6 +666,60 @@ function continuesDone(app: App, repo: Repo, actor: User, f: Finding, evidence:
666666 return 1;
667667 }
668668
669 /**
670 * A finding backed by an incident whose cause has stayed out of the degraded
671 * history for a day is done: the sentinel says since when and closes it. Not a
672 * setting (its own rule), and not one a person has spoken on — they decide.
673 * `active` are the keys found now; a finding that is there is not held.
674 */
675 function closeHeld(app: App, repo: Repo, actor: User, active: ReadonlySet<string>, now: number): void {
676 const open = app.db
677 .query<{ key: string; issue_id: string }, []>(
678 `SELECT t.key, t.issue_id FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id
679 WHERE i.state = 'open' AND t.key LIKE 'degraded:%'`,
680 )
681 .all();
682 if (open.length === 0) return;
683 const history = spans(app.db, now - 30 * DAY_MS, now);
684 for (const t of open) {
685 try {
686 if (active.has(t.key)) continue;
687 let since = -Infinity;
688 let live = false;
689 for (const s of history) {
690 if (`degraded:${keyPart(s.cause)}` !== t.key) continue;
691 if (s.closedAt === null) live = true;
692 else since = Math.max(since, s.closedAt);
693 }
694 if (live || since === -Infinity || now - since < DAY_MS) continue;
695 const spoke = app.db
696 .query<{ n: number }, [string]>(
697 `SELECT COUNT(*) AS n FROM comment c JOIN user u ON u.id = c.author_id
698 WHERE c.issue_id = ?1 AND u.is_agent = 0 AND u.kind = 'user'`,
699 )
700 .get(t.issue_id);
701 if (spoke !== null && spoke.n > 0) continue;
702 const issue = getIssueById(app.db, t.issue_id);
703 if (issue === undefined || issue.repoId !== repo.id) continue;
704 const said = addComment(
705 app,
706 actor,
707 repo,
708 issue,
709 `Held since ${new Date(since).toISOString()}: no new incident on this cause for over a day. Closing as done; if it comes back it is filed again.`,
710 );
711 if (!said.ok) {
712 log.warn("sentinel could not comment", { key: t.key, error: String(said.error) });
713 continue;
714 }
715 const closed = setIssueState(app, actor, repo, issue, "closed", { reason: "done" });
716 if (!closed.ok) log.warn("sentinel could not close", { key: t.key, error: String(closed.error) });
717 } catch (error) {
718 log.error("sentinel close failed", { key: t.key, error: String(error) });
719 }
720 }
721 }
722
669723 /**
670724 * Read, file, record. One open issue per finding: a tracked finding whose
671725 * issue is open is commented on only when its evidence changed; one whose
@@ -746,6 +800,8 @@ export function hunt(
746800 }
747801 }
748802 }
803 if (repo !== undefined && actor !== undefined)
804 closeHeld(app, repo, actor, new Set(findings.map((f) => f.key)), now);
749805 // A hunt with nowhere to file is recorded as such, never as a quiet one.
750806 const unfiled = repo === undefined || actor === undefined;
751807 app.db
test/sentinel.test.ts +63
@@ -813,3 +813,66 @@ describe("a degraded incident whose issue closed as done", () => {
813813 expect(r.commented).toBe(0);
814814 });
815815 });
816
817 // ---- a finding held for a day is closed as done (#2635) ----
818
819 describe("a degraded finding whose cause has stayed closed", () => {
820 let hdir: string;
821 let happ: App;
822 let hrepo: Repo;
823 let halice: User;
824 const T0 = Date.parse("2026-10-18T12:00:00.000Z");
825 const plant = (cause: string, closedAt: number): void => {
826 happ.db
827 .query("INSERT INTO degraded_span (cause, sentence, href, opened_at, closed_at) VALUES (?1, 'It is slow.', '/x', ?2, ?3)")
828 .run(cause, closedAt - 40 * D_MIN, closedAt);
829 };
830 const state = (title: string): string =>
831 happ.db.query<{ state: string }, [string]>("SELECT state FROM issue WHERE title LIKE ?1").get(`%${title}%`)!.state;
832 beforeAll(async () => {
833 hdir = await mkdtemp(join(tmpdir(), "forge-sentinel-held-"));
834 happ = createApp({ dataDir: hdir, port: 0 });
835 const a = await createUser(happ.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
836 if (!a.ok) throw new Error(a.error);
837 halice = a.value;
838 const made = await createRepo(happ, halice, { name: "forge", visibility: "private" });
839 if (!made.ok) throw new Error(String(made.error));
840 hrepo = made.value;
841 // Two incidents, both closed at T0; one will get a person's comment.
842 plant("drill", T0);
843 plant("disk", T0);
844 expect(hunt(happ, "alice/forge", T0 + 5 * D_MIN).filed).toBe(2);
845 });
846 afterAll(async () => {
847 happ.db.close();
848 await rm(hdir, { recursive: true, force: true });
849 });
850
851 test("23 hours closed stays open; a person's comment and a setting keep theirs at 25", () => {
852 // A setting finding, tracked as the sentinel would.
853 const s = createIssue(happ.db, { repoId: hrepo.id, authorId: halice.id, title: "Sentinel: Setting x" });
854 if (!s.ok) throw new Error(String(s.error));
855 happ.db.query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES ('setting:x', ?1, '', 'a', 'a')").run(s.value.id);
856 const disk = happ.db.query<{ id: string }, []>("SELECT id FROM issue WHERE title LIKE '%disk%'").get()!;
857 addComment(happ.db, { issueId: disk.id, authorId: halice.id, body: "looking" });
858
859 hunt(happ, "alice/forge", T0 + 23 * HOUR);
860 expect(state("drill")).toBe("open");
861
862 hunt(happ, "alice/forge", T0 + 25 * HOUR);
863 expect(state("drill")).toBe("closed");
864 const drill = happ.db.query<{ id: string; close_reason: string }, []>("SELECT id, close_reason FROM issue WHERE title LIKE '%drill%'").get()!;
865 expect(drill.close_reason).toBe("done");
866 const said = listComments(happ.db, drill.id).filter((c) => c.body.startsWith("Held since "));
867 expect(said.length).toBe(1);
868 expect(said[0]!.body).toContain(new Date(T0).toISOString());
869 expect(state("disk")).toBe("open");
870 expect(state("Setting x")).toBe("open");
871 });
872
873 test("a finding that comes back is a new issue", () => {
874 const t = T0 + 3 * DAY;
875 happ.db.query("INSERT INTO degraded_span (cause, sentence, href, opened_at, closed_at) VALUES ('drill', 'It is slow.', '/x', ?1, ?2)").run(t - HOUR, t - 20 * D_MIN);
876 expect(hunt(happ, "alice/forge", t).filed).toBe(1);
877 });
878 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs