diff --git a/.agents/issues/GATE-PR-BODY-TRAILERS/ISSUE-LOCAL-01M298Q7PPM145DQA5C30GRYJA.md b/.agents/issues/GATE-PR-BODY-TRAILERS/ISSUE-LOCAL-01M298Q7PPM145DQA5C30GRYJA.md new file mode 100644 index 000000000..669af7e50 --- /dev/null +++ b/.agents/issues/GATE-PR-BODY-TRAILERS/ISSUE-LOCAL-01M298Q7PPM145DQA5C30GRYJA.md @@ -0,0 +1,19 @@ +ID: ISSUE-LOCAL-01M298Q7PPM145DQA5C30GRYJA +Title: agent-pr-body.py accepts a body whose Closes #N contradicts the canonical local record +Row: GATE-PR-BODY-TRAILERS +State: OPEN +Kind: bug +GitHub: - +Mirror: PENDING +Availability: FULL +Created: 2026-09-11 +Updated: 2026-09-11 +Closed: - + +## Problem + +The repository sets squash_merge_commit_message = PR_BODY, so a body's 'Closes #N' closes the GitHub mirror the moment the squash lands. Local files under .agents/issues are the issue authority. Nothing checked that the branch also carries ISSUE-GH-N.md reading State: CLOSED, so the two authorities diverge at the merge and the authoritative half is left reading OPEN. Observed on four pull requests in one pass: #3101 (Closes #3098 while the record read OPEN), #3095 (Closes #3092 while its own spec gate table read FAILING), #3096 (five closing keywords against four OPEN records and one issue with no local record at all), and #3097 (Closes #3093 against a spec saying the row is not ready to land). check-agent-record.py cannot catch it: it is offline and never sees a pull request body, and it silently continues on an unresolvable bare #N. + +## Resolution + +- diff --git a/.agents/specs/gate-pr-body-trailers.md b/.agents/specs/gate-pr-body-trailers.md index 5dd5cb8b3..b0a9e0864 100644 --- a/.agents/specs/gate-pr-body-trailers.md +++ b/.agents/specs/gate-pr-body-trailers.md @@ -1,9 +1,11 @@ # GATE-PR-BODY-TRAILERS: read the bytes the squash will land, in the operator's own shell **Issue:** [#1263](https://github.com/mudler/vllm.cpp/issues/1263). -**Kind:** one new operator command plus the procedure that names it. No checker -rule changes and no checker is edited: `scripts/check-commit-trailers.py` is not -touched by this row. +**Kind:** one new operator command plus the procedure that names it, and (since +2026-09-11) one rule the command applies itself. `scripts/check-commit-trailers.py` +is still not touched by this row; the added rule lives in `scripts/agent-pr-body.py`, +which this row owns, and it reads the canonical local issue records rather than any +commit trailer. **Row:** `GATE-PR-BODY-TRAILERS`. ## Now @@ -219,6 +221,8 @@ reaches a network. | `test_the_landing_procedure_names_the_command` | `AGENTS.md` and `.agents/workflow.md` name it, so the entry point cannot be deleted silently. | | `test_the_suite_is_registered_where_gates_run` | A suite nothing runs is not a gate. | | `test_the_spec_table_names_exactly_these_cases` | This table is compared with the loaded suite, not sampled, so it cannot go stale inside the change that writes it. | +| `test_closing_an_issue_with_no_local_record_is_refused` | A body's closing keyword closes the GitHub mirror the moment the squash lands, so the branch must also carry the local record reading `State: CLOSED`; three landed pull requests left the authoritative half saying OPEN. | +| `test_a_bare_reference_without_a_closing_keyword_is_accepted` | The gate is narrow deliberately. Open pull requests carry six to ten bare `#N` citations each, most without a local record, and failing those would fire the gate on ordinary work rather than on the defect. | ## Gates diff --git a/scripts/agent-pr-body.py b/scripts/agent-pr-body.py index 673cd450b..4b7822444 100755 --- a/scripts/agent-pr-body.py +++ b/scripts/agent-pr-body.py @@ -151,7 +151,52 @@ def validate(body: str) -> int: "UNVERIFIED: the trailer checker rendered no verdict " f"(exit {result.returncode})" ) - return EXIT_CONTRACT if result.returncode else EXIT_OK + verdict = EXIT_CONTRACT if result.returncode else EXIT_OK + problems = closing_keyword_records(body) + for problem in problems: + print(f"ERROR: {problem}", file=sys.stderr) + return EXIT_CONTRACT if problems else verdict + + +CLOSING = re.compile( + r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#([1-9][0-9]*)\b", re.IGNORECASE +) + + +def closing_keyword_records(body: str) -> list[str]: + """Every `Closes #N` in the body whose local issue record does not read CLOSED. + + The body IS the squash message, so a closing keyword here closes the GitHub + mirror the moment it lands. Local files are the issue authority, so if the + branch does not also carry `ISSUE-GH-N.md` reading `State: CLOSED`, the two + authorities disagree from the instant of the merge and the local record -- + the authoritative one -- is the half left saying OPEN. + + Read from the working tree, so run this from the branch being merged; that + is the same tree whose records would land. + """ + + problems: list[str] = [] + for number in sorted({m.group(1) for m in CLOSING.finditer(body)}, key=int): + matches = sorted((ROOT / ".agents/issues").rglob(f"ISSUE-GH-{number}.md")) + if not matches: + problems.append( + f"body closes #{number} but no .agents/issues/**/ISSUE-GH-{number}.md " + "exists; file it (scripts/agent-issue.py import-github) or drop the keyword" + ) + continue + text = matches[0].read_text(encoding="utf-8", errors="replace") + state = next( + (l.split(":", 1)[1].strip() for l in text.splitlines() if l.startswith("State:")), + "", + ) + if state != "CLOSED": + problems.append( + f"body closes #{number} but {matches[0].relative_to(ROOT)} reads " + f"State: {state or '(absent)'}; close it locally first " + "(scripts/agent-issue.py close) or drop the keyword" + ) + return problems def pull_request_number(value: str) -> int: diff --git a/tests/scripts/test_agent_pr_body.py b/tests/scripts/test_agent_pr_body.py index 505b61841..05f1be7f6 100644 --- a/tests/scripts/test_agent_pr_body.py +++ b/tests/scripts/test_agent_pr_body.py @@ -98,6 +98,40 @@ def gh_stub(self, *, stdout: str = "", stderr: str = "", code: int = 0) -> str: ) +class ClosingKeywordRecordTests(ToolHarness): + """`Closes #N` in a body closes the GitHub mirror the moment the squash lands. + + Local files are the issue authority, so the branch must also carry + `ISSUE-GH-N.md` reading `State: CLOSED`. Three landed pull requests + (#3092, #3098 and five on #3096) said `Closes` while their own local record + still read OPEN, which leaves the authoritative half saying OPEN forever. + """ + + def test_closing_an_issue_with_no_local_record_is_refused(self) -> None: + text = body(FILLED).replace( + "fix(ROW): the subject line of a body that will be squashed\n", + "fix(ROW): the subject line of a body that will be squashed\n\nCloses #999999.\n", + ) + result = self.run_tool("--body-file", self.body_file(text)) + self.assertEqual(result.returncode, 1) + self.assertIn(f"ISSUE-GH-{''}999999.md", result.stderr) + + def test_a_bare_reference_without_a_closing_keyword_is_accepted(self) -> None: + """The gate is narrow ON PURPOSE: ordinary citations must not red a body. + + Open pull requests carry six to ten bare `#N` citations each, most + without a local record. Failing those would fire the gate on ordinary + work, which AGENTS.md names as the defect rather than the discipline. + """ + + text = body(FILLED).replace( + "fix(ROW): the subject line of a body that will be squashed\n", + "fix(ROW): the subject line of a body that will be squashed\n\nRefs #999999.\n", + ) + result = self.run_tool("--body-file", self.body_file(text)) + self.assertEqual(result.returncode, 0) + + class OfflineContractTests(ToolHarness): def test_the_exact_landed_malformed_value_is_refused(self) -> None: """The bytes of 281b4bc76c0e, refused by a command an operator can run."""