Public: anyone can read this; the crew can change it.
A shared-red sentinel issue names the pulls that failed the test and quotes the failure, so a builder can reproduce it #8293
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8286-a-shared-red-sentinel-issue-names-the-pu → main 2 commits fork 3 threads · 3 unresolved
3 files
changed +335
−7
src/crew/sentinel.ts +64 −7
| @@ -32,7 +32,15 @@ import { spans } from "../kernel/degraded_history.ts"; | ||
| 32 | 32 | import { QUEUE_WAIT_MAX_MS } from "../kernel/vitals.ts"; |
| 33 | 33 | import { STALE_AFTER_MS } from "../runs/lease.ts"; |
| 34 | 34 | import { isOutageSummary } from "./network.ts"; |
| 35 | import { pullsFailedSince, SHARED_PULLS, SHARED_WINDOW_MS } from "../checks/shared_red.ts"; | |
| 35 | import { | |
| 36 | assertionLines, | |
| 37 | mainVerdict, | |
| 38 | pullsFailedSince, | |
| 39 | SHARED_PULLS, | |
| 40 | SHARED_WINDOW_MS, | |
| 41 | sharedRuns, | |
| 42 | } from "../checks/shared_red.ts"; | |
| 43 | import { readRunLogTail } from "../runs/log.ts"; | |
| 36 | 44 | import { clip } from "../text/clip.ts"; |
| 37 | 45 | |
| 38 | 46 | const log = createLog("sentinel"); |
| @@ -367,7 +375,7 @@ export function repeatedErrors(now: number): Finding[] { | ||
| 367 | 375 | * Each cause whose spans total more than ten minutes in the day ending at |
| 368 | 376 | * `now`, quoting its sentence. Shorter totals stay in the history. |
| 369 | 377 | */ |
| 370 | export function degradedCauses(db: Database, now: number, forgeRepoId?: string): Finding[] { | |
| 378 | export function degradedCauses(db: Database, now: number, forgeRepoId?: string, dataDir?: string): Finding[] { | |
| 371 | 379 | const from = now - DAY_MS; |
| 372 | 380 | const byCause = new Map<string, { sentence: string; href: string; first: number; opened: number; ms: number }>(); |
| 373 | 381 | for (const s of spans(db, from, now)) { |
| @@ -397,6 +405,7 @@ export function degradedCauses(db: Database, now: number, forgeRepoId?: string): | ||
| 397 | 405 | `first open: ${new Date(d.first).toISOString()}`, |
| 398 | 406 | `total: ${minutes} minutes in the day`, |
| 399 | 407 | `href: ${d.href}`, |
| 408 | ...sharedLines(db, cause, now, forgeRepoId, dataDir), | |
| 400 | 409 | ], |
| 401 | 410 | pillar: "P0 Trust: the forge says when it is not well, and is well again", |
| 402 | 411 | lookFor: `\`${cause}\` stays out of the degraded list for a day, or holds under ${DEGRADED_DAY_MS / 60_000} minutes.`, |
| @@ -436,7 +445,7 @@ export function ownCause(cause: string, forgeRepoId: string | undefined): boolea | ||
| 436 | 445 | * at once, not when the day has added up. The finding shares its key with |
| 437 | 446 | * `degradedCauses`, so one cause is one issue. |
| 438 | 447 | */ |
| 439 | export function budgetBreaches(db: Database, now: number, forgeRepoId?: string): Finding[] { | |
| 448 | export function budgetBreaches(db: Database, now: number, forgeRepoId?: string, dataDir?: string): Finding[] { | |
| 440 | 449 | const out: Finding[] = []; |
| 441 | 450 | for (const s of spans(db, now - DAY_MS, now)) { |
| 442 | 451 | if (s.closedAt !== null || !BREACH_CAUSE.test(s.cause) || !ownCause(s.cause, forgeRepoId)) continue; |
| @@ -449,6 +458,7 @@ export function budgetBreaches(db: Database, now: number, forgeRepoId?: string): | ||
| 449 | 458 | `since: ${new Date(s.openedAt).toISOString()}`, |
| 450 | 459 | `href: ${s.href}`, |
| 451 | 460 | ...runLogLines(db, s.cause), |
| 461 | ...sharedLines(db, s.cause, now, forgeRepoId, dataDir), | |
| 452 | 462 | ], |
| 453 | 463 | pillar: "P0 Trust: the forge says when it is not well, and is well again", |
| 454 | 464 | lookFor: `\`${s.cause}\` is gone from the degraded list and the cause is fixed, not waited out.`, |
| @@ -476,16 +486,63 @@ function runLogLines(db: Database, cause: string): string[] { | ||
| 476 | 486 | return run === null ? [] : [`run ${id}: ${run.state}${run.summary ? `, ${oneLine(run.summary)}` : ""}`]; |
| 477 | 487 | } |
| 478 | 488 | |
| 489 | /** How many log lines a shared finding reads from the end of its newest run's log: never the whole file. */ | |
| 490 | const SHARED_LOG_TAIL = 400; | |
| 491 | ||
| 492 | /** | |
| 493 | * What a builder needs to reproduce a `shared:` cause (#8286): the pulls | |
| 494 | * and runs that failed its test, newest first, the base the newest merged, | |
| 495 | * the newest one's assertion lines from the tail of its log, and whether | |
| 496 | * main's newest check passes the test. A private repository's pulls and log | |
| 497 | * lines stay out of a finding filed anywhere but in that repository. | |
| 498 | */ | |
| 499 | function sharedLines( | |
| 500 | db: Database, | |
| 501 | cause: string, | |
| 502 | now: number, | |
| 503 | forgeRepoId: string | undefined, | |
| 504 | dataDir: string | undefined, | |
| 505 | ): string[] { | |
| 506 | if (!cause.startsWith("shared:")) return []; | |
| 507 | const repoId = cause.split(":")[1] ?? ""; | |
| 508 | const repo = db | |
| 509 | .query<{ visibility: string }, [string]>("SELECT visibility FROM repo WHERE id = ?1") | |
| 510 | .get(repoId); | |
| 511 | if (repo === null || (repo.visibility !== "public" && repoId !== forgeRepoId)) return []; | |
| 512 | const found = sharedRuns(db, cause, now); | |
| 513 | if (found === undefined) return []; | |
| 514 | const [newest] = found.runs; | |
| 515 | const out = [ | |
| 516 | `test: ${oneLine(found.test)}`, | |
| 517 | ...found.runs.map((r) => `failed: pull #${r.pull}, check run ${r.runId}`), | |
| 518 | ]; | |
| 519 | if (newest !== undefined) { | |
| 520 | out.push(`base: ${newest.baseOid || "none recorded"} (run ${newest.runId})`); | |
The |
||
| 521 | const quoted = | |
| 522 | dataDir === undefined ? [] : assertionLines(readRunLogTail(dataDir, newest.runId, SHARED_LOG_TAIL).lines, found.test); | |
| 523 | out.push(...quoted.map((l) => `log: ${clip(oneLine(l), QUOTE_MAX)}`)); | |
| 524 | } | |
| 525 | const main = mainVerdict(db, repoId, found.test); | |
| 526 | out.push( | |
| 527 | main === undefined | |
| 528 | ? "main: no check of main recorded" | |
| 529 | : main.passes | |
| 530 | ? `main: passes on main's newest check, run ${main.runId} at ${main.commitOid}` | |
| 531 | : `main: fails on main's newest check too, run ${main.runId} at ${main.commitOid}`, | |
| 532 | ); | |
| 533 | return out; | |
| 534 | } | |
| 535 | ||
| 479 | 536 | /** Everything the sentinel reads, in one pass. */ |
| 480 | export function findFaults(db: Database, now = Date.now(), forgeRepoId?: string): Finding[] { | |
| 537 | export function findFaults(db: Database, now = Date.now(), forgeRepoId?: string, dataDir?: string): Finding[] { | |
| 481 | 538 | const found = [ |
| 482 | ...budgetBreaches(db, now, forgeRepoId), | |
| 539 | ...budgetBreaches(db, now, forgeRepoId, dataDir), | |
| 483 | 540 | ...heldSlots(db, now), |
| 484 | 541 | ...strayTriggers(db), |
| 485 | 542 | ...ledgerStreaks(db, now), |
| 486 | 543 | ...runStreaks(db), |
| 487 | 544 | ...repeatedErrors(now), |
| 488 | ...degradedCauses(db, now, forgeRepoId), | |
| 545 | ...degradedCauses(db, now, forgeRepoId, dataDir), | |
| 489 | 546 | ]; |
| 490 | 547 | const keys = new Set<string>(); |
| 491 | 548 | return found |
| @@ -746,7 +803,7 @@ export function hunt( | ||
| 746 | 803 | let commented = 0; |
| 747 | 804 | const [owner = "", name = ""] = repoPath.split("/"); |
| 748 | 805 | const repo = getRepo(app.db, owner, name); |
| 749 | const findings = findFaults(app.db, now, repo?.id); | |
| 806 | const findings = findFaults(app.db, now, repo?.id, app.config.dataDir); | |
| 750 | 807 | const actor = sentinelActor(app.db); |
| 751 | 808 | if (repo !== undefined && actor !== undefined) { |
| 752 | 809 | for (const f of findings) { |
new above
offline
The slice to SHARED_RUNS_NAMED is untested: record six failed pulls and assert only the newest five are named.