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

Sentinel: release was degraded 178 minutes today: Release 0.7.479 is unproven: the public URL never answered as 0.7.479. It is proven again at 15:55 UTC. #10267

Merged opened this

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

Merged

scout/forge:scout/10248-sentinel-release-was-degraded-178-minute → main 10 commits fork 1 thread · 1 unresolved

  • Merged. scout/10248-sentinel-release-was-degraded-178-minute was merged into main as 3bb2628.

Review

Sign in to review this change.
7 files changed +80 −8
scripts/unreached-allow.json +1
@@ -15,6 +15,7 @@
1515 "src/flow/review_rounds.ts",
1616 "src/flow/shared_red.ts",
1717 "src/flow/stale_fold.ts",
18 "src/spawn/pod.ts",
1819 "src/tui/gallery.ts",
1920 "src/tui/ui/qr.ts",
2021 "src/web/highlight-client.ts",
src/kernel/vitals.ts +2 −1
@@ -32,6 +32,7 @@ import { lastMirror, mirrorNeed } from "../snapshot/mirror.ts";
3232 import { lastSnapshotOutcome, snapshotNeed } from "../snapshot/outcome.ts";
3333 import { drillNeed, firstSeen as drillFirstSeen, lastVerdict } from "../snapshot/verdict.ts";
3434 import { proofNeed } from "../release/prove.ts";
35 import { running as runningVersion } from "./version.ts";
3536 import { rollbackNeed } from "../release/launch.ts";
3637 import { behindStanding } from "../release/behind.ts";
3738 import { newestDrills } from "../core/domain_drills.ts";
@@ -515,7 +516,7 @@ export function vitals(input: VitalsInput): Vitals {
515516 if (behind !== undefined) need("mirror", behind.sentence, behind.since, "/settings/backups");
516517
517518 // A release the forge could not prove against its own public URL is a card until a release does.
518 const unproven = proofNeed(input.dataDir);
519 const unproven = proofNeed(input.dataDir, runningVersion().version);

This argument is the fix, and nothing tests it. Add a vitals() test (test/vitals-release.test.ts) that fails when this line goes back to proofNeed(input.dataDir): a failed proof for another version gives no release card, and one for the running version still does.

519520 if (unproven !== undefined) need("release", unproven.sentence, unproven.since, "/board");
520521
521522 // A roll the image's launcher undid is a card until a later version reads ready.
src/release/prove.ts +16 −6
@@ -16,7 +16,8 @@
1616 * #10168, #10222). After that the plain doubling takes over, so a version
1717 * that never answers costs its public URL a few loads an hour. When a newer
1818 * release answers instead, this process is being replaced: it keeps nothing
19 * and never polls again (#10138).
19 * and does not poll again for `GIVE_UP_MS` (#10138); still running after
20 * that, it was not replaced and proves its version again (#10248).
2021 */
2122
2223 import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -82,8 +83,14 @@ export function isNewer(a: string, b: string): boolean {
8283 return false;
8384 }
8485
85 /** Versions this process saw replaced by a newer one; never polled again. */
86 const superseded = new Set<string>();
86 /**
87 * Versions this process saw a newer release answer for, and until when it
88 * does not poll for them again. A replaced process is gone well within
89 * `GIVE_UP_MS`; one still running after it was not replaced (a roll the
90 * launcher undid), so it proves its version again rather than leave the last
91 * kept failure, and its past retry time, standing for hours (#10248).
92 */
93 const superseded = new Map<string, number>();
8794
8895 const proofFile = (dataDir: string): string => join(dataDir, "release", "proof.json");
8996
@@ -107,9 +114,12 @@ function keep(dataDir: string, proof: Proof): Proof {
107114 }
108115
109116 /** The card an unproven release is, saying when it is tried again; undefined while the last verdict is a pass. */
110 export function proofNeed(dataDir: string): { sentence: string; since: string } | undefined {
117 export function proofNeed(dataDir: string, running?: string): { sentence: string; since: string } | undefined {
111118 const p = lastProof(dataDir);
112119 if (p === undefined || p.ok) return undefined;
120 // Only the process running a version proves it: a failure kept for another version is not
121 // this process's to report, and stood for hours after a roll or a newer boot (#10248).
122 if (running !== undefined && running !== UNSTAMPED && p.version !== running) return undefined;
113123 const next = Date.parse(p.at) + retryAfter(p.failures ?? 1, p.slow === true, p.gaveUp === true);
114124 // When it is tried again, so a person reading the card knows it is not stuck.
115125 const again = Number.isFinite(next) ? ` It is proven again at ${new Date(next).toISOString().slice(11, 16)} UTC.` : "";
@@ -154,7 +164,7 @@ export function proofDue(last: Proof | undefined, version: string, now: number):
154164 export async function proveRelease(o: ProveOptions): Promise<Proof | undefined> {
155165 const now = o.now ?? Date.now;
156166 const last = lastProof(o.dataDir);
157 if (superseded.has(o.version) || !proofDue(last, o.version, now())) return undefined;
167 if (now() < (superseded.get(o.version) ?? -Infinity) || !proofDue(last, o.version, now())) return undefined;
158168 const sleep = o.sleep ?? Bun.sleep;
159169 const doFetch = o.fetch ?? fetch;
160170 const failedBefore = last?.version === o.version && !last.ok ? last : undefined;
@@ -186,7 +196,7 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined>
186196 if (now() + POLL_MS > deadline) {
187197 // A newer release answers: this process is being replaced, and a failure kept now would stand over the newer version's own proof.
188198 if (isNewer(seen, o.version)) {
189 superseded.add(o.version);
199 superseded.set(o.version, now() + GIVE_UP_MS);
190200 return undefined;
191201 }
192202 return finish(`the public URL never answered as ${o.version}`, true, true);
test/crew-loop.test.ts +4
@@ -256,6 +256,10 @@ describe("a crew reviewer's changes-requested wakes the builder", () => {
256256 concurrency: 0,
257257 budgetUsd: 1,
258258 runTimeoutMs: 60_000,
259 // No tick inside the suite: the first one runs the autonomy pass, whose
260 // `moveRequested` queues its own follow-up on a standing verdict, and on
261 // a loaded shard it landed mid-test and moved the counts these assert.
262 tickMs: 3_600_000,
259263 });
260264 });
261265
test/release-boot.test.ts +2
@@ -96,6 +96,8 @@ test("this tree, shipped as the image ships it (src/ alone), boots", async () =>
9696 const repo = join(import.meta.dir, "..");
9797 cpSync(join(repo, "src"), join(image, "src"), { recursive: true });
9898 for (const file of ["package.json", "tsconfig.json"]) cpSync(join(repo, file), join(image, file));
99 // The Dockerfile copies the OpenCode plugin too: src/http/plugin_routes.ts reads it at boot (#10274).
100 cpSync(join(repo, "plugins", "opencode"), join(image, "plugins", "opencode"), { recursive: true });
99101 expect(existsSync(join(image, "scripts"))).toBe(false);
100102 const result = await proveBoot({ tree: image, version: "9.9.9", budgetMs: 60_000 });
101103 expect(result).toMatchObject({ ok: true });
test/release-prove.test.ts +29 −1
@@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
99 import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
12 import { isNewer, lastProof, proofDue, proofNeed, proveRelease, proveRunning, QUICK_RETRIES, retryAfter, RETRY_MS, startProving, type Proof } from "../src/release/prove.ts";
12 import { GIVE_UP_MS, isNewer, lastProof, proofDue, proofNeed, proveRelease, proveRunning, QUICK_RETRIES, retryAfter, RETRY_MS, startProving, type Proof } from "../src/release/prove.ts";
1313 import { owned } from "../src/crew/incidents.ts";
1414
1515 const CSS = '<html><link rel="stylesheet" href="/a.css"></html>';
@@ -179,6 +179,34 @@ describe("proving a release", () => {
179179 expect(again.hits).toEqual([]); // a replaced process does not poll again
180180 });
181181
182 test("a process still running after a newer release answered proves its version again (#10248)", async () => {
183 let t = 0;
184 const clock = { now: () => t, sleep: async (ms: number) => void (t += ms) };
185 // 1.4.0 failed once, then a newer release answered for a moment and was undone.
186 expect((await run("1.4.0", site("1.3.0").fake, clock))?.ok).toBe(false);
187 t += 60_000;
188 expect(await run("1.4.0", site("1.4.1").fake, clock)).toBeUndefined();
189 expect(proofNeed(dir, "1.4.0")).toBeDefined();
190 const soon = site("1.4.0");
191 t += GIVE_UP_MS - 60_000;
192 expect(await run("1.4.0", soon.fake, clock)).toBeUndefined();
193 expect(soon.hits).toEqual([]);
194 t += 60_000;
195 const later = site("1.4.0");
196 expect((await run("1.4.0", later.fake, clock))?.ok).toBe(true);
197 expect(later.hits.length).toBeGreaterThan(0);
198 expect(proofNeed(dir, "1.4.0")).toBeUndefined();
199 });
200
201 test("a failure kept for another version than the running one is no card (#10248)", async () => {
202 let t = 0;
203 await run("1.0.2", site("1.0.0").fake, { now: () => t, sleep: async (ms: number) => void (t += ms) });
204 expect(proofNeed(dir)).toBeDefined();
205 expect(proofNeed(dir, "1.0.2")).toBeDefined();
206 expect(proofNeed(dir, "1.0.3")).toBeUndefined();
207 expect(proofNeed(dir, "dev")).toBeDefined();
208 });
209
182210 test("an older or non-numeric answer is still a kept failure (#10138)", async () => {
183211 for (const [mine, answered] of [["1.0.1", "1.0.0"], ["1.0.2", "dev"]] as const) {
184212 let t = 0;
test/vitals-release.test.ts +26
@@ -48,3 +48,29 @@ test("a passing proof clears it", () => {
4848 keep({ version: "9.9.10", at: "2026-10-18T11:00:00.000Z", ok: true });
4949 expect(card()).toBeUndefined();
5050 });
51
52 // The process running a version reports only that version's failure: one kept
53 // for another version (a roll the launcher undid, a newer boot) is no card (#10248).
54 const runningAs = (version: string, body: () => void) => {
55 const was = process.env["FORGE_VERSION"];
56 process.env["FORGE_VERSION"] = version;
57 try {
58 body();
59 } finally {
60 if (was === undefined) delete process.env["FORGE_VERSION"];
61 else process.env["FORGE_VERSION"] = was;
62 }
63 };
64
65 test("a failed proof kept for another version is no card for the running one", () => {
66 keep({ version: "0.7.479", at: "2026-11-09T12:57:00.000Z", ok: false, failure: "the public URL never answered as 0.7.479" });
67 runningAs("0.7.478", () => expect(card()).toBeUndefined());
68 });
69
70 test("a failed proof for the running version is still a card", () => {
71 keep({ version: "0.7.479", at: "2026-11-09T12:57:00.000Z", ok: false, failure: "the public URL never answered as 0.7.479" });
72 runningAs("0.7.479", () =>
73 expect(card()).toMatchObject({ href: "/board", since: "2026-11-09T12:57:00.000Z" }),
74 );
75 runningAs("v0.7.479", () => expect(card()?.sentence).toStartWith("Release 0.7.479 is unproven"));
76 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs