Public: anyone can read this; the crew can change it.
The sentinel stops refiling a setting a person kept on purpose; it reports it again only when the value changes #2006
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2001-the-sentinel-stops-refiling-a-setting-a → main 4 commits fork 2 threads · 2 unresolved
4 files
changed +138
−4
README.md +9
| @@ -619,6 +619,15 @@ default is new. Each finding is one issue with its evidence, filed in | ||
| 619 | 619 | the first admin. Evidence is scrubbed of anything credential-shaped before it |
| 620 | 620 | is filed. |
| 621 | 621 | |
| 622 | A setting kept on purpose is a decision, not drift. Closing a `setting:<key>` | |
| 623 | finding's issue, or commenting on it (a person, not the filer and not an agent), records the value | |
| 624 | and its `updated_at` in `sentinel_kept`; while both match, the sentinel files | |
| 625 | nothing and comments nothing for that key. Changing the value on the capacity | |
| 626 | page ends the keeping, and the setting is reported again under the usual | |
| 627 | one-day rule. The rule is mechanical: any close counts, with or without a | |
| 628 | reason, so a bare close silences the setting until its value changes. Other | |
| 629 | findings (`held`, `ledger`, `red`) are filed anew when they return. | |
| 630 | ||
| 622 | 631 | - **Board:** a site admin's forge strip shows the open findings and the last |
| 623 | 632 | hunt; a guest or member sees nothing. |
| 624 | 633 | - **Terminal:** press `ctrl+k`, run **Sentinel** — the same line as a toast. |
src/core/migrations/20261016090000-sentinel-kept-settings.ts added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | // A setting a person kept on purpose (#2001): the value and its | |
| 2 | // `forge_setting.updated_at` when its finding was closed or explained, so the | |
| 3 | // sentinel reports it again only when the value changes. | |
| 4 | export default ` | |
| 5 | CREATE TABLE IF NOT EXISTS sentinel_kept ( | |
| 6 | key TEXT PRIMARY KEY, | |
| 7 | value TEXT NOT NULL, | |
| 8 | updated_at TEXT NOT NULL, | |
| 9 | kept_at TEXT NOT NULL | |
| 10 | ); | |
| 11 | `; |
src/crew/sentinel.ts +42 −3
| @@ -62,6 +62,8 @@ export interface Finding { | ||
| 62 | 62 | readonly urgent?: true; |
| 63 | 63 | /** What a person or a run would do, when it is mechanical and reversible. */ |
| 64 | 64 | readonly fix?: string; |
| 65 | /** A setting finding: the value and `updated_at` a person may have kept (#2001). */ | |
| 66 | readonly kept?: { readonly value: string; readonly updatedAt: string }; | |
| 65 | 67 | } |
| 66 | 68 | |
| 67 | 69 | const clip = (text: string, n = 200): string => |
| @@ -186,7 +188,8 @@ export function settingsAway(db: Database, now: number): Finding[] { | ||
| 186 | 188 | `set by ${row.username ?? "unknown"} at ${row.updated_at}`, |
| 187 | 189 | ], |
| 188 | 190 | pillar: "Trust: a setting is a decision, not a leftover", |
| 189 | lookFor: `${key} is back at "${capacityDefault(key)}", or someone says in a comment why it stays.`, | |
| 191 | lookFor: `${key} is back at "${capacityDefault(key)}", or someone says why it stays: close this issue with a reason (or comment one; a bare close keeps it too) and the setting is kept, reported again only if its value changes.`, | |
| 192 | kept: { value, updatedAt: row.updated_at }, | |
| 190 | 193 | fix: `Put it back: clear ${key} on the capacity page (it is recorded and reversible).`, |
| 191 | 194 | }); |
| 192 | 195 | } |
| @@ -545,10 +548,45 @@ function handOver(app: App, repo: Repo, actor: User, issue: Issue): void { | ||
| 545 | 548 | } |
| 546 | 549 | } |
| 547 | 550 | |
| 551 | /** The finding is a setting whose kept value and `updated_at` are on record. */ | |
| 552 | function keptRecorded(db: Database, f: Finding): boolean { | |
| 553 | if (f.kept === undefined) return false; | |
| 554 | const kept = db | |
| 555 | .query<{ value: string; updated_at: string }, [string]>("SELECT value, updated_at FROM sentinel_kept WHERE key = ?1") | |
| 556 | .get(f.key); | |
| 557 | return kept !== null && kept.value === f.kept.value && kept.updated_at === f.kept.updatedAt; | |
| 558 | } | |
| 559 | ||
| 560 | /** | |
| 561 | * Whether a person decided to keep this setting as it now stands: its | |
| 562 | * finding was closed, or explained in a comment by a person (not the sentinel, not an agent), | |
| 563 | * after the value was last set. A decision is recorded with the value and | |
| 564 | * `updated_at` it was made at; the same pair is kept, any other is drift. | |
| 565 | */ | |
| 566 | function settingKept(db: Database, f: Finding, actorId: string, at: string): boolean { | |
| 567 | if (f.kept === undefined) return false; | |
| 568 | if (keptRecorded(db, f)) return true; | |
| 569 | const { value, updatedAt } = f.kept; | |
| 570 | const decided = db | |
| 571 | .query<{ n: number }, [string, string, string]>( | |
| 572 | `SELECT COUNT(*) AS n FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id | |
| 573 | WHERE t.key = ?1 AND ((i.state = 'closed' AND i.closed_at >= ?2) | |
| 574 | OR EXISTS (SELECT 1 FROM comment c WHERE c.issue_id = i.id AND c.author_id <> ?3 AND c.created_at >= ?2 | |
| 575 | AND EXISTS (SELECT 1 FROM user u WHERE u.id = c.author_id AND u.is_agent = 0 AND u.kind = 'user')))`, | |
| 576 | ) | |
| 577 | .get(f.key, updatedAt, actorId); | |
| 578 | if (decided === null || decided.n === 0) return false; | |
outdated
Any close at or after updated_at counts as kept, reason or not. Fine as a rule, but document it in the README. |
||
| 579 | db.query( | |
| 580 | "INSERT INTO sentinel_kept (key, value, updated_at, kept_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(key) DO UPDATE SET value = ?2, updated_at = ?3, kept_at = ?4", | |
| 581 | ).run(f.key, value, updatedAt, at); | |
| 582 | return true; | |
| 583 | } | |
| 584 | ||
| 548 | 585 | /** |
| 549 | 586 | * Read, file, record. One open issue per finding: a tracked finding whose |
| 550 | 587 | * issue is open is commented on only when its evidence changed; one whose |
| 551 | * issue is closed and returns is a new issue. | |
| 588 | * issue is closed and returns is a new issue — except a setting a person | |
| 589 | * kept (`settingKept`), which stays quiet until its value changes. | |
| 552 | 590 | */ |
| 553 | 591 | export function hunt( |
| 554 | 592 | app: App, |
| @@ -565,6 +603,7 @@ export function hunt( | ||
| 565 | 603 | if (repo !== undefined && actor !== undefined) { |
| 566 | 604 | for (const f of findings) { |
| 567 | 605 | try { |
| 606 | if (settingKept(app.db, f, actor.id, at)) continue; | |
| 568 | 607 | const evidence = f.evidence.join("\n"); |
| 569 | 608 | const tracked = app.db |
| 570 | 609 | .query< |
| @@ -698,7 +737,7 @@ export function sentinelTick(app: App, now = Date.now()): HuntResult | null { | ||
| 698 | 737 | .all() |
| 699 | 738 | .map((r) => r.key), |
| 700 | 739 | ); |
| 701 | if (!signals.some((s) => !tracked.has(s.key))) return null; | |
| 740 | if (!signals.some((s) => !tracked.has(s.key) && !keptRecorded(app.db, s))) return null; | |
| 702 | 741 | } |
| 703 | 742 | return hunt(app, repoPath, now); |
| 704 | 743 | } |
test/sentinel.test.ts +76 −1
| @@ -13,7 +13,7 @@ import { createRepo } from "../src/actions/repos.ts"; | ||
| 13 | 13 | import { setCapacity } from "../src/core/capacity.ts"; |
| 14 | 14 | import { clearErrors, createLog } from "../src/kernel/log.ts"; |
| 15 | 15 | import { recordDecision } from "../src/core/decisions.ts"; |
| 16 | import { createLabel, labelsFor, listComments, listIssues } from "../src/core/issues.ts"; | |
| 16 | import { addComment, createLabel, labelsFor, listComments, listIssues } from "../src/core/issues.ts"; | |
| 17 | 17 | import { addCrew, createAgentRow } from "../src/core/agents.ts"; |
| 18 | 18 | import { listEvents } from "../src/core/events.ts"; |
| 19 | 19 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| @@ -41,6 +41,7 @@ let repo: Repo; | ||
| 41 | 41 | const NOW = Date.parse("2026-10-13T12:00:00.000Z"); |
| 42 | 42 | const ago = (ms: number): string => new Date(NOW - ms).toISOString(); |
| 43 | 43 | const HOUR = 3_600_000; |
| 44 | const DAY = 24 * HOUR; | |
| 44 | 45 | |
| 45 | 46 | function plantRun( |
| 46 | 47 | kind: "check" | "agent", |
| @@ -385,6 +386,80 @@ describe("the signals inside the hour", () => { | ||
| 385 | 386 | expect(withRepo(() => sentinelTick(app, lastHunt() + 20 * 60_000))).toBeNull(); |
| 386 | 387 | }); |
| 387 | 388 | |
| 389 | test("a setting kept on purpose is not refiled until its value changes (#2001)", () => { | |
| 390 | const setAt = (value: string, at: string): void => { | |
| 391 | setCapacity(app.db, "checks.concurrency", value, alice.id); | |
| 392 | app.db.query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'checks.concurrency'").run(at); | |
| 393 | }; | |
| 394 | const issuesFor = (): number => | |
| 395 | app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM issue WHERE title LIKE 'Sentinel: Setting checks.concurrency %'").get()!.n; | |
| 396 | app.db.query("DELETE FROM issue WHERE title LIKE 'Sentinel: Setting %'").run(); | |
| 397 | app.db.query("DELETE FROM sentinel_kept").run(); | |
| 398 | app.db.query("DELETE FROM forge_setting WHERE key IN ('cells.egress', 'checks.concurrency')").run(); | |
| 399 | const base = Date.now(); | |
| 400 | setAt("4", new Date(base - 10 * 24 * HOUR).toISOString()); | |
| 401 | expect(hunt(app, "alice/forge", base).filed).toBe(1); | |
| 402 | const first = app.db.query<{ id: string; body: string }, []>("SELECT id, body FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Setting checks.concurrency %'").get()!; | |
| 403 | expect(first.body).toContain("close this issue with a reason"); | |
| 404 | // closed with a reason: kept | |
| 405 | app.db.query("UPDATE issue SET state = 'closed', closed_at = ?2 WHERE id = ?1").run(first.id, new Date(base).toISOString()); | |
| 406 | const comments = listComments(app.db, first.id).length; | |
| 407 | expect(hunt(app, "alice/forge", base + 2 * HOUR).filed).toBe(0); | |
| 408 | expect(hunt(app, "alice/forge", base + 4 * HOUR).filed).toBe(0); | |
| 409 | expect(issuesFor()).toBe(1); | |
| 410 | expect(listComments(app.db, first.id).length).toBe(comments); | |
| 411 | // the value changes, and later is away a day: reported again | |
| 412 | setAt("6", new Date(base + 3 * HOUR).toISOString()); | |
| 413 | expect(hunt(app, "alice/forge", base + 6 * HOUR).filed).toBe(0); // not yet a day away | |
| 414 | expect(hunt(app, "alice/forge", base + 30 * HOUR).filed).toBe(1); | |
| 415 | expect(issuesFor()).toBe(2); | |
| 416 | expect(app.db.query("SELECT value FROM forge_setting WHERE key = 'checks.concurrency'").get()).toEqual({ value: "6" }); | |
| 417 | app.db.query("DELETE FROM forge_setting WHERE key = 'checks.concurrency'").run(); | |
| 418 | }); | |
| 419 | ||
| 420 | test("a comment saying why a setting stays keeps it too (#2001)", async () => { | |
| 421 | const owner = await createUser(app.db, { | |
| 422 | trusted: true, | |
| 423 | username: "carol", | |
| 424 | email: "[email protected]", | |
| 425 | password: "a-good-passphrase", | |
| 426 | }); | |
| 427 | if (!owner.ok) throw new Error(owner.error); | |
| 428 | app.db.query("DELETE FROM issue WHERE title LIKE 'Sentinel: Setting %'").run(); | |
| 429 | app.db.query("DELETE FROM sentinel_kept").run(); | |
| 430 | const base = Date.now(); | |
| 431 | setCapacity(app.db, "checks.concurrency", "4", alice.id); | |
| 432 | app.db.query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'checks.concurrency'").run(new Date(base - 10 * 24 * HOUR).toISOString()); | |
| 433 | hunt(app, "alice/forge", base); | |
| 434 | const issue = app.db.query<{ id: string; body: string }, []>("SELECT id, body FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Setting checks.concurrency %'").get()!; | |
| 435 | const said = addComment(app.db, { issueId: issue.id, authorId: owner.value.id, body: "Kept on purpose: P0 wants it." }); | |
| 436 | expect(said.ok).toBe(true); | |
| 437 | app.db.query("UPDATE comment SET created_at = ?2 WHERE issue_id = ?1").run(issue.id, new Date(base + 1000).toISOString()); | |
| 438 | const before = listComments(app.db, issue.id).length; | |
| 439 | expect(hunt(app, "alice/forge", base + 2 * HOUR).filed).toBe(0); | |
| 440 | expect(listComments(app.db, issue.id).length).toBe(before); | |
| 441 | expect(app.db.query("SELECT value FROM sentinel_kept WHERE key = 'setting:checks.concurrency'").get()).toEqual({ value: "4" }); | |
| 442 | setCapacity(app.db, "checks.concurrency", "1", alice.id); | |
| 443 | }); | |
| 444 | ||
| 445 | test("a comment from an agent does not keep a setting (#2001)", async () => { | |
| 446 | app.db.query("DELETE FROM issue WHERE title LIKE 'Sentinel: Setting %'").run(); | |
| 447 | app.db.query("DELETE FROM sentinel_kept").run(); | |
| 448 | const base = Date.now(); | |
| 449 | setCapacity(app.db, "checks.concurrency", "4", alice.id); | |
| 450 | app.db.query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'checks.concurrency'").run(new Date(base - 10 * 24 * HOUR).toISOString()); | |
| 451 | hunt(app, "alice/forge", base); | |
| 452 | const issue = app.db.query<{ id: string }, []>("SELECT id FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Setting checks.concurrency %'").get()!; | |
| 453 | const bot = await createUser(app.db, { trusted: true, username: "reviewbot", email: "[email protected]", password: "a-good-passphrase" }); | |
| 454 | if (!bot.ok) throw new Error(bot.error); | |
| 455 | app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(bot.value.id); | |
| 456 | addComment(app.db, { issueId: issue.id, authorId: bot.value.id, body: "Looks fine to me." }); | |
| 457 | app.db.query("UPDATE comment SET created_at = ?2 WHERE issue_id = ?1").run(issue.id, new Date(base + 1000).toISOString()); | |
| 458 | hunt(app, "alice/forge", base + 2 * HOUR); | |
| 459 | expect(app.db.query("SELECT COUNT(*) AS n FROM sentinel_kept").get()).toEqual({ n: 0 }); | |
| 460 | setCapacity(app.db, "checks.concurrency", "1", alice.id); | |
| 461 | }); | |
| 462 | ||
| 388 | 463 | test("a newly held slot hunts too", () => { |
| 389 | 464 | const run = createRun(app.db, { repoId: repo.id, kind: "deploy" }); |
| 390 | 465 | const l = lastHunt(); |
new above
offline
c.author_id <> actoraccepts any other author, including agents. Restrict to human users so a bot comment does not silently keep a setting.