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
55 changes: 0 additions & 55 deletions .github/scripts/enqueue-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,50 +47,6 @@ function arg(name) {
// runs); `gate-check.mjs` turns the same object into the `enqueue-guard` check run the ruleset requires
// (#812). One function with two readers, so the check and the printed command cannot disagree — #812's
// kill condition is a second copy of these rules, and there is none.
// #1344 (owner decision 2026-10-27): a branch an external agent owns is frozen while its write access is
// unresolved, and the list of what that means is a file on the base branch, not code: the owner peels the
// seal off by editing or deleting one JSON document, with no release of this script. The list is read at the
// pull request's BASE commit, never its head — a branch that carries its own copy could empty the list and
// un-freeze itself (#1346's shape) — and a pull request that edits the document is refused outright, so
// "edit the seal from inside" is closed too. This is a queue-entry seal, not a permission boundary: a direct
// push or an admin merge is unaffected, and the shared `Birfy` token can edit the file on `main`.
const FROZEN_PATH = '.github/agent/frozen-branches.json'
const FROZEN_REASON = 'frozen: external agent branch; owner decision pending'
const FROZEN_EDIT_REASON = 'frozen: .github/agent/frozen-branches.json is not editable by a pull request'

// Missing file, unreadable file, empty arrays: nothing is frozen. A broken read must not stop the whole
// queue, and the seal is meant to be removable — both point the same way here.
function frozenList({ gh, repo, base }) {
const out = { branches: [], markers: [] }
if (!base) return out
try {
const body = JSON.parse(gh('api', `repos/${repo}/contents/${FROZEN_PATH}?ref=${base}`))
const config = JSON.parse(Buffer.from(String(body?.content ?? ''), 'base64').toString('utf8'))
for (const key of ['branches', 'markers']) {
if (Array.isArray(config?.[key])) out[key] = config[key].map((v) => String(v).toLowerCase()).filter(Boolean)
}
} catch {
// no list, no seal
}
return out
}

// The refusal this file exists for, or `null`: the head branch starts with a listed prefix, the body carries
// a listed marker, or the diff edits the list itself. `merged` is history and is never re-judged (#943).
function frozenRefusal({ gh, repo, pull, files }) {
const touched = (files ?? []).find((f) => f && f.filename === FROZEN_PATH)
// An ADD is how the seal first arrives (this very pull request). A modify or a delete is the self-unlock
// this rule closes, whatever the branch is.
if (touched && touched.status !== 'added') return FROZEN_EDIT_REASON
if (pull?.merged) return null
const list = frozenList({ gh, repo, base: pull?.base?.sha })
const branch = String(pull?.head?.ref ?? '').toLowerCase()
const body = String(pull?.body ?? '').toLowerCase()
if (list.branches.some((prefix) => branch.startsWith(prefix))) return FROZEN_REASON
if (list.markers.some((marker) => body.includes(marker))) return FROZEN_REASON
return null
}

export function decidePull({ gh = realGh, env = process.env, repo: repoOption, pr: prOption, expectedHead, sameChangeSet } = {}) {
const list = (path) => gh('api', '--paginate', '--jq', '.[]', path).split('\n').filter(Boolean).map((line) => JSON.parse(line))
const repo = repoOption || env.GH_REPO || arg('--repo')
Expand Down Expand Up @@ -136,17 +92,6 @@ export function decidePull({ gh = realGh, env = process.env, repo: repoOption, p
// case-insensitive: `--repo thinkflowlab/flybot` used to read our own branch as a fork and print ALLOW.
const fork = String(pull.head?.repo?.full_name ?? '').toLowerCase() !== String(repo).toLowerCase()
const decision = guard({ pr, head, labels, body: pull.body, files: files.map((f) => f.filename), mechanicalRelease, fork, draft: pull.draft, merged: pull.merged, result, reports, policy })
// #1344: the seal answers before anything else can allow the entry — a well-formed PASS at the tip, an
// `agent-pr` label and a clean body change nothing here.
const frozen = frozenRefusal({ gh, repo, pull, files })
if (frozen) {
return {
decision: { ...decision, ok: false, reasons: [frozen], reason: frozen, message: `do not enqueue #${pr}: ${frozen}` },
pull,
repo,
pr,
}
}
return { decision, pull, repo, pr }
}

Expand Down
48 changes: 2 additions & 46 deletions .github/scripts/enqueue-guard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,7 @@ const withCheckpoint = (text, files) => {
return `${text}\n${checkpoint({ issue, claim, files: names.length ? names : [HOST_FILE] })}`
}

// #1344: the fixture also answers the contents read for the frozen list, at the pull request's BASE commit.
const BASE = 'c'.repeat(40)
const FROZEN_PATH = '.github/agent/frozen-branches.json'
const DEFAULT_FROZEN = { branches: ['cursor/'], markers: ['CURSOR_AGENT_PR_BODY'] }
const fakeGh = ({ body = null, raw = false, comments = [], reviews = [], labels = ['agent-pr'], files = [], head = HEAD, heads = null, author = 'opener', headRepo = 'ThinkFlowLab/FlyBot', permission = undefined, draft = false, merged = false, branch = 'dev/fixture', baseSha = BASE, baseFrozen = DEFAULT_FROZEN } = {}) => {
const fakeGh = ({ body = null, raw = false, comments = [], reviews = [], labels = ['agent-pr'], files = [], head = HEAD, heads = null, author = 'opener', headRepo = 'ThinkFlowLab/FlyBot', permission = undefined, draft = false, merged = false, branch = 'dev/fixture' } = {}) => {
// `raw` is for the tests about the checkpoint itself: they must be able to hand over a body without one.
const given = body ?? `${HEADERS}Fixes #602\n`
const text = raw ? given : withCheckpoint(given, files)
Expand All @@ -93,17 +89,13 @@ const fakeGh = ({ body = null, raw = false, comments = [], reviews = [], labels
if (permission == null || permission === 'throw') throw new Error('fake gh: permission ' + path)
return JSON.stringify({ permission, role_name: permission, user: { login: 'reviewed' } })
}
if (path.includes('/contents/')) {
if (baseFrozen === null) throw new Error('fake gh: 404 ' + path)
return JSON.stringify({ content: Buffer.from(JSON.stringify(baseFrozen)).toString('base64'), encoding: 'base64' })
}
if (path.includes('/files')) return files.map((f) => JSON.stringify(typeof f === 'string' ? { filename: f } : f)).join('\n')
if (path.includes('/comments')) return comments.map((c) => JSON.stringify(c)).join('\n')
if (path.includes('/reviews')) return reviews.map((r) => JSON.stringify(r)).join('\n')
if (/\/pulls\/\d+$/.test(path)) {
// #1112 (c): a run reads the head twice, so the fixture can answer differently the second time.
const sha = heads ? heads[Math.min(pullReads++, heads.length - 1)] : head
return JSON.stringify({ head: { sha, ref: branch, repo: { full_name: headRepo } }, base: { sha: baseSha }, user: { login: author }, body: text, labels: labels.map((name) => ({ name })), draft, merged })
return JSON.stringify({ head: { sha, ref: branch, repo: { full_name: headRepo } }, user: { login: author }, body: text, labels: labels.map((name) => ({ name })), draft, merged })
}
// Anything else — `gh pr merge` above all — is the guard doing more than checking.
throw new Error('fake gh: unexpected ' + path)
Expand Down Expand Up @@ -954,39 +946,3 @@ test('#1335: a withdrawal naming another tip leaves this tip enqueueable', () =>
const r = run({ comments: [comment(), correction({ tip: OLD })] })
assert.equal(r.ok, true, r.reasons.join('; '))
})

// ---- #1344: the frozen seal. An external agent owns `cursor/` until its write access is settled, and the
// list lives in a JSON document on the BASE branch so the owner can delete it without a code change. It is
// a queue-entry seal, not a permission boundary: a direct push or an admin merge never reads it.
test('#1344: a cursor/ branch is refused at the queue entry even with a well-formed PASS at its tip', () => {
const r = run({ branch: 'cursor/workflow-1318-c5f8', comments: [comment()] })
assert.equal(r.ok, false)
assert.ok(r.reasons.includes('frozen: external agent branch; owner decision pending'), r.reasons.join('; '))
assert.match(r.message, /^do not enqueue #566: frozen: external agent branch; owner decision pending/)
})

test('#1344: emptying the list on the base branch un-freezes the branch — the seal is removable', () => {
const frozen = run({ branch: 'cursor/workflow-1318-c5f8', comments: [comment()] })
assert.equal(frozen.ok, false)
const released = run({ branch: 'cursor/workflow-1318-c5f8', comments: [comment()], baseFrozen: { branches: [], markers: [] } })
assert.equal(released.ok, true, 'with nothing listed, the branch is judged like any other: ' + released.reasons.join('; '))
const absent = run({ branch: 'cursor/workflow-1318-c5f8', comments: [comment()], baseFrozen: null })
assert.equal(absent.ok, true, 'a base without the file freezes nobody: ' + absent.reasons.join('; '))
})

test('#1344: a body carrying the agent marker is refused on a branch that matches no prefix', () => {
const r = run({ branch: 'dev/friendly-name', body: '<!-- CURSOR_AGENT_PR_BODY_BEGIN -->\n\nFixes #602\n', comments: [comment()] })
assert.equal(r.ok, false)
assert.ok(r.reasons.includes('frozen: external agent branch; owner decision pending'), r.reasons.join('; '))
})

test('#1344: a pull request that edits the frozen list is refused, so it cannot un-freeze itself', () => {
const r = run({ branch: 'cursor/workflow-1318-c5f8', comments: [comment()], baseFrozen: { branches: [], markers: [] }, files: [HOST_FILE, { filename: FROZEN_PATH, status: 'modified' }] })
assert.equal(r.ok, false, 'an emptied list inside the pull request is not the list that is read')
assert.ok(r.reasons.includes('frozen: .github/agent/frozen-branches.json is not editable by a pull request'), r.reasons.join('; '))
})

test('#1344: adding the list is how the seal arrives — an add is not an edit', () => {
const r = run({ branch: 'dev/1344-user-only-conclusions', comments: [comment()], baseFrozen: null, files: [HOST_FILE, { filename: FROZEN_PATH, status: 'added' }] })
assert.equal(r.ok, true, r.reasons.join('; '))
})
16 changes: 1 addition & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# AGENTS.md

Instructions for AI coding agents that work on this repository. Human contributors: see [CONTRIBUTING.md](CONTRIBUTING.md), which has the same rules with more detail.
Expand Down Expand Up @@ -200,26 +200,12 @@

A review's `state` is not read at all: approvals are not a gate any more (user decision 2026-10-22), and the account cannot approve its own pull requests, so its reviews stay `COMMENTED` even for an INC — the INC on #610 was a comment rather than a review.

**One conclusion per tip.** A tip carries at most one conclusion: one `Verdict:` block naming one SHA — the whole 40 characters, because a prefix names every head that starts that way and the newest of them is the tip being judged (#1112). **A tip that moved without changing the reviewed change set keeps its conclusion** (#1317): when no conclusion names the head itself, the gate compares `(path → blob sha)` at the reviewed tip and at the head over the pull request's changed paths, and accepts the PASS only when every one matches — naming both shas and the comparison it made — while a changed blob, a path missing on either side, a truncated tree or an unreadable one refuses exactly as before. A conclusion that names the head always decides, so an INC written at the head still vetoes a carried PASS. Two conclusions at the same tip is an incident, not a tie to be broken — nobody re-verdicts to win, the Reviewer that wrote the later one retracts it in a comment, and the pull request waits. A correction reads `Correction from <Bot>: …` and carries no `Verdict:` line, so it is not a second verdict; the replacement PASS for a missing `Ran:` ([above](#gates)) is the only other body allowed at one tip. **A withdrawal is the one correction the gates read** (#1335): `Correction from <Bot>: the PASS at <sha> is withdrawn`, in the first line the way a `Verdict:` header is, joins the same newest-wins scan at the tip it names, and the gate refuses with `the PASS at <sha> was withdrawn by <Bot>`. It counts only from the Bot that wrote that PASS or from a `reviewers` login — one sentence from any other writer is prose, so a healthy pull request cannot be parked by whoever can comment. A later PASS at the tip supersedes it, exactly as a later FAIL vetoes an earlier PASS, and a correction quoted inside a body is a quote, not a withdrawal. **A GitHub bot account's `Verdict:` is never a conclusion** — `user.type` is `Bot`, or the login ends `[bot]` — whatever its body says (#943, `cursor[bot]` at #940's tip); the rule is the account type and not a login list, because every Bot writes as the shared `Birfy` account and a list containing it would void the whole pipeline, so the real Reviewer voids such a report at that tip with `Correction from Reviewer:`.
**One conclusion per tip.** A tip carries at most one conclusion: one `Verdict:` block naming one SHA — the whole 40 characters, because a prefix names every head that starts that way and the newest of them is the tip being judged (#1112). **A tip that moved without changing the reviewed change set keeps its conclusion** (#1317): when no conclusion names the head itself, the gate compares `(path → blob sha)` at the reviewed tip and at the head over the pull request's changed paths, and accepts the PASS only when every one matches — naming both shas and the comparison it made — while a changed blob, a path missing on either side, a truncated tree or an unreadable one refuses exactly as before. A conclusion that names the head always decides, so an INC written at the head still vetoes a carried PASS. Two conclusions at the same tip is an incident, not a tie to be broken — nobody re-verdicts to win, the Reviewer that wrote the later one retracts it in a comment, and the pull request waits. A correction reads `Correction from <Bot>: …` and carries no `Verdict:` line, so it is not a second verdict; the replacement PASS for a missing `Ran:` ([above](#gates)) is the only other body allowed at one tip. **A withdrawal is the one correction the gates read** (#1335): `Correction from <Bot>: the PASS at <sha> is withdrawn`, in the first line the way a `Verdict:` header is, joins the same newest-wins scan at the tip it names, and the gate refuses with `the PASS at <sha> was withdrawn by <Bot>`. It counts only from the Bot that wrote that PASS or from a `reviewers` login — one sentence from any other writer is prose, so a healthy pull request cannot be parked by whoever can comment. A later PASS at the tip supersedes it, exactly as a later FAIL vetoes an earlier PASS, and a correction quoted inside a body is a quote, not a withdrawal. **A GitHub bot account's `Verdict:` is never a conclusion** — whatever its body says (#943, `cursor[bot]` at #940's tip). The rule is the account type, read the other way round from a login list: only `user.type === 'User'` earns the name of a conclusion, so a bot account (`Bot`, or a login ending `[bot]`) **and a report whose `user` carries no `type` at all** are both refused — the missing field is the shape the impersonation takes, so it is read fail-closed rather than skipped (#1344). The type and not a list, because every Bot writes as the shared `Birfy` account and a list containing it would void the whole pipeline. Such a report never enters the refusal's own sentence: the reason stays `no Verifier report names the tip <sha>`, and the account that wrote it, with the text it wrote, is kept as a note in the decision's `judged` field, so the sentence names the tip rather than the bot (#1344, #1350). The real Reviewer voids such a report at that tip with `Correction from Reviewer:`.

**Judged on the merge tree, never on the pull request page.** The page answers "does this commit pass alone"; the queue answers "does this commit pass merged onto the current `main`", and the second one is the merge decision. #759 was green on every check, was kicked out of the queue twice, and went red on `main` at `core/test/follow-through.test.js:264`. A red queue entry that names files you did not touch is the stale base of [step 2](#pipeline-contract), not bad luck: fix the base, do not re-enqueue blindly.

**A merged pull request's body is not a control.** GitHub evaluates `Fixes #N` once, when the merge lands. Editing the body of a merged pull request afterwards closes nothing and triggers no guard; it only makes the record disagree with what merged. If the issue is still open, close it with a comment naming the pull request — and if the merge itself was wrong, say so on the issue.

#### Untrusted external agent branches

An external agent can open a pull request here under its own branch, and its write access is not settled (owner decision 2026-10-27). What counts as one of those branches is listed in [`.github/agent/frozen-branches.json`](.github/agent/frozen-branches.json) and read at the pull request's **base** commit — today the prefix `cursor/` and any body marker starting `CURSOR_AGENT_PR_BODY` (`<!-- CURSOR_AGENT_PR_BODY_BEGIN -->` above all). `enqueue-guard` refuses such a pull request at the queue entry with `frozen: external agent branch; owner decision pending`, whatever conclusion its tip carries, and it refuses one whose diff edits that file — so the list cannot be emptied from inside the branch it judges. Deleting the file (or emptying both arrays) on `main` releases everything. This is a queue-entry seal, not a permission boundary: a direct push or an admin merge never reads it, and the shared `Birfy` token can edit the file.

Until it is lifted, on such a branch:

- **Do not adopt its `patches/ds-bot/*.patch`.** Those are an external agent's output, not this pipeline's generated files.
- **Do not rebase onto it**, and do not use it as a base for any branch or any review.
- **Do not write a conclusion, a comment or a label on it.** No `Verdict:`, no `Correction`, no `agent-*`: the record stays as the external agent left it.

It is also never the baseline for `generate`. Anyone who has to touch `patches/` starts from `git checkout 41decf09 -- patches/ds-bot` and regenerates from there, never from a branch an external agent produced.

本条自 `cursor` 云端 agent 的写权限被撤销之日起失效,由 Chief 删除。

#### The checkpoint block: a hand-off the next Bot can read (#1202)

A pull request hands itself over in one block in the body, under `## Checkpoint`. The body is the only store: it becomes the squash commit message, so the block cannot fall out of step with the pull request the way a comment or a label can.
Expand Down
Loading