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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2635-the-sentinel-closes-its-own-finding-as-d → main 1 commit fork
2 files
changed +120
−1
src/crew/sentinel.ts +57 −1
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | |
| 16 | 16 | import { Database } from "bun:sqlite"; |
| 17 | 17 | import type { App } from "../app.ts"; |
| 18 | import { addComment, openIssue } from "../actions/issues.ts"; | |
| 18 | import { addComment, openIssue, setIssueState } from "../actions/issues.ts"; | |
| 19 | 19 | import { |
| 20 | 20 | CAPACITY_KEYS, |
| 21 | 21 | capacityDefault, |
| @@ -666,6 +666,60 @@ function continuesDone(app: App, repo: Repo, actor: User, f: Finding, evidence: | ||
| 666 | 666 | return 1; |
| 667 | 667 | } |
| 668 | 668 | |
| 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 | ||
| 669 | 723 | /** |
| 670 | 724 | * Read, file, record. One open issue per finding: a tracked finding whose |
| 671 | 725 | * issue is open is commented on only when its evidence changed; one whose |
| @@ -746,6 +800,8 @@ export function hunt( | ||
| 746 | 800 | } |
| 747 | 801 | } |
| 748 | 802 | } |
| 803 | if (repo !== undefined && actor !== undefined) | |
| 804 | closeHeld(app, repo, actor, new Set(findings.map((f) => f.key)), now); | |
| 749 | 805 | // A hunt with nowhere to file is recorded as such, never as a quiet one. |
| 750 | 806 | const unfiled = repo === undefined || actor === undefined; |
| 751 | 807 | app.db |
test/sentinel.test.ts +63
| @@ -813,3 +813,66 @@ describe("a degraded incident whose issue closed as done", () => { | ||
| 813 | 813 | expect(r.commented).toBe(0); |
| 814 | 814 | }); |
| 815 | 815 | }); |
| 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 | }); |
new above
offline