From 4fe37a618d54e493c13fe6f6d994d1c3b791f48e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 9 Aug 2026 01:11:15 +0000 Subject: [PATCH] chore: sync workflow templates from Workflows repo Automated sync from stranske/Workflows Template hash: f74d65a1c283 Changes synced from sync-manifest.yml --- .github/scripts/github-api-with-retry.js | 2 +- .github/scripts/issue_format.py | 289 +++++++++++++++ .github/workflows/agents-auto-label.yml | 3 +- .../workflows/agents-issue-format-guard.yml | 339 ++++++++++++++++++ .github/workflows/agents-issue-intake.yml | 5 +- .github/workflows/agents-issue-optimizer.yml | 92 +++-- 6 files changed, 702 insertions(+), 28 deletions(-) create mode 100644 .github/scripts/issue_format.py create mode 100644 .github/workflows/agents-issue-format-guard.yml diff --git a/.github/scripts/github-api-with-retry.js b/.github/scripts/github-api-with-retry.js index 9a13c47e6e..90b72cbe4e 100755 --- a/.github/scripts/github-api-with-retry.js +++ b/.github/scripts/github-api-with-retry.js @@ -428,7 +428,7 @@ async function withRetry(fn, options = {}) { ? 'rate limit' : 'transient error'; - console.log( + console.error( `${retryReason} (attempt ${attempt + 1}/${maxRetries + 1}). ` + `Retrying in ${Math.round(actualDelay / 1000)}s...` ); diff --git a/.github/scripts/issue_format.py b/.github/scripts/issue_format.py new file mode 100644 index 0000000000..a3f68f851f --- /dev/null +++ b/.github/scripts/issue_format.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""Validate a GitHub issue body against the fleet's AGENT_ISSUE_FORMAT contract. + +Synced to every consumer repo by `maint-68-sync-consumer-repos.yml`. This is the +single definition of "agent-processable" for the whole fleet — do not fork it +per repo. + +Why it exists: every automated lane reaches an issue through a LABEL. An issue +filed with no label and no Tasks/Acceptance block is invisible to the entire +pipeline — nothing validates it, nothing optimises it, nothing claims it. Local +automation that files *findings* rather than *work orders* therefore produces +issues no agent can ever pick up: good evidence, permanently unactionable. + +Used at both ends: + * `agents-issue-format-guard.yml` validates every issue on open/edit and, on + failure, applies `agents:format` — the label the existing Agents Issue + Optimizer already listens for — so a bad issue is ROUTED to the machinery + that repairs it rather than merely flagged; + * any local script that files issues can pre-flight with + `python .github/scripts/issue_format.py ` and refuse to file junk + (non-zero exit means unfit). + +Rules mirror docs/AGENT_ISSUE_FORMAT.md rather than inventing a parallel +standard: Tasks and Acceptance Criteria are REQUIRED; Why / Scope / +Implementation Notes / Non-Goals are reported as recommended; and at least one +acceptance criterion must name a real test, runnable command, or observable +verification gate. + +Recommended sections are advisory: their absence is reported to help authors +improve an issue, but does not change the exit code or route an otherwise valid +work order through the optimizer. Keeping that distinction prevents the guard +from flagging well-formed work orders solely for an optional heading. + +`_headings()` skips fenced code blocks, and that is load-bearing rather than +cosmetic. Without it a body whose ONLY "Tasks" and "Acceptance Criteria" lines +sit inside a ```bash fence validates as conforming — a false negative that lets +an unactionable issue through the guard. Well-written issues quote commands and +expected output in fences constantly, so this is the common case, not an edge +one. Any change to heading detection must keep a fenced-heading case in the +upstream Workflows test `tests/scripts/test_issue_format.py`. + +Pure stdlib on purpose — it must run on a bare runner with no install step. +""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass, field + +REQUIRED: dict[str, tuple[str, ...]] = { + "Tasks": ("tasks", "task list", "implementation"), + "Acceptance Criteria": ("acceptance criteria", "acceptance", "definition of done"), +} +RECOMMENDED: dict[str, tuple[str, ...]] = { + "Why": ("why", "goals", "summary", "motivation", "finding"), + "Scope": ("scope", "background", "context", "overview"), + "Implementation Notes": ("implementation notes",), + "Non-Goals": ("non-goals", "out of scope", "constraints"), +} +GATE = re.compile( + r"(tests?/[\w./-]+\.py(::[\w:\[\]-]+)?" + r"|\btest_[\w]+" + r"|\bpytest\b|\b(?:python(?:3)?\s+-m\s+(?:unittest|pytest)\b)" + r"|\bnode\s+--test\b|\b(?:npm|pnpm|yarn)\s+(?:run\s+)?(?:test|vitest|jest|playwright)\b" + r"|\b(?:make|just|cargo|go|dotnet)\s+(?:test|check)\b" + r"|\bgh workflow run\b|\bgh run\b" + r"|\bcurl\b|\bHTTP [1-5]\d\d\b" + r"|\b(?:API|endpoint|request|response)\s+(?:returns?|responds with)\s+[1-5]\d\d(?:\s+status)?\b" + r"|\bsmoke\b|\bverif\w*)", + re.I, +) +BANNED_ADJECTIVES = ( + "clean", + "nice", + "good", + "fast", + "better", + "intuitive", + "polished", + "performant", +) +_TASK_CATEGORY = r"(?:file|function|class|method|path|config(?:uration)?|key|job|workflow|command)" +_TASK_EXTENSION = ( + r"py|js|jsx|ts|tsx|yml|yaml|json|toml|md|sh|go|rs|java|kt|rb|php|css|html|sql|" + r"xml|txt|ini|cfg|conf|lock|gradle|swift|c|cc|cpp|h|hpp|cs|fs|r|jl" +) +_TASK_KNOWN_BASENAME = ( + r"(?:Dockerfile|Makefile|Justfile|Procfile|Gemfile|Rakefile|" + r"Cargo\.toml|pyproject\.toml|package\.json|go\.mod|go\.sum|pom\.xml|" + r"build\.gradle|CMakeLists\.txt|README(?:\.(?:md|rst|txt))?|" + r"LICENSE(?:\.(?:md|txt))?|\.gitignore|\.editorconfig)" +) +_TASK_COMMAND = ( + r"(?:python(?:3)?|pytest|npm|pnpm|yarn|make|just|cargo|go|dotnet|gh|curl|" + r"node|vitest|jest|playwright)" +) + + +def _concrete_span(span: str) -> bool: + """Return True when a backticked/unquoted token names a real work target.""" + span = span.strip() + if not span: + return False + if re.fullmatch(_TASK_CATEGORY, span, re.I): + return False + # Bare lowercase English words (`bugs`, `later`) are not actionable targets. + if re.fullmatch(r"[a-z]{2,24}", span): + return False + if "/" in span or span.startswith("."): + return True + if re.fullmatch(_TASK_KNOWN_BASENAME, span, re.I): + return True + if re.fullmatch(rf"[\w.-]+\.(?:{_TASK_EXTENSION})", span, re.I): + return True + if "_" in span or "." in span: + return True + # Multi-segment PascalCase or lowerCamelCase symbols (e.g. IssueFormatter, + # calculateDiscount). A capitalized interior segment distinguishes them from + # generic lowercase prose. + return re.fullmatch(r"[A-Za-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]+", span) is not None + + +def _task_has_concrete_target(item: str) -> bool: + """True when a task checkbox names a file, path, symbol, config, job, or command.""" + # Category word must be followed by a concrete identifier (not "file handling"). + for match in re.finditer(rf"\b{_TASK_CATEGORY}\s+(`[^`]+`|[^\s]+)", item, re.I): + token = match.group(1) + span = token[1:-1] if token.startswith("`") and token.endswith("`") else token.strip("'\"") + if _concrete_span(span): + return True + for match in re.finditer(r"`([^`]+)`", item): + if _concrete_span(match.group(1)): + return True + for token in re.findall(r"\b[A-Za-z][A-Za-z0-9_]*\b", item): + # Unquoted: only unambiguous lowerCamelCase (calculateDiscount). + # Brand/prose capitals (GitHub, JavaScript, OpenAI) must not satisfy. + if not re.fullmatch(r"[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]+", token): + continue + if _concrete_span(token): + return True + if re.search(rf"(?:^|[\s])({_TASK_KNOWN_BASENAME})\b", item, re.I): + return True + if re.search(rf"(?:^|[\s])([\w.-]+\.(?:{_TASK_EXTENSION}))\b", item, re.I): + return True + # Unquoted path with a directory separator (src/main.go, .github/workflows/x.yml). + if re.search(r"(?:^|[\s])((?:\./)?[\w.-]+(?:/[\w./-]+)+)", item): + return True + return re.search(rf"\b{_TASK_COMMAND}\b", item, re.I) is not None + + +def _headings(body: str) -> list[tuple[str, int, int]]: + """Return markdown headings outside fenced code blocks with line indexes.""" + out: list[tuple[str, int, int]] = [] + fence: tuple[str, int] | None = None + for i, line in enumerate(body.splitlines()): + fence_match = re.match(r"\s{0,3}(`{3,}|~{3,})", line) + if fence_match: + marker = fence_match.group(1) + if fence is None: + fence = (marker[0], len(marker)) + elif ( + marker[0] == fence[0] + and len(marker) >= fence[1] + and re.fullmatch( + rf"\s{{0,3}}(?:`{{{fence[1]},}}|~{{{fence[1]},}})\s*", + line, + ) + ): + fence = None + continue + if fence is not None: + continue + heading = re.match(r"\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$", line) + if heading: + out.append((heading.group(2).strip().strip(":").lower(), i, len(heading.group(1)))) + return out + + +def _find(body: str, aliases: tuple[str, ...]) -> int | None: + for text, idx, _ in _headings(body): + if any( + text == alias or text.startswith((f"{alias} (", f"{alias} -", f"{alias} /")) + for alias in aliases + ): + return idx + return None + + +def _section_text(body: str, start: int) -> str: + lines = body.splitlines() + start_level = next(level for _, idx, level in _headings(body) if idx == start) + following = [idx for _, idx, level in _headings(body) if idx > start and level <= start_level] + end = following[0] if following else len(lines) + return "\n".join(lines[start + 1 : end]) + + +@dataclass +class Report: + ok: bool = True + missing_required: list[str] = field(default_factory=list) + missing_recommended: list[str] = field(default_factory=list) + problems: list[str] = field(default_factory=list) + + def as_markdown(self) -> str: + if self.ok and not self.missing_recommended: + return "Issue body conforms to `docs/AGENT_ISSUE_FORMAT.md`." + if self.ok: + out = ["Issue body is agent-processable with advisories.", ""] + else: + out = [ + "This issue is **not yet agent-processable**. See `docs/AGENT_ISSUE_FORMAT.md`.", + "", + ] + if self.missing_required: + out.append( + "**Missing required sections:** " + + ", ".join(f"`{section}`" for section in self.missing_required) + ) + out.extend(f"- {problem}" for problem in self.problems) + if self.missing_recommended: + out.extend( + [ + "", + "_Recommended but absent:_ " + + ", ".join(f"`{section}`" for section in self.missing_recommended), + ] + ) + return "\n".join(out) + + +def validate(body: str) -> Report: + """Check an issue body; every structural format problem is non-conforming.""" + report = Report() + body = body or "" + for name, aliases in REQUIRED.items(): + if _find(body, aliases) is None: + report.missing_required.append(name) + for name, aliases in RECOMMENDED.items(): + if _find(body, aliases) is None: + report.missing_recommended.append(name) + + tasks_at = _find(body, REQUIRED["Tasks"]) + if tasks_at is not None: + task_items = re.findall( + r"^\s*[-*]\s*\[[ xX]\]\s*(.+)$", _section_text(body, tasks_at), re.M + ) + if not task_items: + report.problems.append( + "`Tasks` has no checkbox items (`- [ ] …`); agents track progress by them." + ) + elif any(not _task_has_concrete_target(item) for item in task_items): + report.problems.append( + "`Tasks` must name a concrete file, symbol, path, config key, job, or command." + ) + + acceptance_at = _find(body, REQUIRED["Acceptance Criteria"]) + if acceptance_at is not None: + acceptance = _section_text(body, acceptance_at) + if not GATE.search(acceptance): + report.problems.append( + "`Acceptance Criteria` names no test, runnable command or observable " + "verification gate — Definition of Ready / Quality Bar §2 requires one." + ) + hits = [word for word in BANNED_ADJECTIVES if re.search(rf"\b{word}\b", acceptance, re.I)] + if hits: + report.problems.append( + "`Acceptance Criteria` uses subjective wording (" + + ", ".join(sorted(hits)) + + "); replace with a measurable check." + ) + report.ok = not report.missing_required and not report.problems + return report + + +def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if argv: + with open(argv[0], encoding="utf-8") as issue_file: + body = issue_file.read() + else: + body = sys.stdin.read() + report = validate(body) + print(report.as_markdown()) + return 0 if report.ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/agents-auto-label.yml b/.github/workflows/agents-auto-label.yml index d2f45207cd..b671c8c4e9 100644 --- a/.github/workflows/agents-auto-label.yml +++ b/.github/workflows/agents-auto-label.yml @@ -50,11 +50,12 @@ jobs: github.event.workflow_run.repository.default_branch }} sparse-checkout: .github/actions/agent-event-eligibility sparse-checkout-cone-mode: false + path: eligibility-source # Escape hatch: set mode: warning if false-negative skips appear post-merge. - name: Check event eligibility id: eligibility - uses: ./.github/actions/agent-event-eligibility + uses: ./eligibility-source/.github/actions/agent-event-eligibility with: expected-actions: opened,reopened,edited custom-predicate: >- diff --git a/.github/workflows/agents-issue-format-guard.yml b/.github/workflows/agents-issue-format-guard.yml new file mode 100644 index 0000000000..089672ed6f --- /dev/null +++ b/.github/workflows/agents-issue-format-guard.yml @@ -0,0 +1,339 @@ +name: Agents Issue Format Guard + +on: + issues: + types: [opened, edited, reopened, labeled, unlabeled] + workflow_dispatch: + inputs: + issue_number: + description: "Issue to (re)check" + required: true + type: string + +permissions: + actions: write + contents: read + issues: write + +concurrency: + # Event inputs are null for issue events, so retain the manual fallback + # without making normal issue-triggered runs depend on the dispatch-only + # `inputs` context. + group: >- + issue-format-guard-${{ + github.event.issue.number || github.event.inputs.issue_number || + github.run_id }} + # A skipped label event must never cancel an in-flight opened/edited check. + cancel-in-progress: false + +jobs: + check: + if: >- + github.event_name != 'issues' || + github.event.action == 'opened' || github.event.action == 'edited' || + github.event.action == 'reopened' || + ((github.event.action == 'labeled' || github.event.action == 'unlabeled') && + (github.event.label.name == 'agents:auto-pilot-pause' || + github.event.label.name == 'needs-human' || + github.event.label.name == 'tracker:durable' || github.event.label.name == 'wontfix')) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Resolve issue + id: issue + env: + GH_TOKEN: ${{ github.token }} + NUMBER: ${{ github.event.issue.number || github.event.inputs.issue_number }} + run: | + set -euo pipefail + gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" \ + --json number,body,labels,state,author > issue.json + jq -r '.body // ""' issue.json > body.md + exempt=false + held=false + # A closed issue has nothing left to format. Routing one to the optimizer + # produces a body edit, that edit re-fires this guard, and the pair loops + # against work that is already delivered — see Fine-Art-Archive#464, which + # kept formatting for 17.5 hours after it was closed. + if [[ "$(jq -r '.state // "" | ascii_downcase' issue.json)" == "closed" ]]; then + echo "Issue is closed — nothing to format." + exempt=true + fi + if jq -e '[.labels[].name] | any(. == "tracker:durable" or . == "wontfix")' issue.json >/dev/null; then exempt=true; fi + if jq -e '[.labels[].name] | any(. == "agents:auto-pilot-pause" or . == "needs-human")' issue.json >/dev/null; then held=true; fi + if jq -er '.author.login // ""' issue.json | grep -qiE '(\[bot\]|^renovate$|^dependabot$)'; then exempt=true; fi + if grep -qiE 'do not dispatch|not a (repo|cloud) coding task' body.md; then exempt=true; fi + { + echo "number=$NUMBER" + echo "exempt=$exempt" + echo "held=$held" + echo "state=$(jq -r '.state' issue.json)" + } >> "$GITHUB_OUTPUT" + + - name: Setup API client + if: steps.issue.outputs.exempt != 'true' && steps.issue.outputs.held != 'true' + uses: ./.github/actions/setup-api-client + with: + # This guard only reads issue comments with the workflow token. Do not + # expose the repository-wide secret bundle to the composite action. + github_token: ${{ github.token }} + + - name: Validate against AGENT_ISSUE_FORMAT + id: validate + if: steps.issue.outputs.exempt != 'true' + run: | + set -euo pipefail + report_file="$(mktemp)" + error_file="$(mktemp)" + trap 'rm -f "$report_file" "$error_file"' EXIT + if [[ ! -f .github/scripts/issue_format.py ]]; then + echo "validator absent — skipping while this repo is mid-sync" + echo "rc=skip" >> "$GITHUB_OUTPUT" + exit 0 + fi + set +e + python3 .github/scripts/issue_format.py body.md > "$report_file" 2> "$error_file" + rc=$? + set -e + echo "rc=$rc" >> "$GITHUB_OUTPUT" + cat "$report_file" || true + if [[ "$rc" -eq 1 && -s "$error_file" ]]; then + echo "::error::issue-format validator failed unexpectedly" + cat "$error_file" >&2 + exit 1 + fi + if [[ "$rc" -ne 0 && "$rc" -ne 1 ]]; then + cat "$error_file" >&2 + exit "$rc" + fi + cp "$report_file" report.md + + - name: Invalidate stale format completion after an invalid issue change + if: >- + steps.issue.outputs.exempt != 'true' && steps.issue.outputs.state == 'OPEN' && + steps.validate.outputs.rc == '1' + env: + GH_TOKEN: ${{ github.token }} + NUMBER: ${{ steps.issue.outputs.number }} + run: | + set -euo pipefail + live_issue="$(mktemp)" + live_body="$(mktemp)" + error_file="$(mktemp)" + trap 'rm -f "$live_issue" "$live_body" "$error_file"' EXIT + gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" --json body,state > "$live_issue" + if [[ "$(jq -r '.state' "$live_issue")" != "OPEN" ]]; then + echo "Issue is now closed — preserving agents:formatted." + exit 0 + fi + jq -r '.body // ""' "$live_issue" > "$live_body" + set +e + python3 .github/scripts/issue_format.py "$live_body" > /dev/null 2> "$error_file" + live_rc=$? + set -e + if [[ "$live_rc" -eq 0 ]]; then + echo "Live body now conforms — preserving agents:formatted." + exit 0 + fi + if [[ "$live_rc" -ne 1 || -s "$error_file" ]]; then + echo "::error::issue-format validator failed unexpectedly during label revalidation" + cat "$error_file" >&2 + exit "$live_rc" + fi + if gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" --json labels \ + --jq '.labels[].name' | grep -qx 'agents:formatted'; then + if gh issue edit "$NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label "agents:formatted"; then + echo "Invalid issue body — cleared stale agents:formatted." + else + echo "::warning::could not remove stale agents:formatted" + fi + fi + + - name: Route non-conforming issue to the optimizer + if: steps.issue.outputs.exempt != 'true' && steps.issue.outputs.held != 'true' && steps.issue.outputs.state == 'OPEN' && steps.validate.outputs.rc == '1' + env: + GH_TOKEN: ${{ github.token }} + NUMBER: ${{ steps.issue.outputs.number }} + run: | + set -euo pipefail + # Re-fetch before side effects: cancel-in-progress is false so a queued + # older run must not dispatch against a body a newer edit already fixed. + gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" \ + --json number,body,labels,state,author > live.json + jq -r '.body // ""' live.json > body.md + if [[ "$(jq -r '.state' live.json)" != "OPEN" ]]; then + echo "Issue is now closed — skipping optimizer dispatch." + exit 0 + fi + if jq -e '[.labels[].name] | any(. == "agents:auto-pilot-pause" or . == "needs-human")' live.json >/dev/null; then + echo "Issue is now held — skipping optimizer dispatch." + exit 0 + fi + if [[ -f .github/scripts/issue_format.py ]]; then + error_file="$(mktemp)" + set +e + python3 .github/scripts/issue_format.py body.md > report.md 2> "$error_file" + revalidate_rc=$? + set -e + if [[ "$revalidate_rc" -eq 0 ]]; then + echo "Live body now conforms — skipping optimizer dispatch." + rm -f "$error_file" + exit 0 + fi + # Exit 1 is used for both non-conformance and Python crashes; stderr + # distinguishes a validator runtime error (fail closed) from a normal + # non-conforming body (continue to fingerprint/optimizer). + if [[ "$revalidate_rc" -eq 1 && -s "$error_file" ]]; then + echo "::error::issue-format validator failed unexpectedly during revalidation" + cat "$error_file" >&2 + rm -f "$error_file" + exit 1 + fi + if [[ "$revalidate_rc" -ne 1 ]]; then + echo "::error::issue-format validator failed unexpectedly during revalidation (exit $revalidate_rc)" + cat "$error_file" >&2 + rm -f "$error_file" + exit "$revalidate_rc" + fi + rm -f "$error_file" + fi + fingerprint="$(sha256sum body.md | cut -c1-12)" + marker="" + # Only trust exact HTML markers authored by github-actions[bot] + # (immutable account id 41898282 when the REST payload includes user.id). + trusted_marker="$(FORMAT_GUARD_MARKER="$marker" node - <<'NODE' + (async () => { + const { Octokit } = require('@octokit/rest'); + const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); + const github = new Octokit({ auth: process.env.GH_TOKEN }); + const core = { info: () => {}, warning: console.warn, debug: () => {} }; + const { paginateWithRetry } = await createTokenAwareRetry({ + github, + core, + env: process.env, + task: 'issue-format-guard', + capabilities: ['issues:read'], + }); + const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); + const comments = await paginateWithRetry( + github.rest.issues.listComments, + { owner, repo, issue_number: Number(process.env.NUMBER), per_page: 100 } + ); + const trusted = comments.some((comment) => + comment.user?.login === 'github-actions[bot]' + && (comment.user?.id == null || comment.user.id === 41898282) + && (comment.body || '').includes(process.env.FORMAT_GUARD_MARKER) + ); + process.stdout.write(trusted ? 'true' : 'false'); + })().catch((error) => { + console.error(error); + process.exit(1); + }); + NODE + )" + has_format_label=false + if jq -e '[.labels[].name] | any(. == "agents:format")' live.json >/dev/null; then + has_format_label=true + fi + # The marker is written only after dispatch succeeds. Keep the label as + # the in-flight lease: a repeated guard run must not enqueue another + # optimizer while that lease is still present. If the label was removed, + # retry the dispatch because the earlier handoff no longer owns the work. + dispatch=true + if [[ "$trusted_marker" == true && "$has_format_label" == true ]]; then + echo "Identical invalid body is already routed and in flight; skipping duplicate dispatch." + dispatch=false + elif [[ "$trusted_marker" == true ]]; then + echo "Prior format-guard marker present but agents:format is absent — retrying optimizer dispatch." + fi + if jq -e '[.labels[].name] | any(. == "agents:formatted")' live.json >/dev/null; then + gh issue edit "$NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label "agents:formatted" \ + || echo "::warning::could not remove agents:formatted" + fi + if [[ "$dispatch" != true ]]; then + exit 0 + fi + # Acquiring the label is the handoff lease. Do not dispatch without it: + # otherwise a trusted marker alone could repeat the optimizer dispatch. + if ! gh issue edit "$NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "agents:format"; then + echo "::error::could not acquire agents:format lease; refusing optimizer dispatch" + exit 1 + fi + # GITHUB_TOKEN label edits do not start issues:labeled workflows; dispatch is explicit. + # Persist the completion marker only after a successful workflow_dispatch so a + # failed run remains retryable on the next guard pass. + # Capture the attempt clock before dispatch: gh workflow run can exit non-zero + # after GitHub has already accepted the request. + dispatch_attempted_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) + if ! gh workflow run agents-issue-optimizer.yml --repo "$GITHUB_REPOSITORY" \ + -f issue_number="$NUMBER" -f phase=format; then + accepted=false + for _try in 1 2 3 4 5; do + sleep 2 + # shellcheck disable=SC2016 + match_count=$(gh run list --repo "$GITHUB_REPOSITORY" \ + --workflow=agents-issue-optimizer.yml \ + --event workflow_dispatch \ + --limit 20 \ + --json createdAt,displayTitle \ + | jq --arg since "$dispatch_attempted_at" --arg issue "#$NUMBER" \ + '[.[] | select(.createdAt >= $since and (.displayTitle | contains($issue)))] | length') + if [[ "${match_count:-0}" -gt 0 ]]; then + accepted=true + break + fi + done + if [[ "$accepted" == true ]]; then + echo "::warning::optimizer dispatch CLI failed but a matching run was accepted; preserving agents:format lease" + echo "::error::optimizer dispatch status ambiguous; no completion marker written" + exit 1 + fi + gh issue edit "$NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label "agents:format" \ + || echo "::warning::could not release failed agents:format lease" + echo "::error::optimizer dispatch failed; no completion marker written so later runs can retry" + exit 1 + fi + if [[ "$trusted_marker" != true ]]; then + { + cat report.md + echo + echo "Dispatched the Agents Issue Optimizer format phase." + echo + echo "$marker" + } | gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file - + fi + + - name: Restore format completion after an unheld revalidation + if: >- + steps.issue.outputs.exempt != 'true' && steps.validate.outputs.rc == '0' && + github.event.action == 'unlabeled' && + (github.event.label.name == 'agents:auto-pilot-pause' || github.event.label.name == 'needs-human') + env: + GH_TOKEN: ${{ github.token }} + NUMBER: ${{ steps.issue.outputs.number }} + run: | + set -euo pipefail + if gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" --json labels \ + --jq '[.labels[].name] | any(. == "agents:auto-pilot-pause" or . == "needs-human")' | grep -qx true; then + echo "Issue remains held — leaving agents:formatted cleared." + exit 0 + fi + gh issue edit "$NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "agents:formatted" \ + || echo "::warning::could not apply agents:formatted (label missing in this repo?)" + echo "Unheld issue revalidated — restored agents:formatted." + + - name: Clear the stale format trigger + if: steps.issue.outputs.exempt != 'true' && steps.issue.outputs.held != 'true' && steps.validate.outputs.rc == '0' + env: + GH_TOKEN: ${{ github.token }} + NUMBER: ${{ steps.issue.outputs.number }} + run: | + set -euo pipefail + if gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" --json labels \ + --jq '.labels[].name' | grep -qx 'agents:format'; then + gh issue edit "$NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label "agents:format" + echo "Conforms now — cleared agents:format." + fi diff --git a/.github/workflows/agents-issue-intake.yml b/.github/workflows/agents-issue-intake.yml index f9a8e0b657..fc184d69f4 100644 --- a/.github/workflows/agents-issue-intake.yml +++ b/.github/workflows/agents-issue-intake.yml @@ -111,8 +111,9 @@ jobs: if: | needs.route.outputs.should_run_bridge == 'true' && (github.event_name != 'issues' || - contains(toJson(github.event.issue.labels.*.name), 'agent:') || - contains(toJson(github.event.issue.labels.*.name), 'agents:')) && + (github.event.issue.state != 'closed' && + (contains(toJson(github.event.issue.labels.*.name), 'agent:') || + contains(toJson(github.event.issue.labels.*.name), 'agents:')))) && !contains(github.event.issue.labels.*.name, 'agents:auto-pilot') runs-on: ubuntu-latest outputs: diff --git a/.github/workflows/agents-issue-optimizer.yml b/.github/workflows/agents-issue-optimizer.yml index dd95677e1c..b84c664e96 100644 --- a/.github/workflows/agents-issue-optimizer.yml +++ b/.github/workflows/agents-issue-optimizer.yml @@ -1,5 +1,13 @@ name: Agents Issue Optimizer +# The recursion guard below correlates prior runs by issue number via `displayTitle`. +# workflow_dispatch runs otherwise display the bare workflow name, so the guard matched +# nothing and reported 0 for an issue it was re-running every minute. Pin the issue +# number into the run name so both trigger types are correlatable. +run-name: >- + Agents Issue Optimizer #${{ + github.event.issue.number || inputs.issue_number }} + on: issues: types: [labeled] @@ -18,6 +26,13 @@ on: - apply - format +concurrency: + group: >- + agents-issue-optimizer-${{ + github.repository }}-${{ + github.event.issue.number || github.event.inputs.issue_number || github.run_id }} + cancel-in-progress: false + jobs: optimize_issue: runs-on: ubuntu-latest @@ -126,6 +141,7 @@ jobs: repository: stranske/Workflows path: workflows-scripts sparse-checkout: | + .github/scripts/issue_format.py config scripts/langchain tools @@ -163,14 +179,17 @@ jobs: '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \ || date -u -v-1H '+%Y-%m-%dT%H:%M:%SZ') + # `gh run list` defaults to 20 runs, which a tight loop exhausts inside the + # window; ask for enough history to actually see the recursion. # shellcheck disable=SC2016 count=$(gh run list \ --workflow=agents-issue-optimizer.yml \ + --limit 100 \ --json conclusion,createdAt,displayTitle \ | jq --arg cutoff "$one_hour_ago" \ --arg issue "#$ISSUE_NUMBER" \ '[.[] | select(.createdAt > $cutoff - and (.displayTitle | contains($issue))) + and (.displayTitle | endswith($issue))) ] | length') echo "Optimizer runs for issue #$ISSUE_NUMBER in last hour: $count" @@ -340,7 +359,8 @@ jobs: }); NODE - python - <<'PY' || true + set +e + python - <<'PY' import json import sys sys.path.insert(0, 'workflows-scripts/scripts/langchain') @@ -350,33 +370,38 @@ jobs: issue = json.load(f) with open('/tmp/open_issues.json', encoding='utf-8') as f: open_issues = json.load(f) - with open('/tmp/dedup_comments.json', encoding='utf-8') as f: - comments = json.load(f) + with open('/tmp/dedup_comments.json', encoding='utf-8') as f: + comments = json.load(f) - marker = issue_dedup.SIMILAR_ISSUES_MARKER - for comment in comments or []: - body = (comment or {}).get('body') or '' - if marker in body: - raise SystemExit(0) + marker = issue_dedup.SIMILAR_ISSUES_MARKER + for comment in comments or []: + body = (comment or {}).get('body') or '' + if marker in body: + raise SystemExit(0) # Conservative defaults; can be tuned later. threshold = 0.82 - store = issue_dedup.build_issue_vector_store(open_issues) - if store is None: - raise SystemExit(0) - - title = (issue.get('title') or '').strip() - body = (issue.get('body') or '').strip() - query = f"{title}\n{body}".strip() if body else title - if not query: - raise SystemExit(0) - - matches = issue_dedup.find_similar_issues(store, query, threshold=threshold, k=5) - comment = issue_dedup.format_similar_issues_comment(matches, max_items=5) - if comment: - with open('/tmp/dedup_comment.md', 'w', encoding='utf-8') as out: - out.write(comment) + store = issue_dedup.build_issue_vector_store(open_issues) + if store is None: + raise SystemExit(0) + + title = (issue.get('title') or '').strip() + body = (issue.get('body') or '').strip() + query = f"{title}\n{body}".strip() if body else title + if not query: + raise SystemExit(0) + + matches = issue_dedup.find_similar_issues(store, query, threshold=threshold, k=5) + comment = issue_dedup.format_similar_issues_comment(matches, max_items=5) + if comment: + with open('/tmp/dedup_comment.md', 'w', encoding='utf-8') as out: + out.write(comment) PY + dedup_rc=$? + set -e + if [[ $dedup_rc -ne 0 ]]; then + echo "::warning::issue dedup python exited with $dedup_rc; continuing without similar-issues comment" + fi if [[ -f /tmp/dedup_comment.md ]]; then gh issue comment "${ISSUE_NUMBER}" --body-file /tmp/dedup_comment.md || true @@ -466,6 +491,9 @@ jobs: print('Suggestions applied successfully') " || exit 1 + # Keep agents:formatted truthful for apply-phase output too. + python workflows-scripts/.github/scripts/issue_format.py /tmp/updated_body.md + # Update issue body gh issue edit "${ISSUE_NUMBER}" --body-file /tmp/updated_body.md @@ -504,6 +532,11 @@ jobs: print('Issue formatted successfully') " || exit 1 + # Keep the format-result label truthful: the generated issue must + # pass the canonical Workflows validator before it can be marked + # agents:formatted below. + python workflows-scripts/.github/scripts/issue_format.py /tmp/formatted_body.md + # Update issue body with formatted version gh issue edit "${ISSUE_NUMBER}" --body-file /tmp/formatted_body.md @@ -550,3 +583,14 @@ jobs: body="${body//RUN_URL_PLACEHOLDER/${RUN_URL}}" gh issue comment "${ISSUE_NUMBER}" --body "$body" || true fi + + - name: Release failed format lease + if: (failure() || cancelled()) && steps.check.outputs.should_run == 'true' && steps.check.outputs.phase == 'format' + env: + GH_TOKEN: ${{ steps.token.outputs.token || github.token }} + ISSUE_NUMBER: ${{ steps.check.outputs.issue_number }} + run: | + set -euo pipefail + # A guard retry may proceed only after the failed format run releases its lease. + gh issue edit "$ISSUE_NUMBER" --remove-label "agents:format" \ + || echo "::warning::could not release failed agents:format lease"