diff --git a/.github/labels.json b/.github/labels.json index fe79f232e..9a614462a 100644 --- a/.github/labels.json +++ b/.github/labels.json @@ -8,8 +8,8 @@ {"name": "needs-info", "color": "D4C5F9", "description": "Waiting for the reporter or a maintainer decision"}, {"name": "needs-human", "color": "B60205", "description": "The agent loop stopped; a person must decide (reason in the last comment)"}, {"name": "hold", "color": "000000", "description": "A person froze this issue or PR; agents and automation leave it alone"}, - {"name": "risk:T0", "color": "C2E0C6", "description": "Risk tier T0 (docs only), set by the controller from the diff; never by hand"}, - {"name": "risk:T1", "color": "BFD4F2", "description": "Risk tier T1 (client, tests, generated files), set by the controller from the diff"}, - {"name": "risk:T2", "color": "FBCA04", "description": "Risk tier T2 (host/plugin code or an unnamed path), set by the controller from the diff"}, - {"name": "risk:T3", "color": "D93F0B", "description": "Risk tier T3 (a PASS at the tip and green CI), set by the controller from the diff"} + {"name": "risk:T0", "color": "C2E0C6", "description": "T0 (docs only). Never by hand; advisory, the merge gate recomputes it"}, + {"name": "risk:T1", "color": "BFD4F2", "description": "T1 (client, tests, generated files). Advisory; the merge gate recomputes it"}, + {"name": "risk:T2", "color": "FBCA04", "description": "T2 (host/plugin code or an unnamed path). Advisory; the merge gate recomputes it"}, + {"name": "risk:T3", "color": "D93F0B", "description": "T3 (a PASS at the tip and green CI). Advisory; the merge gate recomputes it"} ] diff --git a/.github/scripts/risk-label-docs.test.mjs b/.github/scripts/risk-label-docs.test.mjs new file mode 100644 index 000000000..749c6c549 --- /dev/null +++ b/.github/scripts/risk-label-docs.test.mjs @@ -0,0 +1,41 @@ +// #629: the `risk:T*` label is written by a poll, so a PR a person reads can carry none. These checks +// keep the written promise in step with what a poll can deliver — otherwise an absent label reads as a +// low tier (fail open) or as a reason to block (stall), and readers conclude the controller is broken. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const read = (rel) => readFileSync(new URL(rel, import.meta.url), 'utf8') +const section = (text, heading) => { + const start = text.indexOf(heading) + assert.ok(start >= 0, `no section ${heading}`) + const end = text.indexOf('\n### ', start + heading.length) + return text.slice(start, end < 0 ? undefined : end) +} + +const labels = JSON.parse(read('../labels.json')) +const agents = read('../../AGENTS.md') +const controller = read('../../docs/controller.md') + +test('labels.json: each risk tier says it is advisory and names the recomputing gate', () => { + const risk = labels.filter((l) => /^risk:T\d$/.test(l.name)) + assert.deepEqual(risk.map((l) => l.name), ['risk:T0', 'risk:T1', 'risk:T2', 'risk:T3']) + for (const l of risk) { + assert.match(l.description, /advisory/i, `${l.name}: a person reading the label must see it is advisory`) + assert.match(l.description, /merge gate recomputes/i, `${l.name}: name the authority, not the poll`) + } +}) + +test('AGENTS.md: an absent risk label is not a tier and not a reason to block', () => { + const s = section(agents, '### Issue labels') + assert.match(s, /`risk:T\*` label is advisory, and never the tier a decision uses/, 'the labels section must say the label is advisory') + assert.match(s, /never as T0\/T1 and never as a reason to block/, 'say what a missing label does not mean') + assert.match(s, /merge-gate` recomputes from the diff/, 'name the tier that actually decides') +}) + +test('docs/controller.md: the label is a poll artifact, so it can be absent', () => { + const s = section(controller, '## Limits') + assert.match(s, /A missing `risk:T\*` is not a tier/, 'the Limits section must say a missing label is not a tier') + assert.match(s, /recomputes the tier from the diff/, 'point at the gate, not the label') + assert.match(s, /2\.5–6 hours apart/, 'keep the observed run spacing that makes the poll unreliable (#629)') +}) diff --git a/AGENTS.md b/AGENTS.md index d1b3a7b60..0b75b6dce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,7 @@ Decided by the owner (Danyang, GitHub `Birfy`) on 2026-10-18/19. Four scheduled - **Source:** `self-test`. - **State:** `needs-info` (spec unclear), `needs-human` (needs a design decision from the owner; the comment says what), `awaiting-approval` (legacy: a PR a person must decide on; routine T2/T3 PRs no longer wait for a click), `agent-ready`, `agent-working`, `agent-pr`, `hold`. - **Controller-managed, never set by hand:** `risk:T0` to `risk:T3`, `agent-task`, `status:done`. +- **A `risk:T*` label is advisory, and never the tier a decision uses.** The controller is a poll: GitHub delays scheduled runs by hours (2.5–6 h spacing observed on this repository, [#629](https://github.com/ThinkFlowLab/FlyBot/issues/629)), and a fork PR's `pull_request` run has a read-only token, so it cannot label one at all. An open PR can therefore carry no tier until the next run, and a PR opened and merged between two runs never gets one. Read a missing label as "not polled yet" — never as T0/T1 and never as a reason to block. The tier that decides is the one `merge-gate` recomputes from the diff at merge time, and `risk-tier.yml` reports the same tier on every PR. - `hold` means automation leaves the item alone. ### Triage: when an issue is `agent-ready` diff --git a/docs/controller.md b/docs/controller.md index 46686876f..431f251bb 100644 --- a/docs/controller.md +++ b/docs/controller.md @@ -12,7 +12,7 @@ files of a PR are read through the API and judged by the same rules as the risk | Rule | Effect | | --- | --- | -| Every open PR (50 most recently updated, not `hold`) | `risk:T0`..`risk:T3` from the risk gate, replacing a stale `risk:T*`. If the files cannot be read, it changes nothing | +| Every open PR (50 most recently updated, not `hold`) | `risk:T0`..`risk:T3` from the risk gate, replacing a stale `risk:T*`. If the files cannot be read, it changes nothing. **Advisory only:** a label exists once a run has seen the PR, and no merge decision reads it (see [Limits](#limits)) | | An agent's PR | adds `agent-pr`. A PR is an agent's when its author is in `agentAuthors`, or its branch in this repository starts with an `agentBranchPrefixes` entry (`agent/`), or a branch in this repository says `Fixes #N` for an issue that is `agent-ready` or `agent-working`. A fork PR counts by author only. The label is never removed | | An issue labeled `agent-ready` or `agent-working` | removes `agent-ready` **and** `agent-working` when the last `agent-ready` was set by someone who is not a maintainer, or when someone who is not a maintainer edited the text or title after it. Re-labeling by a maintainer re-approves it | | A claim (`agent-working`) | removes `agent-working` when it is older than `claimTtlHours` (6) and no open PR that says `Fixes #N` has a commit or was opened within that time | @@ -61,6 +61,7 @@ The script and `controller.json` come from the default branch (`checkout` of `de - Labels only: it leaves no comment, so the reason for a removal is in the job summary (the timeline shows `github-actions` removed the label). - Events caused by `GITHUB_TOKEN` start no workflows, so nothing chains from its labels; it runs on the schedule. - GitHub stops scheduled workflows of a public repository after 60 days without repository activity. +- **A missing `risk:T*` is not a tier.** GitHub runs scheduled workflows late under load: the 30-minute cron produced runs 2.5–6 hours apart on this repository (48 runs, all `success`, none skipped — #629), so a label may arrive hours after the PR is opened, and a PR opened and merged between two runs gets none at all. A fork PR cannot be labelled by its own `pull_request` run either (read-only `GITHUB_TOKEN`), which is why the external ones (#611, #619) lack it by construction. Nothing depends on the label: `merge-gate` recomputes the tier from the diff with the base copy of `risk-gate.mjs`, and `risk-tier.yml` reports the same tier on every PR, forks included. Read an absent label as "the poll has not seen this PR yet" — never as T0/T1 and never as a reason to block. - 50 PRs and 50 issues per label per run (`controller.json`); about 3 API calls per PR per run. ## Try it by hand (read-only)