Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/labels.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
]
41 changes: 41 additions & 0 deletions .github/scripts/risk-label-docs.test.mjs
Original file line number Diff line number Diff line change
@@ -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)')
})
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 2 additions & 1 deletion docs/controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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)
Expand Down
Loading