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

The sentinel files a degraded incident once: closed as done, the same incident is commented on, not filed again #2554

Merged opened this

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

Merged

scout/forge:scout/2553-the-sentinel-files-a-degraded-incident-o → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/2553-the-sentinel-files-a-degraded-incident-o was merged into main as b4089bd.

Review

Sign in to review this change.
3 files changed +129 −3
src/core/migrations/20261018100000-incident-remembered.ts added +7
@@ -0,0 +1,7 @@
1 // A degraded finding remembers the incident it was filed for (#2553): the
2 // time the cause first opened, and when its closed issue was last told the
3 // incident goes on. Both are null for every other finding.
4 export default `
5 ALTER TABLE sentinel_finding ADD COLUMN incident TEXT;
6 ALTER TABLE sentinel_finding ADD COLUMN commented_at TEXT;
7 `;
src/crew/sentinel.ts +48 −3
@@ -64,6 +64,8 @@ export interface Finding {
6464 readonly fix?: string;
6565 /** A setting finding: the value and `updated_at` a person may have kept (#2001). */
6666 readonly kept?: { readonly value: string; readonly updatedAt: string };
67 /** A degraded finding: when its cause first opened. Same cause and time, same incident (#2553). */
68 readonly incident?: string;
6769 }
6870
6971 const clip = (text: string, n = 200): string =>
@@ -386,7 +388,7 @@ export function repeatedErrors(now: number): Finding[] {
386388 */
387389 export function degradedCauses(db: Database, now: number): Finding[] {
388390 const from = now - DAY_MS;
389 const byCause = new Map<string, { sentence: string; href: string; first: number; ms: number }>();
391 const byCause = new Map<string, { sentence: string; href: string; first: number; opened: number; ms: number }>();
390392 for (const s of spans(db, from, now)) {
391393 const start = Math.max(s.openedAt, from);
392394 const held = Math.min(s.closedAt ?? now, now) - start;
@@ -396,6 +398,7 @@ export function degradedCauses(db: Database, now: number): Finding[] {
396398 sentence: s.sentence,
397399 href: s.href,
398400 first: seen?.first ?? start,
401 opened: seen?.opened ?? s.openedAt,
399402 ms: (seen?.ms ?? 0) + Math.max(0, held),
400403 });
401404 }
@@ -415,6 +418,7 @@ export function degradedCauses(db: Database, now: number): Finding[] {
415418 ],
416419 pillar: "P0 Trust: the forge says when it is not well, and is well again",
417420 lookFor: `\`${cause}\` stays out of the degraded list for a day, or holds under ${DEGRADED_DAY_MS / 60_000} minutes.`,
421 incident: new Date(d.opened).toISOString(),
418422 });
419423 }
420424 return out;
@@ -626,6 +630,42 @@ export function recordKept(db: Database, key: string, value: string, updatedAt:
626630 ).run(key, value, updatedAt, at);
627631 }
628632
633 /**
634 * A degraded incident whose issue was closed as done and which goes on (same
635 * cause, same first-open time) is told to that issue, not filed again: the
636 * fix is likely merged but not live. At most one comment a day. True when
637 * the incident is that one: the comments made (0 or 1), else undefined.
638 */
639 function continuesDone(app: App, repo: Repo, actor: User, f: Finding, evidence: string, at: string, now: number): 0 | 1 | undefined {
640 const prior = app.db
641 .query<{ issue_id: string; commented_at: string | null }, [string, string]>(
642 `SELECT t.issue_id, t.commented_at FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id
643 WHERE t.key = ?1 AND t.incident = ?2 AND i.state = 'closed'
644 ORDER BY t.first_seen DESC LIMIT 1`,
645 )
646 .get(f.key, f.incident ?? "");
647 if (prior === null) return undefined;
648 const issue = getIssueById(app.db, prior.issue_id);
649 if (issue === undefined || issue.closeReason !== "done") return undefined;
650 if (prior.commented_at !== null && now - Date.parse(prior.commented_at) < DAY_MS) return 0;
651 const minutes = f.evidence.find((e) => e.startsWith("total: "));
652 const said = addComment(
653 app,
654 actor,
655 repo,
656 issue,
657 `Still degraded after the fix; the fix may not be live yet. At ${at}${minutes === undefined ? "" : `, ${minutes.slice(7)}`}; evidence now:\n${f.evidence.map((e) => `- ${e}`).join("\n")}`,
658 );
659 if (!said.ok) {
660 log.warn("sentinel could not comment", { key: f.key, error: String(said.error) });
661 return 0;
662 }
663 app.db
664 .query("UPDATE sentinel_finding SET commented_at = ?1, last_seen = ?1, evidence = ?2 WHERE key = ?3 AND issue_id = ?4")
665 .run(at, evidence, f.key, prior.issue_id);
666 return 1;
667 }
668
629669 /**
630670 * Read, file, record. One open issue per finding: a tracked finding whose
631671 * issue is open is commented on only when its evidence changed; one whose
@@ -674,6 +714,11 @@ export function hunt(
674714 .run(at, evidence, f.key, tracked.issue_id);
675715 continue;
676716 }
717 const told = f.incident === undefined ? undefined : continuesDone(app, repo, actor, f, evidence, at, now);
718 if (told !== undefined) {
719 commented += told;
720 continue;
721 }
677722 const title = `Sentinel: ${clip(f.title, 200)}`;
678723 // An issue already filed under this title whose record was lost
679724 // (the insert failed last hunt) is adopted, not filed twice.
@@ -694,8 +739,8 @@ export function hunt(
694739 if (f.urgent && f.kept === undefined) handOver(app, repo, actor, made.value);
695740 }
696741 app.db
697 .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES (?1, ?2, ?3, ?4, ?4)")
698 .run(f.key, issueId, evidence, at);
742 .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen, incident) VALUES (?1, ?2, ?3, ?4, ?4, ?5)")
743 .run(f.key, issueId, evidence, at, f.incident ?? null);
699744 } catch (error) {
700745 log.error("sentinel filing failed", { key: f.key, error: String(error) });
701746 }
test/sentinel.test.ts +74
@@ -739,3 +739,77 @@ describe("a budget breach is crew work at once", () => {
739739 }
740740 });
741741 });
742
743 // ---- a degraded incident closed as done (#2553) ----
744
745 describe("a degraded incident whose issue closed as done", () => {
746 let idir: string;
747 let iapp: App;
748 let irepo: Repo;
749 const T0 = Date.parse("2026-10-18T12:00:00.000Z");
750 const OPEN = T0 - 3 * HOUR;
751 const span = (cause: string, openedAt: number, minutes: number): void => {
752 iapp.db
753 .query("INSERT INTO degraded_span (cause, sentence, href, opened_at, closed_at) VALUES (?1, 'It is slow.', '/x', ?2, ?3)")
754 .run(cause, openedAt, openedAt + minutes * D_MIN);
755 };
756 const issues = (state: string) => listIssues(iapp.db, irepo.id, { state } as never);
757 const close = (reason: string): void => {
758 iapp.db.query("UPDATE issue SET state = 'closed', close_reason = ?1, closed_at = ?2").run(reason, new Date(T0).toISOString());
759 };
760 beforeAll(async () => {
761 idir = await mkdtemp(join(tmpdir(), "forge-sentinel-incident-"));
762 iapp = createApp({ dataDir: idir, port: 0 });
763 const a = await createUser(iapp.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
764 if (!a.ok) throw new Error(a.error);
765 const made = await createRepo(iapp, a.value, { name: "forge", visibility: "private" });
766 if (!made.ok) throw new Error(String(made.error));
767 irepo = made.value;
768 });
769 afterAll(async () => {
770 iapp.db.close();
771 await rm(idir, { recursive: true, force: true });
772 });
773
774 test("is commented once a day, not refiled", () => {
775 span("drill", OPEN, 40 * 60);
776 expect(hunt(iapp, "alice/forge", T0).filed).toBe(1);
777 close("done");
778 const again = hunt(iapp, "alice/forge", T0 + 30 * D_MIN);
779 expect(again.filed).toBe(0);
780 expect(again.commented).toBe(1);
781 expect(issues("open").length).toBe(0);
782 const closed = issues("closed");
783 expect(closed.length).toBe(1);
784 const comments = listComments(iapp.db, closed[0]!.id);
785 expect(comments.length).toBe(1);
786 expect(comments[0]!.body).toContain("Still degraded after the fix; the fix may not be live yet");
787 // Same day: nothing more, and still no new issue.
788 const third = hunt(iapp, "alice/forge", T0 + 2 * HOUR);
789 expect(third.filed).toBe(0);
790 expect(third.commented).toBe(0);
791 expect(listComments(iapp.db, closed[0]!.id).length).toBe(1);
792 // The next day it is told again.
793 expect(hunt(iapp, "alice/forge", T0 + 25 * HOUR).commented).toBe(1);
794 expect(listComments(iapp.db, closed[0]!.id).length).toBe(2);
795 expect(issues("open").length).toBe(0);
796 });
797
798 test("a new incident of the same cause is refiled", () => {
799 const later = T0 + 3 * DAY;
800 span("drill", later - HOUR, 30);
801 const r = hunt(iapp, "alice/forge", later);
802 expect(r.filed).toBe(1);
803 expect(issues("open").length).toBe(1);
804 });
805
806 test("a finding closed as dropped that returns is refiled", () => {
807 const t = T0 + 10 * DAY;
808 span("disk", t - 2 * HOUR, 30);
809 expect(hunt(iapp, "alice/forge", t).filed).toBe(1);
810 iapp.db.query("UPDATE issue SET state = 'closed', close_reason = 'dropped', closed_at = ?1 WHERE state = 'open'").run(new Date(t).toISOString());
811 const r = hunt(iapp, "alice/forge", t + 30 * D_MIN);
812 expect(r.filed).toBe(1);
813 expect(r.commented).toBe(0);
814 });
815 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs