diff --git a/.github/actions/path-classifier/classify.js b/.github/actions/path-classifier/classify.js index dbc081a0f0..77ef82cde7 100644 --- a/.github/actions/path-classifier/classify.js +++ b/.github/actions/path-classifier/classify.js @@ -3,6 +3,7 @@ const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process'); +const vm = require('node:vm'); const OUTPUT_NAMES = { 'docs-only': 'is-docs-only', @@ -25,6 +26,16 @@ const DEFAULT_CATEGORIES = { 'test-only': { paths: ['tests/**', '**/test_*.py', '**/*.test.js'], requireAll: true }, }; +const STABLE_SYNC_BRANCHES = new Set([ + 'sync/workflows-candidate', + 'sync/workflows-delivery', +]); + +function isStableDeliveryPullRequest(githubContext) { + const branch = githubContext?.event?.pull_request?.head?.ref || ''; + return githubContext?.event_name === 'pull_request' && STABLE_SYNC_BRANCHES.has(branch); +} + function normalizePath(value) { return String(value || '').replace(/\\/g, '/').replace(/^\.\/+/, ''); } @@ -182,6 +193,122 @@ function parseGithubContext() { } } +function stableDeliverySealStatus(githubContext, { contract, now } = {}) { + const event = githubContext?.event || {}; + const pullRequest = event.pull_request; + if (!isStableDeliveryPullRequest(githubContext)) { + return { required: false, valid: true, reason: '' }; + } + + const headRepository = pullRequest?.head?.repo?.full_name || ''; + const baseRepository = pullRequest?.base?.repo?.full_name || ''; + if (!headRepository || !baseRepository || headRepository !== baseRepository) { + return { + required: true, + valid: false, + reason: 'stable delivery must originate from the base repository', + }; + } + if (!contract) { + return { required: true, valid: false, reason: 'delivery contract is unavailable' }; + } + + const record = contract.parseDeliveryRecord(pullRequest?.body || ''); + const eligibility = contract.mergeEligibility(record, { + now: now || new Date().toISOString(), + repository: baseRepository, + requireSealed: true, + headSha: pullRequest?.head?.sha || '', + }); + return { + required: true, + valid: Boolean(eligibility.eligible), + reason: eligibility.reason, + }; +} + +function compileDeliveryContract(source, filename) { + const module = { exports: {} }; + const sandbox = { module, exports: module.exports }; + vm.runInNewContext(String(source), sandbox, { filename }); + return module.exports; +} + +function readContractAtRef(ref, contractPath) { + return runGit(['show', `${ref}:${contractPath}`]); +} + +function isAddOnlyContractDiff(diffText, contractPath) { + return String(diffText || '') + .split(/\r?\n/) + .some((line) => line === `A\t${contractPath}`); +} + +function contractAddedBetweenRefs(baseSha, headSha, contractPath) { + const added = runGit([ + 'diff', + '--name-status', + '--diff-filter=A', + baseSha, + headSha, + '--', + contractPath, + ]); + return isAddOnlyContractDiff(added, contractPath); +} + +function loadDeliveryContract( + githubContext = {}, + { + readTrustedContract = readContractAtRef, + readBootstrapContract = readContractAtRef, + isBootstrapAddition = contractAddedBetweenRefs, + } = {}, +) { + const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); + const relativeContractPath = '.github/scripts/sync_pr_lease_contract.js'; + const pullRequest = githubContext?.event?.pull_request; + + if (isStableDeliveryPullRequest(githubContext)) { + const baseSha = pullRequest?.base?.sha || ''; + if (!baseSha) { + return null; + } + try { + const source = readTrustedContract(baseSha, relativeContractPath); + return compileDeliveryContract(source, `${baseSha}:${relativeContractPath}`); + } catch { + // A consumer's first stable-delivery rollout necessarily predates the + // lease contract on its base. Permit only that exact add-only bootstrap: + // same repository, exact observed head, and the contract path added (not + // modified or renamed) between base and head. Maint 71 remains the final + // boundary and independently requires the exact generated head to carry + // a valid GitHub-recognized signature before it can merge. + const headSha = pullRequest?.head?.sha || ''; + const headRepository = pullRequest?.head?.repo?.full_name || ''; + const baseRepository = pullRequest?.base?.repo?.full_name || ''; + if (!headSha || !headRepository || headRepository !== baseRepository) { + return null; + } + try { + if (!isBootstrapAddition(baseSha, headSha, relativeContractPath)) { + return null; + } + const source = readBootstrapContract(headSha, relativeContractPath); + return compileDeliveryContract(source, `${headSha}:${relativeContractPath}`); + } catch { + return null; + } + } + } + + const contractPath = path.resolve(workspace, relativeContractPath); + if (!fs.existsSync(contractPath)) { + return null; + } + return require(contractPath); +} + function runGit(args) { return execFileSync('git', args, { cwd: process.env.GITHUB_WORKSPACE || process.cwd(), @@ -224,7 +351,13 @@ function fetchBaseRef(baseRef, githubContext) { } } -function listChangedFiles({ baseRef, githubContext } = {}) { +function listChangedFiles({ + baseRef, + githubContext, + baseAlreadyFetched = false, + fetchBase = fetchBaseRef, + diffGit = tryGit, +} = {}) { const envFiles = process.env.PATH_CLASSIFIER_FILES_JSON; if (envFiles) { const parsed = JSON.parse(envFiles); @@ -234,7 +367,9 @@ function listChangedFiles({ baseRef, githubContext } = {}) { return parsed.map(normalizePath).filter(Boolean); } - fetchBaseRef(baseRef, githubContext); + if (!baseAlreadyFetched) { + fetchBase(baseRef, githubContext); + } const head = githubContext.sha || 'HEAD'; const ranges = []; if (baseRef) { @@ -248,7 +383,7 @@ function listChangedFiles({ baseRef, githubContext } = {}) { } for (const range of ranges) { - const output = tryGit(['diff', '--name-only', range]); + const output = diffGit(['diff', '--name-only', range]); if (output) { return output.split(/\r?\n/).map(normalizePath).filter(Boolean); } @@ -312,15 +447,32 @@ function writeOutputs(outputs) { function main() { const githubContext = parseGithubContext(); + const baseRef = resolveBaseRef(process.env.INPUT_BASE_REF || '', githubContext); + // The stable-delivery contract is loaded from the exact trusted base SHA. + // Fetch it before evaluating a stable delivery seal, then reuse that fetch + // for changed-file classification. Ordinary PRs defer the same fetch until + // classification so every run fetches the base at most once. + const baseAlreadyFetched = isStableDeliveryPullRequest(githubContext); + if (baseAlreadyFetched) { + fetchBaseRef(baseRef, githubContext); + } + const seal = stableDeliverySealStatus(githubContext, { + contract: loadDeliveryContract(githubContext), + }); + if (seal.required && !seal.valid) { + throw new Error( + `Mutable generated delivery is not mergeable: ${seal.reason}. ` + + 'Maint 71 must seal this exact head after bounded reviewer settlement.', + ); + } const forceFull = String(process.env.INPUT_FORCE_FULL || '').toLowerCase() === 'true'; const configPath = process.env.INPUT_CONFIG_PATH || '.github/path-classification.yml'; - const baseRef = resolveBaseRef(process.env.INPUT_BASE_REF || '', githubContext); const config = loadConfig(configPath); let files = []; let conservativeFull = false; try { - files = listChangedFiles({ baseRef, githubContext }); + files = listChangedFiles({ baseRef, githubContext, baseAlreadyFetched }); } catch (error) { conservativeFull = true; console.warn(`::warning::Unable to list changed files; forcing full classification: ${error.message}`); @@ -341,8 +493,13 @@ module.exports = { OUTPUT_NAMES, classifyFiles, globToRegExp, + isAddOnlyContractDiff, + isStableDeliveryPullRequest, + listChangedFiles, loadConfig, + loadDeliveryContract, matchesAny, normalizePath, parseClassificationConfig, + stableDeliverySealStatus, }; diff --git a/.github/agents/registry.yml b/.github/agents/registry.yml index d1ca8854ac..6cc23eb91f 100644 --- a/.github/agents/registry.yml +++ b/.github/agents/registry.yml @@ -5,6 +5,12 @@ default_agent: codex # Shared keepalive marker prefix (agent-agnostic) keepalive_marker_prefix: agent-keepalive +# Cross-agent credentials whose absence can form a concrete authority remedy. +# Provider credentials come from each routed agent's required_secrets list. +authority_shared_secrets: + - ACTIONS_BOT_PAT + - OPENAI_API_KEY + # Dedicated instrumentation-only contract for the Sol/Terra/Luna plumbing # canary. The reusable workflow ref is replaced with the exact commit containing # this runner before merge. Trial profiles are rejected by ordinary agent and diff --git a/.github/scripts/error_classifier.js b/.github/scripts/error_classifier.js index 22ea0fc4f8..069bb1827e 100644 --- a/.github/scripts/error_classifier.js +++ b/.github/scripts/error_classifier.js @@ -233,6 +233,10 @@ function classifyByMessage(message) { if (matchesPattern(message, TRANSIENT_PATTERNS)) { return ERROR_CATEGORIES.transient; } + // normaliseMessage lowercases classifier input before this branch. + if (/\bmissing\s+[a-z][a-z0-9_.-]*\s+auth\s*:\s*set\s+(?:the\s+)?[a-z][a-z0-9_]{2,}\b/.test(message)) { + return ERROR_CATEGORIES.auth; + } if (matchesPattern(message, AUTH_PATTERNS)) { return ERROR_CATEGORIES.auth; } diff --git a/.github/scripts/gate_summary.py b/.github/scripts/gate_summary.py index 344d31c650..056702726c 100644 --- a/.github/scripts/gate_summary.py +++ b/.github/scripts/gate_summary.py @@ -2,9 +2,11 @@ import json import os +import re import sys from collections.abc import Iterable, Mapping from dataclasses import dataclass +from datetime import UTC, datetime from pathlib import Path @@ -22,6 +24,9 @@ class SummaryContext: python_required: bool = True docs_guard_result: str = "success" test_quality_result: str = "skipped" + delivery_seal_required: bool = False + delivery_seal_valid: bool = True + delivery_seal_reason: str = "" @dataclass(slots=True) @@ -43,6 +48,65 @@ class SummaryResult: "pending": 5, } +STABLE_SYNC_BRANCHES = {"sync/workflows-candidate", "sync/workflows-delivery"} +DELIVERY_RECORD_PATTERN = re.compile(r"") + + +def _delivery_seal_from_event(event_path: Path | None) -> tuple[bool, bool, str]: + """Return whether a stable generated delivery is sealed to its exact head.""" + if event_path is None or not event_path.is_file(): + return False, True, "" + try: + payload = json.loads(event_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False, True, "" + pull_request = payload.get("pull_request") + if not isinstance(pull_request, Mapping): + return False, True, "" + head = pull_request.get("head") + branch = str(head.get("ref") or "") if isinstance(head, Mapping) else "" + if branch not in STABLE_SYNC_BRANCHES: + return False, True, "" + head_repository = ( + str(head.get("repo", {}).get("full_name") or "") + if isinstance(head, Mapping) and isinstance(head.get("repo"), Mapping) + else "" + ) + base_repository = str(pull_request.get("base", {}).get("repo", {}).get("full_name") or "") + if not head_repository or not base_repository or head_repository != base_repository: + return True, False, "stable delivery must originate from the base repository" + head_sha = str(head.get("sha") or "") if isinstance(head, Mapping) else "" + body = str(pull_request.get("body") or "") + match = DELIVERY_RECORD_PATTERN.search(body) + if not match: + return True, False, "missing delivery record" + try: + record = json.loads(match.group(1)) + except json.JSONDecodeError: + return True, False, "invalid delivery record" + if record.get("schema") != "sync-pr-delivery-record/v1": + return True, False, "invalid delivery schema" + if record.get("terminal_disposition"): + return True, False, "terminal delivery record" + if record.get("delivery_state") != "sealed": + return True, False, f"delivery state is {record.get('delivery_state') or 'missing'}" + if not head_sha or record.get("sealed_head_sha") != head_sha: + return True, False, "sealed head does not match the PR head" + repository = base_repository + if record.get("repository") != repository: + return True, False, "delivery repository does not match the PR" + try: + lease_expires_at = datetime.fromisoformat( + str(record.get("lease_expires_at") or "").replace("Z", "+00:00") + ) + except ValueError: + return True, False, "delivery lease is invalid" + if lease_expires_at.tzinfo is None: + return True, False, "delivery lease is invalid" + if lease_expires_at <= datetime.now(UTC): + return True, False, "delivery lease expired" + return True, True, "exact head sealed" + def _normalize(value: str | None, default: str = "unknown") -> str: if value is None: @@ -294,6 +358,18 @@ def _active_lines( def summarize(context: SummaryContext) -> SummaryResult: docs_guard_result = _normalize(context.docs_guard_result or "success") + if context.delivery_seal_required and not context.delivery_seal_valid: + reason = context.delivery_seal_reason or "exact-head seal missing" + return SummaryResult( + lines=[ + "### Gate status", + f"Generated delivery hold: {_emoji('failure')} {reason}.", + "Maint 71 must complete bounded review settlement and seal this exact head.", + ], + state="failure", + description=f"Generated delivery is not sealed: {reason}.", + ) + if context.doc_only or not context.run_core: lines = _doc_only_lines(context.reason, docs_guard_result) description = ( @@ -431,6 +507,13 @@ def build_context() -> SummaryContext: artifacts_root = Path(os.environ.get("GATE_ARTIFACTS_ROOT", "gate_artifacts")) summary_path = _resolve_path("GITHUB_STEP_SUMMARY") output_path = _resolve_path("GITHUB_OUTPUT") + delivery_seal_required, delivery_seal_valid, delivery_seal_reason = _delivery_seal_from_event( + _resolve_path("GITHUB_EVENT_PATH") + ) + if _normalize(os.environ.get("DELIVERY_SEAL_RESULT"), "success") == "failure": + delivery_seal_required = True + delivery_seal_valid = False + delivery_seal_reason = delivery_seal_reason or "generated delivery seal job failed" return SummaryContext( doc_only=doc_only, @@ -445,6 +528,9 @@ def build_context() -> SummaryContext: summary_path=summary_path, output_path=output_path, python_required=python_required, + delivery_seal_required=delivery_seal_required, + delivery_seal_valid=delivery_seal_valid, + delivery_seal_reason=delivery_seal_reason, ) 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..e3a79cef31 --- /dev/null +++ b/.github/scripts/issue_format.py @@ -0,0 +1,611 @@ +#!/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 contextlib +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +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|component|method|path|config(?:uration)?|key|job|workflow|command)" +) +_TASK_TRAILING_SYMBOL_CATEGORY = r"(?:function|class|component|method|job|workflow)" +_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)?\s+-m\s+(?:pytest|unittest)\b|pytest\b|node\s+--test\b|" + r"(?:npm|pnpm|yarn)\s+(?:run\s+)?(?:test|vitest|jest|playwright)\b|" + r"(?:make|just|cargo|go|dotnet)\s+(?:test|check)\b|" + r"gh\s+(?:workflow\s+run|run)\s+\S+|curl\s+\S+)" +) + + +def _concrete_span(span: str) -> bool: + """Return True when a backticked/unquoted token names a real work target.""" + span = span.strip().rstrip(".,;:!?") + 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("'\"") + # A quoted identifier immediately following an explicit target category + # is concrete even when its spelling is a normal lowercase word (for + # example, ``function `validate` `` or ``key `timeout` ``). + if ( + token.startswith("`") + and token.endswith("`") + and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", span) + and not re.fullmatch(_TASK_CATEGORY, span, re.I) + ): + return True + if _concrete_span(span): + return True + # Natural prose often names a PascalCase symbol before its category (for + # example, "UserForm component"). The category makes this use specific; + # generic capitalized product words without that context remain rejected. + for match in re.finditer( + rf"\b([A-Z][A-Za-z0-9]*[A-Z][A-Za-z0-9]+)\s+{_TASK_TRAILING_SYMBOL_CATEGORY}\b", + item, + ): + if _concrete_span(match.group(1)): + 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 + # Command names in prose ("make the UI better", "go improve it") are not + # concrete targets. Require a command-shaped invocation instead. + return re.search(rf"(?:^|[\s`]){_TASK_COMMAND}", item, re.I) is not None + + +# --- Addressability --------------------------------------------------------- +# +# A perfectly-formatted issue can still be impossible to action, because format +# and addressability are different axes. Fine-Art-Archive #406-409 are the +# reference case: every required section present, `agents:formatted` awarded, +# and every one of the six paths #409 instructs an agent to modify exists only +# in a local workspace that is not on GitHub. A lane cloned the repo, could not +# find `scripts/automation_audit.py`, and stalled — `agents:tried-codex` -> +# `needs-human` -> `agents:auto-pilot-pause`. +# +# `_task_has_concrete_target` already requires a task to NAME a file; this asks +# the next question — does that file exist in the repo the issue was filed +# against? +# +# The gate is deliberately asymmetric. Naming a file that does not exist yet is +# normal and correct ("create `src/foo.py`"), so unresolved paths alone never +# fail. What fails is an issue that cites several paths and resolves NONE of +# them, which means it describes some other tree. That also aligns with the +# format's own requirement that `Why` cite current evidence at `file:line`: an +# issue with no resolvable path has no current evidence in this repo. +_PATH_SPAN = re.compile(r"`([^`\n]+)`") +_UNQUOTED_PATH = re.compile( + r"(?]*>\s*Original Issue\s*" + r"(?P`{3,}|~{3,})text\s*\n(?P.*?)\n(?P=fence)\s*", + re.DOTALL | re.IGNORECASE, +) +# Self-referential boilerplate. The format contract tells authors to cite it, so +# nearly every body mentions it — and it lives in every repo, which means +# counting it as evidence would let one boilerplate line defeat the whole gate. +# Measured on Fine-Art-Archive #409: this was the ONLY path that resolved. +_NOT_EVIDENCE = frozenset( + { + "docs/AGENT_ISSUE_FORMAT.md", + "AGENT_ISSUE_FORMAT.md", + } +) +_PATHISH_EXT = ( + ".py", + ".js", + ".ts", + ".tsx", + ".jsx", + ".sh", + ".rb", + ".go", + ".rs", + ".java", + ".yml", + ".yaml", + ".json", + ".toml", + ".ini", + ".cfg", + ".md", + ".sql", + ".html", + ".css", + ".xml", + ".txt", + ".lock", + ".env", +) +# Below this many citations the sample is too small to conclude "wrong repo" — +# a single path in a prose aside must not fail an otherwise sound issue. +_MIN_CITATIONS_TO_JUDGE = 3 + + +def _normalise_cited_path(raw: str) -> str | None: + """Return a safe repo-relative citation, or None for non-path text.""" + candidate = _NODE_SUFFIX.sub("", raw.strip()) + candidate = _LINE_SUFFIX.sub("", candidate) + if candidate.startswith("./"): + candidate = candidate[2:] + if not candidate or " " in candidate: + return None + if candidate.startswith("/") or any(part == ".." for part in candidate.split("/")): + return None + if candidate in _NOT_EVIDENCE: + return None + if "://" in candidate or candidate.startswith(("-", "@", "#", "$")): + return None + if any(ch in candidate for ch in "*?<>|"): # globs and placeholders + return None + if "/" not in candidate and not candidate.endswith(_PATHISH_EXT): + return None + return candidate + + +def _task_items(body: str) -> list[str]: + return re.findall(r"^\s*[-*]\s*\[[ xX]\]\s*(.+)$", body or "", re.M) + + +def _candidate_matches(text: str) -> list[tuple[int, int, str]]: + """Extract candidate paths together with their position in a task.""" + matches = [(match.start(), match.end(), match.group(1)) for match in _PATH_SPAN.finditer(text)] + matches.extend( + (match.start(), match.end(), match.group(1)) for match in _UNQUOTED_PATH.finditer(text) + ) + return sorted(matches) + + +_EXPLICIT_CREATE_PREFIX = re.compile( + r"\b(?:create|add|introduce|scaffold|generate|write)\s+" + r"(?:(?:a|the)\s+)?(?:new\s+)?(?:files?\s+)?(?:at\s+|named\s+)?$", + re.I, +) + + +def _cited_paths(body: str) -> list[str]: + """Safe repo-relative citations, including unquoted task paths.""" + seen: dict[str, None] = {} + for raw in _PATH_SPAN.findall(body or ""): + if candidate := _normalise_cited_path(raw): + seen.setdefault(candidate, None) + for item in _task_items(body): + for raw in _UNQUOTED_PATH.findall(item): + if candidate := _normalise_cited_path(raw): + seen.setdefault(candidate, None) + return list(seen) + + +def _created_paths(body: str) -> set[str]: + """Paths explicitly created by a task are not pre-existing evidence.""" + created: set[str] = set() + for item in _task_items(body): + creation_chain = False + previous_end = 0 + seen_in_item: set[str] = set() + for start, end, raw in _candidate_matches(item): + candidate = _normalise_cited_path(raw) + if candidate is None or candidate in seen_in_item: + continue + seen_in_item.add(candidate) + # The path must be the direct object of an explicit file-creation + # phrase. "Add validation to missing/a.py" modifies a cited file; + # it does not declare that file as new. + prefix = item[:start].rstrip("`") + if _EXPLICIT_CREATE_PREFIX.search(prefix): + creation_chain = True + elif creation_chain: + separator = item[previous_end:start] + creation_chain = bool( + re.fullmatch(r"\s*(?:[,;]\s*)?(?:(?:and|or)\s+)?", separator, re.I) + ) + if creation_chain: + created.add(candidate) + previous_end = end + return created + + +def _search_roots(repo_root: Path) -> list[Path]: + """`repo_root` plus the conventional source roots, and packages under them. + + Issues routinely cite a path relative to the package rather than the repo — + `collect/quality.py` for `src/fine_art_archive/collect/quality.py`. Treating + those as missing would fill the advisory with false alarms and, worse, make + `resolved` undercount, which is what the failure rule keys on. + """ + roots = [repo_root] + for name in ("src", "lib", "app", "packages"): + base = repo_root / name + if not base.is_dir(): + continue + roots.append(base) + # One level deeper covers the src// layout. Bounded so a large + # monorepo cannot turn this into a directory walk. + with contextlib.suppress(OSError): + roots.extend(sorted(p for p in base.iterdir() if p.is_dir())[:12]) + return roots + + +def _resolve_citations(body: str, repo_root: Path) -> tuple[list[str], list[str]]: + """Split cited paths into (resolved, unresolved) against `repo_root`.""" + roots = _search_roots(repo_root) + resolved: list[str] = [] + unresolved: list[str] = [] + for candidate in _cited_paths(body): + found = any((root / candidate).exists() for root in roots) + (resolved if found else unresolved).append(candidate) + return resolved, unresolved + + +def _list_content_indent(line: str) -> int | None: + """Return the content indentation established by a Markdown list marker.""" + match = re.match(r"^( {0,3})(?:[-+*]|\d+[.)]) +", line) + return match.end() if match else None + + +def _fence_match(line: str, list_indent: int | None) -> re.Match[str] | None: + """Match a Markdown fence, including a fence nested in the current list.""" + match = re.match(r"^( *)(`{3,}|~{3,})", line) + if match is None: + return None + indent = len(match.group(1)) + if indent <= 3: + return match + if list_indent is not None and list_indent <= indent <= list_indent + 3: + return match + return 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 + list_indent: int | None = None + for i, line in enumerate(body.splitlines()): + if (new_list_indent := _list_content_indent(line)) is not None: + list_indent = new_list_indent + elif ( + line.strip() + and fence is None + and len(line) - len(line.lstrip(" ")) < (list_indent or 0) + ): + list_indent = None + fence_match = _fence_match(line, list_indent) + if fence_match: + marker = fence_match.group(2) + if fence is None: + fence = (marker[0], len(marker)) + elif ( + marker[0] == fence[0] + and len(marker) >= fence[1] + and not line[fence_match.end() :].strip() + ): + 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]) + + +def _without_fenced_code(text: str) -> str: + """Remove Markdown fences so examples cannot satisfy issue requirements.""" + kept: list[str] = [] + fence: tuple[str, int] | None = None + list_indent: int | None = None + for line in text.splitlines(): + if (new_list_indent := _list_content_indent(line)) is not None: + list_indent = new_list_indent + elif ( + line.strip() + and fence is None + and len(line) - len(line.lstrip(" ")) < (list_indent or 0) + ): + list_indent = None + match = _fence_match(line, list_indent) + if match: + marker = match.group(2) + if fence is None: + fence = (marker[0], len(marker)) + elif ( + marker[0] == fence[0] + and len(marker) >= fence[1] + and not line[match.end() :].strip() + ): + # Closing fences are marker-only (optional whitespace); trailing + # content such as a language tag must not end the fence. + fence = None + continue + if fence is None: + kept.append(line) + return "\n".join(kept) + + +def _strip_original_issue_blocks(text: str) -> str: + """Remove only the formatter's canonical fenced provenance block.""" + return _ORIGINAL_ISSUE_BLOCK_RE.sub("", text).rstrip() + + +@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) + advisories: list[str] = field(default_factory=list) + + def as_markdown(self) -> str: + if self.ok and not self.missing_recommended and not self.advisories: + 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.advisories: + out.extend(["", "_Advisory:_"]) + out.extend(f"- {advisory}" for advisory in self.advisories) + 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, repo_root: Path | None = None) -> Report: + """Check an issue body; every structural format problem is non-conforming. + + `repo_root`, when given, additionally checks that the paths the body cites + actually exist there — see the Addressability block above. It is optional so + the validator stays a pure body check for callers that have no checkout. + """ + report = Report() + body = _strip_original_issue_blocks(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*(.+)$", + _without_fenced_code(_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) + acceptance_prose = _without_fenced_code(acceptance) + if not GATE.search(acceptance_prose): + report.problems.append( + "`Acceptance Criteria` names no test, runnable command or observable " + "verification gate — Definition of Ready / Quality Bar §2 requires one." + ) + prose = re.sub(r"(?= _MIN_CITATIONS_TO_JUDGE and not resolved: + report.problems.append( + f"None of the {cited} paths this issue cites exist in this repository " + f"({', '.join(f'`{p}`' for p in unresolved_evidence[:6])}" + + (", …" if cited > 6 else "") + + "). An agent cloning this repo has nothing to act on. File it " + "against the repo that holds the code, or cite the evidence here." + ) + elif unresolved: + report.advisories.append( + f"{len(unresolved)} cited path(s) do not exist yet: " + + ", ".join(f"`{p}`" for p in unresolved[:6]) + + (", …" if len(unresolved) > 6 else "") + + " — expected when a task creates them; check for typos otherwise." + ) + + 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() + # CI runs this after `actions/checkout`, so the working directory IS the + # repo the issue was filed against — which is exactly what addressability + # must be judged against. + report = validate(body, repo_root=Path.cwd()) + print(report.as_markdown()) + return 0 if report.ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/keepalive_challenge_due.js b/.github/scripts/keepalive_challenge_due.js new file mode 100644 index 0000000000..595733477b --- /dev/null +++ b/.github/scripts/keepalive_challenge_due.js @@ -0,0 +1,150 @@ +'use strict'; + +const crypto = require('node:crypto'); + +const STATE_RE = //gs; +const KEEPALIVE_SUMMARY_MARKER = ''; +const TRUSTED_KEEPALIVE_STATE_AUTHORS = new Set([ + 'agents-workflows-bot[bot]', + 'stranske-keepalive[bot]', +]); + +function isTrustedKeepaliveStateComment(comment = {}) { + const body = String(comment?.body || ''); + const login = String(comment?.user?.login || '').trim().toLowerCase(); + const type = String(comment?.user?.type || '').trim().toLowerCase(); + return ( + body.includes(KEEPALIVE_SUMMARY_MARKER) && + TRUSTED_KEEPALIVE_STATE_AUTHORS.has(login) && + type === 'bot' + ); +} + +function authorityClaimPayload({ + repository, + prNumber, + boundaryFingerprint, + nonce, + sweepRunId, + sweepRunAttempt, +} = {}) { + const fields = { + repository: String(repository || '').toLowerCase(), + prNumber: String(prNumber || ''), + boundaryFingerprint: String(boundaryFingerprint || '').toLowerCase(), + nonce: String(nonce || '').toLowerCase(), + sweepRunId: String(sweepRunId || ''), + sweepRunAttempt: String(sweepRunAttempt || ''), + }; + if ( + !/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(fields.repository) || + !/^\d+$/.test(fields.prNumber) || + !/^[0-9a-f]{64}$/.test(fields.boundaryFingerprint) || + !/^[0-9a-f]{64}$/.test(fields.nonce) || + !/^\d+$/.test(fields.sweepRunId) || + !/^\d+$/.test(fields.sweepRunAttempt) + ) { + return ''; + } + return [ + 'keepalive-authority-claim:v1', + `repository=${fields.repository}`, + `pr=${fields.prNumber}`, + `fingerprint=${fields.boundaryFingerprint}`, + `nonce=${fields.nonce}`, + `sweep_run_id=${fields.sweepRunId}`, + `sweep_run_attempt=${fields.sweepRunAttempt}`, + ].join('\n'); +} + +function signAuthorityChallengeClaim({ signingKey, ...claim } = {}) { + const key = String(signingKey || ''); + const payload = authorityClaimPayload(claim); + if (!key || !payload) return ''; + return crypto.createHmac('sha256', key).update(payload).digest('hex'); +} + +function verifyAuthorityChallengeClaim({ signature, ...options } = {}) { + const supplied = String(signature || '').toLowerCase(); + const expected = signAuthorityChallengeClaim(options); + if (!/^[0-9a-f]{64}$/.test(supplied) || !expected) return false; + return crypto.timingSafeEqual(Buffer.from(supplied, 'hex'), Buffer.from(expected, 'hex')); +} + +function verifyAuthorityChallengeEnvelope({ + claimJson, + signingKey, + repository, + prNumber, + boundaryFingerprint, +} = {}) { + let claim; + try { + claim = JSON.parse(String(claimJson || '')); + } catch (_) { + return false; + } + if (!claim || typeof claim !== 'object' || Array.isArray(claim)) return false; + return verifyAuthorityChallengeClaim({ + signingKey, + signature: claim.signature, + repository, + prNumber, + boundaryFingerprint, + nonce: claim.nonce, + sweepRunId: claim.sweep_run_id, + sweepRunAttempt: claim.sweep_run_attempt, + }); +} + +function parseLatestKeepaliveState(comments = []) { + let latest = null; + for (const comment of comments) { + if (!isTrustedKeepaliveStateComment(comment)) continue; + const body = String(comment?.body || ''); + for (const match of body.matchAll(STATE_RE)) { + try { + latest = JSON.parse(match[1]); + } catch (_) { + // Ignore malformed or manually edited state markers and keep looking. + } + } + } + return latest; +} + +function selectDueAuthorityChallenge({ labels = [], comments = [], now = new Date() } = {}) { + const labelNames = new Set(labels.map((label) => String(label?.name || label).toLowerCase())); + if (!labelNames.has('agent:needs-attention') || labelNames.has('needs-human')) return null; + + const state = parseLatestKeepaliveState(comments); + const attention = state?.attention; + if ( + attention?.owner !== 'automation' || + attention?.disposition !== 'challenge-due' || + !attention?.challenge_due_at + ) { + return null; + } + + const dueAt = Date.parse(attention.challenge_due_at); + const nowMs = now instanceof Date ? now.getTime() : Date.parse(String(now)); + if (!Number.isFinite(dueAt) || !Number.isFinite(nowMs) || dueAt > nowMs) return null; + + return { + dueAt: new Date(dueAt).toISOString(), + key: String(attention.key || ''), + boundaryFingerprint: String(attention.boundary_fingerprint || ''), + nextAction: String(attention.next_action || ''), + }; +} + +module.exports = { + authorityClaimPayload, + isTrustedKeepaliveStateComment, + parseLatestKeepaliveState, + selectDueAuthorityChallenge, + signAuthorityChallengeClaim, + verifyAuthorityChallengeClaim, + verifyAuthorityChallengeEnvelope, +}; diff --git a/.github/scripts/keepalive_loop.js b/.github/scripts/keepalive_loop.js index 07900d06f4..e1e578cad8 100644 --- a/.github/scripts/keepalive_loop.js +++ b/.github/scripts/keepalive_loop.js @@ -2,16 +2,22 @@ const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const { parseScopeTasksAcceptanceSections } = require('./issue_scope_parser'); const { getGithubApiCache } = require('./github-api-cache-client'); -const { loadKeepaliveState, formatStateComment } = require('./keepalive_state'); +const { + loadKeepaliveState, + formatStateComment, + upsertStateCommentBody, +} = require('./keepalive_state'); const { resolvePromptMode } = require('./keepalive_prompt_routing'); const { classifyError, ERROR_CATEGORIES } = require('./error_classifier'); const { formatFailureComment } = require('./failure_comment_formatter'); const { detectConflicts } = require('./conflict_detector'); const { parseTimeoutConfig } = require('./timeout_config'); const { ensureRateLimitWrapped } = require('./github-rate-limited-wrapper'); +const { verifyAuthorityChallengeClaim } = require('./keepalive_challenge_due'); // Token load balancer for rate limit management let tokenLoadBalancer = null; @@ -23,7 +29,7 @@ try { const ATTEMPT_HISTORY_LIMIT = 20; const ATTEMPTED_TASK_LIMIT = 20; -const HUMAN_BLOCKER_LABELS = ['agent:needs-attention', 'needs-human']; +const AUTOMATION_ATTENTION_LABELS = ['agent:needs-attention']; const TIMEOUT_VARIABLE_NAMES = [ 'WORKFLOW_TIMEOUT_DEFAULT', @@ -57,16 +63,134 @@ const AGENT_EXECUTION_ACTIONS = new Set(['run', 'fix', 'conflict']); // Resolve default agent from registry let _defaultAgent = 'codex'; +let _agentRegistry = { default_agent: 'codex', agents: {}, authority_shared_secrets: [] }; try { const { loadAgentRegistry } = require('./agent_registry.js'); - _defaultAgent = loadAgentRegistry().default_agent || 'codex'; + _agentRegistry = loadAgentRegistry(); + _defaultAgent = _agentRegistry.default_agent || 'codex'; } catch (_) { /* registry not available */ } function normalise(value) { return String(value ?? '').trim(); } -async function clearStaleHumanBlockerLabels({ github, owner, repo, prNumber, core }) { +function buildAuthorityChallengeEvidence({ + agentSummary, + summaryReason, + agentType, + operation, +} = {}) { + const rawSummary = normalise(agentSummary || summaryReason).replace(/\s+/g, ' '); + const routedAgent = (normalise(agentType) || _defaultAgent).toLowerCase(); + const authorityCredentialAllowlist = new Set([ + ...(_agentRegistry.authority_shared_secrets || []), + ...(_agentRegistry.agents?.[routedAgent]?.required_secrets || []), + ].map(value => normalise(value)).filter(value => /^[A-Z][A-Z0-9]*_[A-Z0-9_]+$/.test(value))); + const namedCredentialRemedyPatterns = [ + /\b(?:missing|required|unset|unavailable|undefined)\b\s+(?:the\s+)?(?:(?:api|oauth|access|auth|private|signing|service|bot)\s+)?(?:keys?|tokens?|secrets?|passwords?|credentials?)\s*[:=]?\s*\b([A-Z][A-Z0-9]*_[A-Z0-9_]+)\b/gi, + /\b(?:missing|required|unset|unavailable|undefined)\b\s*[:=]?\s*\b([A-Z][A-Z0-9]*_[A-Z0-9_]+)\b/gi, + /\bmissing\s+[A-Za-z][A-Za-z0-9_.-]*\s+auth\s*:\s*set\s+(?:the\s+)?([A-Z][A-Z0-9]*_[A-Z0-9_]+)\b/gi, + ]; + let canonicalCredentialTarget = ''; + for (const pattern of namedCredentialRemedyPatterns) { + for (const match of rawSummary.matchAll(pattern)) { + if (authorityCredentialAllowlist.has(match[1])) canonicalCredentialTarget = match[1]; + } + } + const namespacedOauthScope = String.raw`(?:repo\s*:\s*(?:status|invite)|user\s*:\s*(?:email|follow)|codespace\s*:\s*secrets|(?:read|write|admin)\s*:\s*(?:org|repo_hook|public_key|gpg_key|ssh_signing_key|discussion|enterprise|project)|read\s*:\s*(?:user|audit_log|network_configurations)|write\s*:\s*network_configurations|admin\s*:\s*org_hook|manage_billing\s*:\s*(?:enterprise|copilot)|manage_runners\s*:\s*enterprise|scim\s*:\s*enterprise)`; + const structuredPermissionTarget = String.raw`(?:(?:actions|attestations|checks|contents|deployments|discussions|id-token|issues|models|packages|pages|pull-requests|repository-projects|security-events|statuses|workflows?)\s*:\s*(?:read|write|admin|none)|${namespacedOauthScope})`; + const standaloneOauthScope = String.raw`(?:repo|workflow|gist|notifications|user|delete_repo|codespace|copilot|project)`; + const standaloneOauthScopes = new Set( + ['repo', 'workflow', 'gist', 'notifications', 'user', 'delete_repo', 'codespace', 'copilot', 'project'], + ); + const permissionTarget = String.raw`(?:${structuredPermissionTarget}|${standaloneOauthScope})`; + // The target itself remains strictly allowlisted; presentation wrappers are + // optional so plain, quoted, code, bold, and bracketed runner output all + // produce the same bounded remedy. + const quotedPermissionTarget = String.raw`(?:\*\*|__|[\x60"'\[<(])?(?${permissionTarget})(?:\*\*|__|[\x60"'\])>])?`; + const remedyCue = String.raw`(?:missing|required|requires?|needs?|insufficient|unavailable|unset|undefined|grant|enable)`; + const permissionRemedyPatterns = [ + // A cue must be part of the same grammatical remedy as the target. Do not + // let an unrelated target borrow a nearby cue or context word. + new RegExp(String.raw`\b(?${remedyCue})\b\s+(?:the\s+)?(?scopes?|permissions?)\b\s*(?:[:=]\s*)?${quotedPermissionTarget}`, 'gi'), + new RegExp(String.raw`\b(?${remedyCue})\b\s+(?:the\s+)?${quotedPermissionTarget}\s+(?scopes?|permissions?)\b`, 'gi'), + new RegExp(String.raw`\b(?scopes?|permissions?)\b\s*(?:is\s+|are\s+)?(?${remedyCue})\b\s*(?:[:=]\s*)?${quotedPermissionTarget}`, 'gi'), + new RegExp(String.raw`\b(?scopes?|permissions?)\b\s*(?:[:=]\s*)?${quotedPermissionTarget}\s+(?:is\s+|are\s+)?(?${remedyCue})\b`, 'gi'), + new RegExp(String.raw`${quotedPermissionTarget}\s+(?scopes?|permissions?)\b\s+(?:is\s+|are\s+)?(?${remedyCue})\b`, 'gi'), + ]; + let contextualPermissionTarget = null; + for (const pattern of permissionRemedyPatterns) { + for (const match of rawSummary.matchAll(pattern)) { + const { context, target } = match.groups; + if (standaloneOauthScopes.has(target.toLowerCase()) && !context.toLowerCase().startsWith('scope')) continue; + if (!contextualPermissionTarget || match.index > contextualPermissionTarget.index) { + contextualPermissionTarget = { target, index: match.index }; + } + } + } + const canonicalPermissionTarget = contextualPermissionTarget?.target + ? contextualPermissionTarget.target.replace(/\s*:\s*/g, ':') + : ''; + if (!rawSummary) { + return { fingerprint: '', detail: '', humanAction: '', actionable: false }; + } + // Durable state and human-visible actions never copy arbitrary runner text. + // Extract only finite, explicitly allowlisted authority facts. + const statusCodes = [...new Set( + [...rawSummary.matchAll(/\b(?:HTTP\s*)?(401|403)\b/gi)].map(match => match[1]), + )].sort(); + const challengedOperation = (normalise(operation) || 'run').toLowerCase(); + const actionable = Boolean(canonicalCredentialTarget || canonicalPermissionTarget); + const detailParts = []; + if (canonicalCredentialTarget) detailParts.push(`Required credential: ${canonicalCredentialTarget}`); + if (canonicalPermissionTarget) detailParts.push(`Required permission: ${canonicalPermissionTarget}`); + if (statusCodes.length) detailParts.push(`HTTP ${statusCodes.join('/')}`); + const detail = detailParts.length + ? detailParts.join('; ') + : 'Authority failure'; + const fingerprintProjection = JSON.stringify({ + credential: canonicalCredentialTarget, + permission: canonicalPermissionTarget.toLowerCase(), + status_codes: statusCodes, + }); + return { + fingerprint: crypto.createHash('sha256') + .update(`agent=${routedAgent}|operation=${challengedOperation}|${fingerprintProjection}`) + .digest('hex'), + detail, + humanAction: actionable + ? `Resolve the reproduced runner authority failure: ${detail}` + : '', + actionable, + }; +} + +function selectEscalationDisposition({ + required, + errorCategory, + summaryReason, + authorityChallengeConfirmed = false, +} = {}) { + if (!required) return 'none'; + if (authorityChallengeConfirmed) return 'needs-human'; + const reason = normalise(summaryReason).toLowerCase(); + if (errorCategory === ERROR_CATEGORIES.auth && !reason.includes('rate-limit')) { + return 'challenge-due'; + } + // Runner/CI/resource/logic/unknown failures and exhausted iteration budgets + // prove only that the current strategy stopped. They remain automation-owned. + return 'automation-retry'; +} + +async function clearStaleHumanBlockerLabels({ + github, + owner, + repo, + prNumber, + core, + automationOwned = false, +}) { + if (!automationOwned) return { complete: true, removed: [] }; let currentLabels = []; try { const { data } = await github.rest.issues.listLabelsOnIssue({ @@ -80,13 +204,19 @@ async function clearStaleHumanBlockerLabels({ github, owner, repo, prNumber, cor .filter(Boolean); } catch (error) { core?.warning?.(`Unable to inspect PR labels before stale human-blocker cleanup: ${error.message}`); - return []; + return { complete: false, removed: [] }; } - const staleLabels = HUMAN_BLOCKER_LABELS.filter((label) => + // `needs-human` is a hard authority blocker. Keepalive never removes it: + // label provenance can change between evaluation and this live read, so an + // operator-confirmed blocker must be cleared only by the independent + // challenge controller. This cleanup is limited to keepalive's own soft + // attention label and requires persisted automation ownership. + const staleLabels = AUTOMATION_ATTENTION_LABELS.filter((label) => currentLabels.includes(label.toLowerCase()) ); const removed = []; + let complete = true; for (const label of staleLabels) { try { await github.rest.issues.removeLabel({ @@ -100,6 +230,7 @@ async function clearStaleHumanBlockerLabels({ github, owner, repo, prNumber, cor if (error?.status === 404) { continue; } + complete = false; core?.warning?.(`Failed to remove stale ${label} label: ${error.message}`); } } @@ -107,7 +238,7 @@ async function clearStaleHumanBlockerLabels({ github, owner, repo, prNumber, cor if (removed.length > 0) { core?.info?.(`Removed stale human-blocker label(s) after successful keepalive state: ${removed.join(', ')}`); } - return removed; + return { complete, removed }; } function resolvePromptRouting({ scenario, mode, action, reason } = {}) { @@ -1115,7 +1246,12 @@ function classifyFailureDetails({ action, runResult, summaryReason, agentExitCod // If the agent runner reports failure with exit code 0, that strongly suggests // an infrastructure/control-plane hiccup rather than a code/tool failure. - if (runFailed && summaryReason === 'agent-run-failed' && (!agentExitCode || agentExitCode === '0')) { + if ( + runFailed && + summaryReason === 'agent-run-failed' && + (!agentExitCode || agentExitCode === '0') && + category !== ERROR_CATEGORIES.auth + ) { category = ERROR_CATEGORIES.transient; } @@ -2660,11 +2796,14 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload // An iteration is productive if it has a reasonable productivity score const isProductive = productivityScore >= 20 && !hasRecentFailures; - // max_iterations is a hard per-PR budget. Once reached, stop dispatching - // and require a human to raise the budget or remove the blocker. + // max_iterations is the ordinary per-PR budget. Once reached, stop the + // current strategy; a single forced recovery lease may cross it once. const hasMaxIterations = maxIterations > 0; const reachedMaxIterations = hasMaxIterations && iteration >= maxIterations; - const shouldStopForMaxIterations = reachedMaxIterations; + // A force retry is a single, explicit recovery lease. It may cross the + // persisted round budget once; the summary step prevents a forced run from + // recursively dispatching another forced run. + const shouldStopForMaxIterations = reachedMaxIterations && !forceRetry; // Build task appendix for the agent prompt (after state load for reconciliation info) const taskAppendix = buildTaskAppendix(normalisedSections, checkboxCounts, state, { prBody: pr.body }); @@ -2693,7 +2832,7 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload // This prevents premature stop when the verifier identifies unmet criteria. const needsVerification = allComplete && !verificationDone && !verificationAttempted; const needsVerificationRetry = allComplete && verificationFailed - && verificationAttemptCount < maxVerificationAttempts; + && (verificationAttemptCount < maxVerificationAttempts || forceRetry); // Only treat GitHub API conflicts as definitive (mergeable_state === 'dirty') // CI-log based conflict detection has too many false positives from commit messages @@ -2765,7 +2904,14 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload // This ensures at least one fix attempt is made before giving up, and // that transient cancelled rounds don't consume the fix budget. const gateFailure = await classifyGateFailure({ github, context, pr, core }); - if (gateFailure.shouldFixMode && consecutiveFixRounds < fixAttemptMax) { + if (forceRetry) { + // A terminal recovery lease must perform recovery work, even after + // the ordinary complete-Gate/fix budgets are exhausted. The summary + // consumes the lease after this single non-recursive fix attempt. + action = 'fix'; + reason = `force-retry-fix-${gateFailure.failureType || 'complete-gate'}`; + if (core) core.info(`Forced recovery: retrying complete Gate failure (${gateFailure.failureType || 'unknown'}).`); + } else if (gateFailure.shouldFixMode && consecutiveFixRounds < fixAttemptMax) { // Fix is possible and we haven't exhausted fix attempts — try to fix action = 'fix'; reason = `fix-${gateFailure.failureType}`; @@ -3042,6 +3188,12 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in inputs.roundsWithoutTaskCompletion ?? inputs.rounds_without_task_completion; const agentType = normalise(inputs.agent_type ?? inputs.agentType) || _defaultAgent; const runResult = normalise(inputs.runResult || inputs.run_result); + const agentExecutionStartedInput = + inputs.agent_execution_started ?? inputs.agentExecutionStarted; + const agentExecutionStarted = + agentExecutionStartedInput === undefined || agentExecutionStartedInput === '' + ? null + : toBool(agentExecutionStartedInput, false); const stateTrace = normalise(inputs.trace || inputs.keepalive_trace || ''); // Delegation policy inputs (from evaluate step when agent:auto is active) @@ -3049,12 +3201,33 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in const delegationShouldSwitch = toBool(inputs.delegation_should_switch ?? inputs.delegationShouldSwitch, false); const agentRoutingMode = normalise(inputs.agent_routing_mode ?? inputs.agentRoutingMode); - const { state: previousState, commentId } = await loadKeepaliveState({ + const { + state: previousState, + commentId, + commentAuthorLogin, + commentAuthorType, + } = await loadKeepaliveState({ github, context, prNumber, trace: stateTrace, }); + const trustedSummaryAuthor = normalise( + inputs.trusted_summary_author ?? inputs.trustedSummaryAuthor, + ).toLowerCase(); + const existingSummaryAuthor = normalise(commentAuthorLogin).toLowerCase(); + const existingSummaryAuthorType = normalise(commentAuthorType).toLowerCase(); + const migrateSummaryWriter = Boolean( + commentId && + trustedSummaryAuthor && + (existingSummaryAuthor !== trustedSummaryAuthor || existingSummaryAuthorType !== 'bot'), + ); + if (migrateSummaryWriter) { + core?.info?.( + `Creating a trusted App-owned keepalive summary; existing writer ` + + `${existingSummaryAuthor || 'unknown'} is not ${trustedSummaryAuthor}.`, + ); + } const hasTasksTotalInput = tasksTotalInput !== undefined && tasksTotalInput !== ''; const hasTasksUncheckedInput = tasksUncheckedInput !== undefined && tasksUncheckedInput !== ''; @@ -3102,6 +3275,10 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in ); // Resolve force_retry early — needed by the live-recount and zero-activity blocks. const isForceRetry = toBool(inputs.force_retry ?? inputs.forceRetry, false); + const previousRecoveryLease = + previousState?.recovery_lease && typeof previousState.recovery_lease === 'object' + ? { ...previousState.recovery_lease } + : {}; let roundsWithoutTaskCompletion = hasRoundsWithoutTaskCompletionInput ? toNumber(roundsWithoutTaskCompletionInput, 0) @@ -3365,6 +3542,82 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in const errorCategory = failureDetails.category; const errorType = failureDetails.type; const errorRecovery = failureDetails.recovery; + const previousAttention = previousState?.attention && typeof previousState.attention === 'object' + ? previousState.attention + : {}; + const previousAuthorityChallenge = + previousAttention.owner === 'automation' && + previousAttention.disposition === 'challenge-due'; + const authorityChallengeFingerprint = normalise( + inputs.authority_challenge_fingerprint ?? inputs.authorityChallengeFingerprint, + ); + let authorityChallengeClaim = {}; + const rawAuthorityChallengeClaim = normalise( + inputs.authority_challenge_claim ?? inputs.authorityChallengeClaim, + ); + if (rawAuthorityChallengeClaim && rawAuthorityChallengeClaim.length <= 2048) { + try { + const parsed = JSON.parse(rawAuthorityChallengeClaim); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + authorityChallengeClaim = parsed; + } + } catch { + // A malformed or manually crafted claim is deliberately untrusted. + } + } + const authorityChallengeClaimVerified = verifyAuthorityChallengeClaim({ + signingKey: inputs.authority_challenge_signing_key, + signature: authorityChallengeClaim.signature, + repository: `${context.repo.owner}/${context.repo.repo}`, + prNumber, + boundaryFingerprint: authorityChallengeFingerprint, + nonce: authorityChallengeClaim.nonce, + sweepRunId: authorityChallengeClaim.sweep_run_id, + sweepRunAttempt: authorityChallengeClaim.sweep_run_attempt, + }); + const authorityChallengeProvenanceMatches = + previousAuthorityChallenge && + Boolean(authorityChallengeFingerprint) && + authorityChallengeClaimVerified && + authorityChallengeFingerprint === previousAttention.boundary_fingerprint; + const authorityEvidence = buildAuthorityChallengeEvidence({ + agentSummary, + summaryReason, + agentType, + operation: action, + }); + const escalationRequired = + ((action === 'run' || action === 'fix') && runResult && runResult !== 'success' && errorCategory !== ERROR_CATEGORIES.transient) || + (action === 'stop' && !isSuccessStop && !isNeutralStop && errorCategory !== ERROR_CATEGORIES.transient); + const authorityChallengeConfirmed = + authorityChallengeProvenanceMatches && + escalationRequired && + errorCategory === ERROR_CATEGORIES.auth && + Boolean(authorityEvidence.fingerprint) && + authorityEvidence.actionable && + authorityEvidence.fingerprint === authorityChallengeFingerprint; + let escalationDisposition = selectEscalationDisposition({ + required: escalationRequired || stop, + errorCategory, + summaryReason, + authorityChallengeConfirmed, + }); + const recoveryLeaseReason = stop + ? normalise(summaryReason).replace(/-repeat$/, '') + : ''; + const recoveryLeaseKey = recoveryLeaseReason + ? `${recoveryLeaseReason}:max-iterations=${maxIterations}` + : ''; + const recoveryLeaseMatches = + Boolean(recoveryLeaseKey) && normalise(previousRecoveryLease.key) === recoveryLeaseKey; + const recoveryLeaseAlreadyIssued = + recoveryLeaseMatches && ['issued', 'consumed'].includes(previousRecoveryLease.status); + const shouldIssueTerminalRecoveryLease = + stop && + escalationDisposition === 'automation-retry' && + !isForceRetry && + !recoveryLeaseAlreadyIssued; + let hardHumanLabelApplied = false; const tasksComplete = Math.max(0, tasksTotal - tasksUnchecked); const allTasksComplete = tasksUnchecked === 0 && tasksTotal > 0; const previousCompleteGateFailureRounds = toNumber(previousState?.complete_gate_failure_rounds, 0); @@ -3813,17 +4066,24 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in '_To resume immediately: Wait for rate limit reset, or add additional API tokens._', ); } else if (stop) { + const challengeDue = escalationDisposition === 'challenge-due'; summaryLines.push( '', - '### 🛑 Paused – Human Attention Required', + challengeDue + ? '### 🔎 Paused – Independent Authority Challenge Required' + : '### 🔁 Paused – Automation Recovery Required', '', - 'The keepalive loop has paused due to repeated failures.', + challengeDue + ? 'The keepalive loop found a possible access boundary. Automation must verify it before asking a human.' + : 'The keepalive loop paused this execution strategy after repeated failures; ownership remains with automation.', '', '**To resume:**', - '1. Investigate the failure reason above', - '2. Fix any issues in the code or prompt', - '3. Remove the `needs-human` label from this PR', - '4. The next Gate pass will restart the loop', + challengeDue + ? '1. Reproduce the access failure from current state and verify the exact unavailable permission or secret' + : '1. Route the failure to CI repair, retry/backoff, alternate-agent, review fallback, or issue decomposition', + '2. Record a concrete next action and responsible automation worker', + '3. Use `needs-human` only after an independent review proves a real authority boundary', + '4. Re-run Gate or apply the automation retry path', '', '_Or manually edit this comment to reset `failure: {}` in the state below._', ); @@ -3934,6 +4194,64 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in }, }; + const ordinaryProgressAfterRecovery = + !isForceRetry && + actionRunsAgent && + runResult === 'success' && + (agentFilesChanged > 0 || tasksCompletedThisRound > 0 || checklistChanged); + const forcedLeaseExecutedAgent = + actionRunsAgent && + (agentExecutionStarted === null ? true : agentExecutionStarted) && + Boolean(runResult) && + !['skipped', 'cancelled'].includes(runResult); + const recoveryLeaseBudgetChanged = + Object.keys(previousRecoveryLease).length > 0 && + toNumber(previousRecoveryLease.max_iterations, maxIterations) !== maxIterations; + if ( + isForceRetry && + previousRecoveryLease.status === 'issued' && + !isSuccessStop && + !isNeutralStop + ) { + const forcedLeaseStatus = forcedLeaseExecutedAgent ? 'consumed' : 'deferred'; + newState.recovery_lease = { + ...previousRecoveryLease, + status: forcedLeaseStatus, + ...(forcedLeaseExecutedAgent + ? { consumed_at: new Date().toISOString() } + : { + deferred_at: new Date().toISOString(), + deferred_reason: summaryReason, + }), + }; + } else if (shouldIssueTerminalRecoveryLease) { + const retryingDeferredLease = + recoveryLeaseMatches && previousRecoveryLease.status === 'deferred'; + const dispatchedAt = new Date().toISOString(); + newState.recovery_lease = { + key: recoveryLeaseKey, + reason: recoveryLeaseReason, + status: 'issued', + issued_at: retryingDeferredLease + ? previousRecoveryLease.issued_at || dispatchedAt + : dispatchedAt, + last_dispatched_at: dispatchedAt, + dispatch_attempt: retryingDeferredLease + ? toNumber(previousRecoveryLease.dispatch_attempt, 1) + 1 + : 1, + iteration: nextIteration, + max_iterations: maxIterations, + }; + } else if ( + Object.keys(previousRecoveryLease).length > 0 && + !recoveryLeaseBudgetChanged && + !ordinaryProgressAfterRecovery && + !isSuccessStop && + !isNeutralStop + ) { + newState.recovery_lease = previousRecoveryLease; + } + // Persist agent delegation state when in auto mode if (agentRoutingMode === 'auto' || previousState?.current_agent) { const previousDelegationLog = Array.isArray(previousState?.delegation_log) @@ -4017,9 +4335,6 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in }); } - const previousAttention = previousState?.attention && typeof previousState.attention === 'object' - ? previousState.attention - : {}; if (Object.keys(previousAttention).length > 0) { newState.attention = { ...previousAttention }; } @@ -4029,40 +4344,192 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in core.setOutput('error_category', errorCategory || ''); } - const shouldEscalate = - ((action === 'run' || action === 'fix') && runResult && runResult !== 'success' && errorCategory !== ERROR_CATEGORIES.transient) || - (action === 'stop' && !isSuccessStop && !isNeutralStop && errorCategory !== ERROR_CATEGORIES.transient); + const shouldEscalate = escalationRequired || stop; const shouldClearStaleHumanBlockers = isSuccessStop || + (previousAuthorityChallenge && runResult === 'success') || (gateConclusion === 'success' && tasksUnchecked === 0 && runResult === 'success' && (action === 'run' || action === 'fix')); - const attentionKey = [summaryReason, runResult, errorCategory, errorType, agentExitCode].filter(Boolean).join('|'); + const attentionKey = [ + summaryReason, + runResult, + errorCategory, + errorType, + agentExitCode, + authorityEvidence.fingerprint, + ].filter(Boolean).join('|'); const priorAttentionKey = normalise(previousAttention.key); + const previousAttentionHasLegacyOwnership = + Object.keys(previousAttention).length > 0 && + !normalise(previousAttention.owner) && + !normalise(previousAttention.disposition); + const previousAttentionAutomationOwned = + (previousAttention.owner === 'automation' && + ['automation-retry', 'challenge-due'].includes(previousAttention.disposition)) || + previousAttentionHasLegacyOwnership; + const challengeDueAt = escalationDisposition === 'challenge-due' + ? new Date().toISOString() + : null; + if (shouldEscalate) { + const firstSeenAt = priorAttentionKey === attentionKey + ? previousAttention.first_seen_at || new Date().toISOString() + : new Date().toISOString(); + if (escalationDisposition === 'needs-human') { + newState.attention = { + key: attentionKey, + disposition: 'needs-human', + owner: 'human', + first_seen_at: previousAttention.first_seen_at || firstSeenAt, + challenge_started_at: previousAttention.challenge_due_at || firstSeenAt, + confirmed_at: new Date().toISOString(), + confirmation: 'scheduled-current-state-recheck-reproduced-auth-boundary', + boundary_fingerprint: authorityEvidence.fingerprint, + boundary_detail: authorityEvidence.detail, + human_action: authorityEvidence.humanAction, + next_action: authorityEvidence.humanAction, + }; + } else { + newState.attention = { + key: attentionKey, + disposition: escalationDisposition, + owner: 'automation', + first_seen_at: firstSeenAt, + challenge_due_at: challengeDueAt, + boundary_fingerprint: escalationDisposition === 'challenge-due' + ? authorityEvidence.fingerprint + : '', + boundary_detail: escalationDisposition === 'challenge-due' + ? authorityEvidence.detail + : '', + next_action: escalationDisposition === 'challenge-due' + ? 'Independently rerun the current operation and confirm the same redacted authority-boundary fingerprint.' + : 'Route to automation retry/backoff, CI repair, alternate agent, or review fallback.', + }; + } + } // NOTE: Failure comment posting removed - handled by reusable-*-run.yml with proper deduplication // This prevents duplicate failure notifications on PRs - summaryLines.push('', formatStateComment(newState)); - const body = summaryLines.join('\n'); - try { - if (commentId) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: commentId, - body, - }); - } else { - await github.rest.issues.createComment({ + let summaryCommentId = migrateSummaryWriter ? 0 : commentId; + const persistSummary = async (body) => { + if (summaryCommentId) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: summaryCommentId, + body, + }); + } else { + const created = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + summaryCommentId = Number(created?.data?.id) || 0; + } + }; + + if (shouldClearStaleHumanBlockers) { + const cleanup = await clearStaleHumanBlockerLabels({ + github, owner: context.repo.owner, repo: context.repo.repo, - issue_number: prNumber, - body, + prNumber, + core, + automationOwned: previousAttentionAutomationOwned, }); + if (previousAttentionAutomationOwned && cleanup.complete) { + delete newState.attention; + } else if (previousAuthorityChallenge && runResult === 'success') { + if (cleanup.complete) { + delete newState.attention; + } else { + newState.attention = { + ...previousAttention, + cleanup_pending_label: true, + next_action: 'Retry removal of the automation-owned agent:needs-attention label after recovery.', + }; + } + } + } + + if (escalationDisposition === 'needs-human') { + // First persist a durable automation-owned transition containing the + // exact action. If either later API call fails, the PR never falls back + // to an actionless or falsely human-owned state. + const pendingState = { + ...newState, + attention: { + ...newState.attention, + disposition: 'challenge-due', + owner: 'automation', + challenge_due_at: new Date().toISOString(), + confirmation_pending_label: true, + next_action: authorityEvidence.humanAction, + }, + }; + const pendingLines = [ + ...summaryLines, + '', + '### ⏳ Confirmed Authority Boundary – Applying Blocker', + '', + `**Exact human action:** ${authorityEvidence.humanAction}`, + '', + formatStateComment(pendingState), + ]; + await persistSummary(pendingLines.join('\n')); + + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['needs-human'], + }); + hardHumanLabelApplied = true; + } catch (error) { + escalationDisposition = 'challenge-due'; + newState.attention = pendingState.attention; + core?.warning?.(`Failed to apply needs-human; retaining durable authority challenge: ${error.message}`); + } + + if (hardHumanLabelApplied) { + summaryLines.push( + '', + '### 🛑 Independent Authority Challenge Confirmed', + '', + 'A scheduled current-state recheck reproduced the same external authority boundary.', + `**Exact human action:** ${authorityEvidence.humanAction}`, + ); + } + } + + summaryLines.push('', formatStateComment(newState)); + const body = summaryLines.join('\n'); + await persistSummary(body); + + if (isForceRetry && forcedLeaseExecutedAgent) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: 'agent:retry', + }); + } catch (error) { + if ( + error?.status !== 404 && + !String(error?.message || '').includes('Label does not exist') + ) { + core?.warning?.(`Failed to consume agent:retry label: ${error.message}`); + } + } } // Append to the work log comment (best-effort; failures don't block the loop) @@ -4093,39 +4560,90 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in core?.warning?.(`[work-log] append failed: ${workLogError.message}`); } - if (shouldClearStaleHumanBlockers) { - await clearStaleHumanBlockerLabels({ - github, + if (shouldEscalate) { + const routingLabel = escalationDisposition === 'needs-human' + ? 'needs-human' + : escalationDisposition === 'challenge-due' + ? 'agent:needs-attention' + : 'agent:retry'; + const addRoutingLabel = () => github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, - prNumber, - core, + issue_number: prNumber, + labels: [routingLabel], }); - } - - if (shouldEscalate) { try { - await github.rest.issues.addLabels({ + const clearAutomationAttention = () => clearStaleHumanBlockerLabels({ + github, owner: context.repo.owner, repo: context.repo.repo, - issue_number: prNumber, - labels: ['agent:needs-attention'], + prNumber, + core, + automationOwned: previousAttentionAutomationOwned, }); + if (escalationDisposition === 'needs-human') { + // The hard blocker was applied before the human-owned state was + // persisted. Only now may the recoverable label be removed. + await clearAutomationAttention(); + } else if (escalationDisposition === 'challenge-due') { + // Adding an already-present label is idempotent. Never remove the + // only sweep-routing signal while renewing or replacing a challenge. + await addRoutingLabel(); + } else { + // The direct workflow dispatch below owns the retry lease. Do not + // add agent:retry: its labeled event could race a successful + // dispatch, while GITHUB_TOKEN cannot wake a failed one. A failure + // defers the durable lease for a later direct retry instead. + await clearAutomationAttention(); + } } catch (error) { - if (core) core.warning(`Failed to add agent:needs-attention label: ${error.message}`); + if (core) core.warning(`Failed to add ${escalationDisposition} routing label: ${error.message}`); } - } - - if (stop) { - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - labels: ['needs-human'], - }); - } catch (error) { - if (core) core.warning(`Failed to add needs-human label: ${error.message}`); + // Every automation-owned terminal gets one immediate recovery lease. + // Persist the issued/consumed lease across events so later ordinary + // sweeps cannot mint another lease for the same terminal boundary. + if ( + escalationDisposition === 'automation-retry' && + !isForceRetry && + (!stop || shouldIssueTerminalRecoveryLease) + ) { + try { + const retryWorkflowId = normalise( + inputs.retry_workflow_id ?? inputs.retryWorkflowId, + ) || 'agents-keepalive-loop.yml'; + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: retryWorkflowId, + ref: + context.payload?.repository?.default_branch || + context.payload?.pull_request?.base?.ref || + 'main', + inputs: { + pr_number: String(prNumber), + force_retry: 'true', + }, + }); + } catch (error) { + core?.warning?.(`Failed to dispatch bounded automation retry: ${error.message}`); + if (newState.recovery_lease?.status === 'issued') { + newState.recovery_lease = { + ...newState.recovery_lease, + status: 'deferred', + deferred_at: new Date().toISOString(), + deferred_reason: 'workflow-dispatch-failed', + }; + try { + await persistSummary( + upsertStateCommentBody(body, formatStateComment(newState)), + ); + } catch (stateError) { + core?.warning?.( + `Failed to persist deferred recovery lease: ${stateError.message}`, + ); + } + } + } } } @@ -4864,5 +5382,7 @@ module.exports = { fileMatchesScopePattern, validateScopeCompliance, buildMetricsRecord, + buildAuthorityChallengeEvidence, parseCapabilityBundlesInput, + selectEscalationDisposition, }; diff --git a/.github/scripts/keepalive_state.js b/.github/scripts/keepalive_state.js index decd77284b..bb0d37a880 100644 --- a/.github/scripts/keepalive_state.js +++ b/.github/scripts/keepalive_state.js @@ -407,6 +407,8 @@ async function loadKeepaliveState({ github: rawGithub, context, prNumber, trace state: loadedState, commentId: existing.comment?.id ? Number(existing.comment.id) : 0, commentUrl: existing.comment?.html_url || '', + commentAuthorLogin: existing.comment?.user?.login || '', + commentAuthorType: existing.comment?.user?.type || '', }; } diff --git a/.github/scripts/runtime_ac_merge_guard.js b/.github/scripts/runtime_ac_merge_guard.js index 7e2c0472da..3b3a8c4632 100644 --- a/.github/scripts/runtime_ac_merge_guard.js +++ b/.github/scripts/runtime_ac_merge_guard.js @@ -9,6 +9,7 @@ const RUNTIME_AC_REQUIRED_LABELS = new Set([ 'ac-checks', 'runtime-checks', ]); +const GENERATED_DELIVERY_HOLD_LABEL = 'sync:delivery-staging'; function labelName(label) { if (typeof label === 'string') { @@ -92,6 +93,7 @@ async function assertRuntimeAcMergeAllowed({ labels, withRetry, source = 'external merge lane', + allowSealedSyncDelivery = false, } = {}) { if (!owner || !repo || !prNumber) { throw new Error('owner, repo, and prNumber are required for runtime AC merge guard.'); @@ -100,6 +102,20 @@ async function assertRuntimeAcMergeAllowed({ const labelItems = Array.isArray(labels) ? labels : await fetchPullRequestLabels({ github, owner, repo, prNumber, withRetry }); + const normalizedLabels = new Set(labelItems.map(normalizeLabelName).filter(Boolean)); + if (normalizedLabels.has(GENERATED_DELIVERY_HOLD_LABEL) && !allowSealedSyncDelivery) { + const message = + `Generated delivery guard blocked ${source} for PR #${prNumber}: ` + + `${GENERATED_DELIVERY_HOLD_LABEL} remains set. Maint 71 must seal the exact ` + + 'reviewed head before merge.'; + if (core && typeof core.warning === 'function') core.warning(message); + const error = new Error(message); + error.code = 'generated_delivery_staging'; + error.labels = [GENERATED_DELIVERY_HOLD_LABEL]; + throw error; + } + // The sealed-delivery exception bypasses only the generated-delivery hold. + // Runtime acceptance labels remain an independent hard merge boundary. const requirement = runtimeAcRequirement(labelItems); if (!requirement.required) { @@ -129,6 +145,7 @@ async function assertRuntimeAcMergeAllowed({ } module.exports = { + GENERATED_DELIVERY_HOLD_LABEL, RUNTIME_AC_REQUIRED_LABELS, assertRuntimeAcMergeAllowed, hasRuntimeAcRequirement, diff --git a/.github/scripts/source_context.js b/.github/scripts/source_context.js index f583acdfa8..ed9c1f64d0 100644 --- a/.github/scripts/source_context.js +++ b/.github/scripts/source_context.js @@ -368,17 +368,26 @@ function inferredSourceType(pull = {}) { if (author.startsWith('dependabot') || branch.startsWith('dependabot/')) { return SOURCE_TYPES.DEPENDABOT; } - if ( - branch.startsWith('sync/') || - branch.startsWith('sync-') || - labels.includes('campaign:sync-dependabot') || - /\bsync\b/.test(title) - ) { + if (branch.startsWith('sync/') || branch.startsWith('sync-')) { + return SOURCE_TYPES.SYNC_CAMPAIGN; + } + if (labels.includes('campaign:sync-dependabot')) { + return SOURCE_TYPES.SYNC_CAMPAIGN; + } + if (author === 'github-actions[bot]' || author === 'github-actions') { + return SOURCE_TYPES.AUTOMATION_RUN; + } + if (/\bsync\b/.test(title)) { return SOURCE_TYPES.SYNC_CAMPAIGN; } if (/review[-/ ]follow/.test(branch) || /\breview\s+follow[- ]?up\b/.test(title)) { return SOURCE_TYPES.REVIEW_FOLLOWUP; } + if ([ + 'feat/', 'fix/', 'docs/', 'audit/', 'chore/', 'refactor/', 'perf/', 'test/', + ].some((prefix) => branch.startsWith(prefix))) { + return SOURCE_TYPES.LOCAL_REQUEST; + } return SOURCE_TYPES.UNKNOWN; } diff --git a/.github/scripts/sync_pr_lease_contract.js b/.github/scripts/sync_pr_lease_contract.js new file mode 100644 index 0000000000..01caa2119e --- /dev/null +++ b/.github/scripts/sync_pr_lease_contract.js @@ -0,0 +1,152 @@ +'use strict'; + +// A generated PR is a short-lived delivery attempt. The durable campaign issue +// retains coordination history; this marker lets the producer and merger agree +// on which attempt is current without treating an arbitrary open PR as current. +const DELIVERY_RECORD_SCHEMA = 'sync-pr-delivery-record/v1'; +const DELIVERY_RECORD_MARKER = 'sync-pr-delivery-record:v1'; +const DELIVERY_STATES = new Set(['staging', 'reviewing', 'sealed']); + +function clean(value) { + return String(value || '').trim(); +} + +function unique(values) { + return [...new Set((values || []).map(clean).filter(Boolean))]; +} + +function normalizeRecord(record = {}) { + const normalized = { + schema: clean(record.schema) || DELIVERY_RECORD_SCHEMA, + durable_issue_url: clean(record.durable_issue_url), + plan_id: clean(record.plan_id), + generation: clean(record.generation), + repository: clean(record.repository), + desired_tree_hash: clean(record.desired_tree_hash), + source_commit: clean(record.source_commit), + head_observed_sha: clean(record.head_observed_sha), + head_observed_at: clean(record.head_observed_at), + lease_expires_at: clean(record.lease_expires_at), + predecessor_prs: unique(record.predecessor_prs), + successor_prs: unique(record.successor_prs), + delivery_state: clean(record.delivery_state), + review_started_at: clean(record.review_started_at), + sealed_at: clean(record.sealed_at), + sealed_head_sha: clean(record.sealed_head_sha), + review_evidence: + record.review_evidence && typeof record.review_evidence === 'object' + ? record.review_evidence + : {}, + terminal_disposition: clean(record.terminal_disposition), + }; + return normalized; +} + +function deliveryRecordErrors(record = {}) { + const normalized = normalizeRecord(record); + const required = [ + 'durable_issue_url', 'plan_id', 'generation', 'repository', + 'desired_tree_hash', 'source_commit', 'lease_expires_at', + ]; + const errors = []; + if (normalized.schema !== DELIVERY_RECORD_SCHEMA) errors.push('schema'); + for (const field of required) if (!normalized[field]) errors.push(field); + if (normalized.terminal_disposition && !['merged', 'superseded', 'expired', 'blocked'].includes(normalized.terminal_disposition)) { + errors.push('terminal_disposition'); + } + if (normalized.delivery_state && !DELIVERY_STATES.has(normalized.delivery_state)) { + errors.push('delivery_state'); + } + if (normalized.lease_expires_at && Number.isNaN(Date.parse(normalized.lease_expires_at))) { + errors.push('lease_expires_at'); + } + if (Boolean(normalized.head_observed_sha) !== Boolean(normalized.head_observed_at)) { + errors.push('head_observation_pair'); + } + if (normalized.head_observed_at && Number.isNaN(Date.parse(normalized.head_observed_at))) { + errors.push('head_observed_at'); + } + for (const field of ['review_started_at', 'sealed_at']) { + if (normalized[field] && Number.isNaN(Date.parse(normalized[field]))) errors.push(field); + } + if ( + ['reviewing', 'sealed'].includes(normalized.delivery_state) + && !normalized.review_started_at + ) { + errors.push('review_started_at'); + } + if (normalized.delivery_state === 'sealed') { + if (!normalized.sealed_at) errors.push('sealed_at'); + if (!normalized.sealed_head_sha) errors.push('sealed_head_sha'); + } + return errors; +} + +function formatDeliveryRecord(record = {}) { + const normalized = normalizeRecord(record); + const errors = deliveryRecordErrors(normalized); + if (errors.length) throw new Error(`Invalid delivery record: ${errors.join(', ')}`); + return ``; +} + +function parseDeliveryRecord(body = '') { + const match = String(body || '').match(new RegExp(``)); + if (!match) return null; + try { + const record = normalizeRecord(JSON.parse(match[1])); + return deliveryRecordErrors(record).length ? null : record; + } catch (_) { + return null; + } +} + +function replaceDeliveryRecord(body = '', changes = {}) { + const current = parseDeliveryRecord(body); + if (!current) throw new Error('Missing or invalid delivery record'); + const marker = formatDeliveryRecord({ ...current, ...changes }); + const expression = new RegExp(``); + return String(body).replace(expression, () => marker); +} + +function mergeEligibility( + record, + { + now = new Date().toISOString(), + planId = '', + repository = '', + desiredTreeHash = '', + requireSealed = false, + headSha = '', + } = {}, +) { + const normalized = normalizeRecord(record); + const errors = deliveryRecordErrors(normalized); + if (errors.length) return { eligible: false, reason: `invalid:${errors.join(',')}` }; + if (normalized.terminal_disposition) return { eligible: false, reason: `terminal:${normalized.terminal_disposition}` }; + if (Date.parse(normalized.lease_expires_at) <= Date.parse(now)) return { eligible: false, reason: 'lease_expired' }; + if (clean(planId) && normalized.plan_id !== clean(planId)) return { eligible: false, reason: 'plan_mismatch' }; + if (clean(repository) && normalized.repository !== clean(repository)) return { eligible: false, reason: 'repository_mismatch' }; + if (clean(desiredTreeHash) && normalized.desired_tree_hash !== clean(desiredTreeHash)) return { eligible: false, reason: 'desired_tree_mismatch' }; + if (requireSealed && normalized.delivery_state !== 'sealed') { + return { + eligible: false, + reason: `delivery_not_sealed:${normalized.delivery_state || 'legacy'}`, + }; + } + if (requireSealed && clean(headSha) && normalized.sealed_head_sha !== clean(headSha)) { + return { eligible: false, reason: 'sealed_head_mismatch' }; + } + return { eligible: true, reason: 'current_unexpired' }; +} + +module.exports = { + DELIVERY_RECORD_SCHEMA, + DELIVERY_RECORD_MARKER, + DELIVERY_STATES, + normalizeRecord, + deliveryRecordErrors, + formatDeliveryRecord, + parseDeliveryRecord, + replaceDeliveryRecord, + mergeEligibility, +}; diff --git a/.github/workflows/agents-73-codex-belt-conveyor.yml b/.github/workflows/agents-73-codex-belt-conveyor.yml index 46592d0b70..069c5d70da 100644 --- a/.github/workflows/agents-73-codex-belt-conveyor.yml +++ b/.github/workflows/agents-73-codex-belt-conveyor.yml @@ -489,6 +489,7 @@ jobs: try { await withRetry(() => github.rest.git.deleteRef({ owner, repo, ref: `heads/${branch}` })); } catch (error) { + // # best-effort: the PR is already merged, so branch cleanup can be retried later. core.warning(`Failed to delete branch ${branch}: ${error.message}`); } @@ -517,6 +518,7 @@ jobs: try { await withRetry(() => github.rest.issues.update({ owner, repo, issue_number: issue, state: 'closed' })); } catch (error) { + // # best-effort: GitHub may already have closed the issue from the merged closing reference. core.warning(`Failed to close issue #${issue}: ${error.message}`); } try { @@ -560,6 +562,7 @@ jobs: try { await withRetry(() => github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: `Gate succeeded; merged automatically and closed issue #${issue || '(unknown)'}.` })); } catch (error) { + // # best-effort: the merge is durable even if its confirmation comment cannot be posted. core.warning(`Failed to comment on PR #${prNumber}: ${error.message}`); } @@ -599,5 +602,6 @@ jobs: }, })); } catch (error) { + // # best-effort: the completed merge does not depend on immediately waking the dispatcher. core.warning(`Failed to re-dispatch dispatcher: ${error.message}`); } diff --git a/.github/workflows/agents-80-pr-event-hub.yml b/.github/workflows/agents-80-pr-event-hub.yml index 71dab3ab93..1a3ce1e85d 100644 --- a/.github/workflows/agents-80-pr-event-hub.yml +++ b/.github/workflows/agents-80-pr-event-hub.yml @@ -285,6 +285,7 @@ jobs: })); console.log('Removed autofix:bot-comments label'); } catch (error) { + // # best-effort: the one-shot trigger label may be removed on a later event. console.log(`Could not remove label: ${error.message}`); } @@ -538,5 +539,6 @@ jobs: })); core.info('Removed verify:create-issue label'); } catch (error) { + // # best-effort: the one-shot trigger label may be removed on a later event. core.warning(`Could not remove label: ${error.message}`); } diff --git a/.github/workflows/agents-81-gate-followups.yml b/.github/workflows/agents-81-gate-followups.yml index bc20de1fc8..c09a3426f5 100644 --- a/.github/workflows/agents-81-gate-followups.yml +++ b/.github/workflows/agents-81-gate-followups.yml @@ -18,6 +18,21 @@ on: required: false default: true type: boolean + authority_challenge_fingerprint: + description: 'Internal sweep provenance for one due authority challenge' + required: false + default: '' + type: string + authority_challenge_claim: + description: 'Internal signed sweep claim metadata for one due authority challenge' + required: false + default: '' + type: string + sweep_recheck: + description: 'Internal hourly sweep wakeup; re-evaluates without forcing Gate bypass' + required: false + default: false + type: boolean permissions: contents: write @@ -34,7 +49,6 @@ concurrency: github.run_id }} cancel-in-progress: false - env: WRITE_TOKEN: >- ${{ secrets.AGENTS_AUTOMATION_PAT || @@ -103,6 +117,15 @@ jobs: }} GATE_CONCLUSION: ${{ github.event.workflow_run.conclusion || '' }} FORCE_RETRY: ${{ github.event.inputs.force_retry || 'false' }} + AUTHORITY_CHALLENGE_FINGERPRINT: >- + ${{ github.event_name == 'workflow_dispatch' && + github.actor == 'github-actions[bot]' && + github.event.inputs.sweep_recheck == 'true' && + github.event.inputs.authority_challenge_fingerprint || '' }} + SWEEP_RECHECK: >- + ${{ github.event_name == 'workflow_dispatch' && + github.actor == 'github-actions[bot]' && + github.event.inputs.sweep_recheck == 'true' }} run: | if [ -z "${PR_NUMBER:-}" ]; then { @@ -114,6 +137,20 @@ jobs: exit 0 fi + if [ "${SWEEP_RECHECK:-false}" = "true" ]; then + { + echo "should_run=true" + if [ -n "${AUTHORITY_CHALLENGE_FINGERPRINT:-}" ]; then + echo "reason=due-authority-challenge" + else + echo "reason=scheduled-sweep-recheck" + fi + echo "current_hash=" + echo "prior_hash=" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + python - <<'PY' > /tmp/state-fingerprint-inputs.json import json import os @@ -295,8 +332,39 @@ jobs: PR_NUMBER: ${{ steps.evaluate.outputs.pr_number }} HEAD_SHA: ${{ steps.evaluate.outputs.head_sha }} PROVIDER: ${{ steps.evaluate.outputs.agent_type || 'codex' }} + AUTHORITY_CHALLENGE_FINGERPRINT: >- + ${{ github.event_name == 'workflow_dispatch' && + github.actor == 'github-actions[bot]' && + github.event.inputs.sweep_recheck == 'true' && + github.event.inputs.authority_challenge_fingerprint || '' }} + AUTHORITY_CHALLENGE_CLAIM: >- + ${{ github.event_name == 'workflow_dispatch' && + github.actor == 'github-actions[bot]' && + github.event.inputs.sweep_recheck == 'true' && + github.event.inputs.authority_challenge_claim || '' }} + AUTHORITY_CHALLENGE_SIGNING_KEY: >- + ${{ secrets.KEEPALIVE_AUTHORITY_SIGNING_KEY || '' }} run: | set -euo pipefail + if node <<'NODE' + const { verifyAuthorityChallengeEnvelope } = + require('./.github/scripts/keepalive_challenge_due.js'); + const verified = verifyAuthorityChallengeEnvelope({ + claimJson: process.env.AUTHORITY_CHALLENGE_CLAIM, + signingKey: process.env.AUTHORITY_CHALLENGE_SIGNING_KEY, + repository: process.env.GITHUB_REPOSITORY, + prNumber: process.env.PR_NUMBER, + boundaryFingerprint: process.env.AUTHORITY_CHALLENGE_FINGERPRINT, + }); + process.exitCode = verified ? 0 : 1; + NODE + then + { + echo "should_dispatch=true" + echo "reason=due-authority-challenge" + } >> "$GITHUB_OUTPUT" + exit 0 + fi if [ -z "${PR_NUMBER:-}" ] || [ -z "${HEAD_SHA:-}" ]; then { echo "should_dispatch=true" @@ -322,6 +390,7 @@ jobs: environment: agent-standard outputs: secrets_ok: ${{ steps.check.outputs.secrets_ok }} + failure_summary: ${{ steps.check.outputs.failure_summary }} steps: - name: Check secrets id: check @@ -329,6 +398,7 @@ jobs: HAS_CODEX_AUTH: ${{ secrets.CODEX_AUTH_JSON != '' }} HAS_CLAUDE_AUTH: ${{ secrets.CLAUDE_AUTH_JSON != '' }} HAS_CLAUDE_OAUTH: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }} + AGENT_TYPE: ${{ needs.evaluate.outputs.agent_type || 'codex' }} HAS_APP_ID: >- ${{ secrets.KEEPALIVE_APP_ID != '' || secrets.WORKFLOWS_APP_ID != '' }} @@ -347,11 +417,20 @@ jobs: [ "$HAS_APP_ID" = "true" ]; then echo "secrets_ok=true" >> "$GITHUB_OUTPUT" else - message="::error::No agent auth found. Configure one of: CODEX_AUTH_JSON," - message="$message CLAUDE_AUTH_JSON, CLAUDE_CODE_OAUTH_TOKEN, or app credentials" - message="$message via KEEPALIVE_APP_ID/PRIVATE_KEY or WORKFLOWS_APP_ID/PRIVATE_KEY." - echo "$message" + case "$AGENT_TYPE" in + claude) + missing_msg="Missing Claude auth: set CLAUDE_CODE_OAUTH_TOKEN or CLAUDE_AUTH_JSON" + ;; + codex) + missing_msg="Missing Codex auth: set CODEX_AUTH_JSON" + ;; + *) + missing_msg="Missing ${AGENT_TYPE} auth: set the routed agent credential" + ;; + esac + echo "::error::$missing_msg" echo "secrets_ok=false" >> "$GITHUB_OUTPUT" + echo "failure_summary=$missing_msg" >> "$GITHUB_OUTPUT" exit 1 fi @@ -707,6 +786,58 @@ jobs: core.setOutput('llm_tasks_count', llmCompletedTasks.length); core.setOutput('commit_tasks_count', result.sources?.commit || 0); + - name: Mint KEEPALIVE_APP summary token + id: summary_keepalive_app_token + if: ${{ env.KEEPALIVE_APP_ID != '' && env.KEEPALIVE_APP_PRIVATE_KEY != '' }} + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + continue-on-error: true + env: + KEEPALIVE_APP_ID: ${{ secrets.KEEPALIVE_APP_ID || '' }} + KEEPALIVE_APP_PRIVATE_KEY: ${{ secrets.KEEPALIVE_APP_PRIVATE_KEY || '' }} + with: + app-id: ${{ env.KEEPALIVE_APP_ID }} + private-key: ${{ env.KEEPALIVE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-actions: write + permission-contents: read + permission-issues: write + permission-pull-requests: read + + - name: Mint WORKFLOWS_APP summary token + id: summary_workflows_app_token + if: | + steps.summary_keepalive_app_token.outputs.token == '' && + env.WORKFLOWS_APP_ID != '' && + env.WORKFLOWS_APP_PRIVATE_KEY != '' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + env: + WORKFLOWS_APP_ID: ${{ secrets.WORKFLOWS_APP_ID || '' }} + WORKFLOWS_APP_PRIVATE_KEY: ${{ secrets.WORKFLOWS_APP_PRIVATE_KEY || '' }} + with: + app-id: ${{ env.WORKFLOWS_APP_ID }} + private-key: ${{ env.WORKFLOWS_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-actions: write + permission-contents: read + permission-issues: write + permission-pull-requests: read + + - name: Require trusted keepalive summary writer + env: + KEEPALIVE_SUMMARY_TOKEN: >- + ${{ + steps.summary_keepalive_app_token.outputs.token || + steps.summary_workflows_app_token.outputs.token || + '' + }} + run: | + if [ -z "$KEEPALIVE_SUMMARY_TOKEN" ]; then + echo "::error::A dedicated keepalive or Workflows App token is required to persist trusted keepalive state." + exit 1 + fi + - name: Update summary comment id: update-summary uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 @@ -717,18 +848,53 @@ jobs: needs.run-claude.outputs.final-message-summary || needs.run-codex.outputs.error-summary || needs.run-claude.outputs.error-summary || + needs.preflight.outputs.failure_summary || '' }} + PREFLIGHT_FAILURE_SUMMARY: ${{ needs.preflight.outputs.failure_summary || '' }} + FORCE_RETRY: ${{ github.event.inputs.force_retry || 'false' }} + AUTHORITY_CHALLENGE_FINGERPRINT: >- + ${{ github.event_name == 'workflow_dispatch' && + github.actor == 'github-actions[bot]' && + github.event.inputs.sweep_recheck == 'true' && + github.event.inputs.authority_challenge_fingerprint || '' }} + AUTHORITY_CHALLENGE_CLAIM: >- + ${{ github.event_name == 'workflow_dispatch' && + github.actor == 'github-actions[bot]' && + github.event.inputs.sweep_recheck == 'true' && + github.event.inputs.authority_challenge_claim || '' }} + AUTHORITY_CHALLENGE_SIGNING_KEY: >- + ${{ secrets.KEEPALIVE_AUTHORITY_SIGNING_KEY || '' }} + KEEPALIVE_SUMMARY_WRITER: >- + ${{ + steps.summary_keepalive_app_token.outputs.token != '' && + 'stranske-keepalive[bot]' || + 'agents-workflows-bot[bot]' + }} with: - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: >- + ${{ + steps.summary_keepalive_app_token.outputs.token || + steps.summary_workflows_app_token.outputs.token + }} script: | const { updateKeepaliveLoopSummary } = require('./.github/scripts/keepalive_loop.js'); const claudeResult = '${{ needs.run-claude.result }}'; const codexResult = '${{ needs.run-codex.result }}'; - const runResult = + const runnerResult = claudeResult && claudeResult !== 'skipped' ? claudeResult : codexResult; + const preflightFailureSummary = process.env.PREFLIGHT_FAILURE_SUMMARY || ''; + const allRunnersSkipped = [claudeResult, codexResult] + .every((result) => !result || result === 'skipped'); + const agentExecutionStarted = [ + '${{ needs.run-codex.outputs.agent-execution-started }}', + '${{ needs.run-claude.outputs.agent-execution-started }}', + ].some((value) => value === 'true'); + const runResult = preflightFailureSummary && allRunnersSkipped + ? 'failure' + : runnerResult; const agentExitCode = '${{ needs.run-codex.outputs.exit-code }}' || @@ -775,6 +941,7 @@ jobs: agent_routing_mode: '${{ needs.evaluate.outputs.agent_routing_mode }}', // Agent run result - check which agent ran run_result: runResult, + agent_execution_started: agentExecutionStarted, // Agent output details (merged from whichever agent ran) agent_exit_code: agentExitCode, agent_changes_made: agentChangesMade, @@ -785,6 +952,15 @@ jobs: llm_provider: llmProvider, llm_confidence: llmConfidence, llm_analysis_run: llmAnalysisRun, + force_retry: process.env.FORCE_RETRY === 'true', + authority_challenge_fingerprint: + process.env.AUTHORITY_CHALLENGE_FINGERPRINT || '', + authority_challenge_claim: + process.env.AUTHORITY_CHALLENGE_CLAIM || '', + authority_challenge_signing_key: + process.env.AUTHORITY_CHALLENGE_SIGNING_KEY || '', + trusted_summary_author: process.env.KEEPALIVE_SUMMARY_WRITER || '', + retry_workflow_id: 'agents-81-gate-followups.yml', }; await updateKeepaliveLoopSummary({ github, context, core, inputs }); @@ -1010,6 +1186,15 @@ jobs: return stop('head repository mismatch (likely fork)'); } + const headRef = String(prData.head?.ref || ''); + if (headRef.startsWith('sync/workflows-')) { + return stop( + 'generated sync PR is Maint 71-owned; ' + + 'intentional delivery holds are not autofix work', + 'generated_sync_pr', + ); + } + const labels = Array.isArray(prData.labels) ? prData.labels .map((label) => (label?.name || '').toLowerCase()) 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-auto-pilot.yml b/.github/workflows/agents-auto-pilot.yml index b8ffd84c17..cc24e38adf 100644 --- a/.github/workflows/agents-auto-pilot.yml +++ b/.github/workflows/agents-auto-pilot.yml @@ -481,6 +481,7 @@ jobs: ISSUE_NUMBER: ${{ steps.context.outputs.issue_number }} with: script: | + // # best-effort: ancillary API failure is logged; primary result remains authoritative. const scriptsPath = process.env.WORKFLOWS_SCRIPTS_PATH || process.env.GITHUB_WORKSPACE; const retryHelpers = require(`${scriptsPath}/.github/scripts/github-api-with-retry.js`); const { paginateWithRetry } = retryHelpers; @@ -795,6 +796,21 @@ jobs: with open('/tmp/guard_blocked', 'w') as f: f.write(reason) sys.exit(0) + if result.get('error'): + reason = 'Formatter failed: ' + str(result.get('error')) + print('NEEDS_REFINEMENT: ' + reason) + with open('/tmp/needs_refinement', 'w') as f: + f.write(reason) + sys.exit(0) + if result.get('needs_refinement'): + reason = 'Formatter output does not satisfy the canonical issue-format contract.' + audit = result.get('validation_audit') + if audit: + reason += '\nvalidation_audit=' + json.dumps(audit, sort_keys=True) + print(f'NEEDS_REFINEMENT: {reason}') + with open('/tmp/needs_refinement', 'w') as f: + f.write(reason) + sys.exit(0) formatted = result.get('formatted_body', '') if not formatted: print('ERROR: No formatted body returned') @@ -858,10 +874,39 @@ jobs: process.exit(1); }); GUARD_NODE + echo "stop_autopilot=true" >> "$GITHUB_OUTPUT" echo "🛑 Automation stopped due to prompt injection guard." exit 0 fi + # Never publish or mark a body as formatted when the formatter itself + # reports that its final output fails the canonical contract. + if [ -f /tmp/needs_refinement ]; then + echo "⚠️ Formatter requires human refinement; pausing auto-pilot." + FORMAT_REFINEMENT_REASON="$(cat /tmp/needs_refinement)" node - <<'REFINEMENT_NODE' + (async () => { + const { Octokit } = require('@octokit/rest'); + const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); + const core = { info: () => {}, warning: console.warn, debug: () => {} }; + const github = new Octokit({ auth: process.env.GITHUB_TOKEN }); + const { withRetry } = await createTokenAwareRetry({ + github, core, env: process.env, task: 'auto-pilot', capabilities: ['issues:write'], + }); + const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); + const issue_number = Number(process.env.ISSUE_NUMBER); + await withRetry((client) => client.rest.issues.addLabels({ + owner, repo, issue_number, labels: ['needs-human', 'agents:auto-pilot-pause'], + })); + await withRetry((client) => client.rest.issues.createComment({ + owner, repo, issue_number, + body: `⚠️ **Auto-pilot paused during formatting.**\n\n${process.env.FORMAT_REFINEMENT_REASON}`, + })); + })().catch((error) => { console.error(error); process.exit(1); }); + REFINEMENT_NODE + echo "stop_autopilot=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Update issue body node - <<'NODE' (async () => { diff --git a/.github/workflows/agents-issue-format-guard.yml b/.github/workflows/agents-issue-format-guard.yml new file mode 100644 index 0000000000..98ec467761 --- /dev/null +++ b/.github/workflows/agents-issue-format-guard.yml @@ -0,0 +1,390 @@ +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 == 'unlabeled' && + github.event.label.name == 'agents:format') || + ((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 + # Tolerated: a consumer repo mid-sync may not have the validator yet, + # and hard-failing would block every issue there. But the job used to + # exit GREEN having validated nothing, so a sync that dropped the + # file removed the gate with no signal at all. Make it loud. + echo "::warning title=Issue format guard inactive::.github/scripts/issue_format.py is missing, so this issue was NOT validated. Re-sync from stranske/Workflows to restore the gate." + 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 1 + fi + # `gh … | grep -q` inverts under `set -o pipefail`: grep exits at its + # first match, gh dies on SIGPIPE, and the failed pipeline makes this + # `if` take the FALSE branch even though the label IS present. It only + # bites once gh is slow enough to still be writing — i.e. as the issue + # grows. Materialise first, then grep a file. + gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" --json labels \ + --jq '.labels[].name' > labels.txt + if grep -qx 'agents:formatted' labels.txt; 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="" + retry_marker_prefix="" + if [[ "$format_guard_attempts" -ge "$max_format_guard_attempts" ]]; then + echo "Format guard exhausted $format_guard_attempts optimizer attempts; pausing issue." + gh issue edit "$NUMBER" --repo "$GITHUB_REPOSITORY" \ + --add-label "agents:auto-pilot-pause" \ + || echo "::warning::could not apply agents:auto-pilot-pause" + gh issue edit "$NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label "agents:format" \ + || echo "::warning::could not release agents:format after attempt cap" + printf '%s\n' \ + '' \ + "Automated formatting stopped after $format_guard_attempts optimizer attempts. Fix the issue body manually, then remove agents:auto-pilot-pause before retrying." \ + | gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file - \ + || echo "::warning::could not post format-guard attempt-cap comment" + exit 0 + fi + 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 | endswith($issue)))] | length') + if [[ "${match_count:-0}" -gt 0 ]]; then + accepted=true + break + fi + done + if [[ "$accepted" == true ]]; then + { + cat report.md + echo + echo "Optimizer dispatch was accepted despite a CLI error; preserving the format lease." + echo + echo "$attempt_marker" + } | gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file - + echo "::warning::optimizer dispatch CLI failed but a matching run was accepted; preserving agents:format lease" + echo "::error::optimizer dispatch status ambiguous; recorded the accepted attempt for the retry cap" + 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 + { + cat report.md + echo + echo "Dispatched the Agents Issue Optimizer format phase (attempt $format_guard_next_attempt of $max_format_guard_attempts)." + echo + echo "$attempt_marker" + } | gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file - + + - 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 + gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" --json labels \ + --jq '[.labels[].name] | any(. == "agents:auto-pilot-pause" or . == "needs-human")' > held.txt + if grep -qx true held.txt; 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 + gh issue view "$NUMBER" --repo "$GITHUB_REPOSITORY" --json labels \ + --jq '.labels[].name' > labels.txt + if grep -qx 'agents:format' labels.txt; 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..22e0895969 100644 --- a/.github/workflows/agents-issue-intake.yml +++ b/.github/workflows/agents-issue-intake.yml @@ -111,9 +111,11 @@ 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:')) && - !contains(github.event.issue.labels.*.name, 'agents:auto-pilot') + (github.event.issue.state != 'closed' && + (contains(toJson(github.event.issue.labels.*.name), 'agent:') || + contains(toJson(github.event.issue.labels.*.name), 'agents:')))) && + (github.event_name != 'issues' || + !contains(github.event.issue.labels.*.name, 'agents:auto-pilot')) runs-on: ubuntu-latest outputs: should_run: ${{ steps.check.outputs.should_run }} diff --git a/.github/workflows/agents-issue-optimizer.yml b/.github/workflows/agents-issue-optimizer.yml index dd95677e1c..eec484a4c9 100644 --- a/.github/workflows/agents-issue-optimizer.yml +++ b/.github/workflows/agents-issue-optimizer.yml @@ -1,5 +1,12 @@ 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 || github.event.inputs.issue_number }} + on: issues: types: [labeled] @@ -18,6 +25,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 @@ -25,6 +39,7 @@ jobs: issues: write contents: read models: read + actions: write steps: - name: Check trigger conditions @@ -33,15 +48,74 @@ jobs: EVENT_NAME: ${{ github.event_name }} LABEL_NAME: ${{ github.event.label.name }} DISPATCH_PHASE: ${{ inputs.phase }} + DISPATCH_ISSUE_NUMBER: ${{ inputs.issue_number }} + EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail + format_issue_is_eligible() { + local issue_number="$1" + if ! gh issue view "$issue_number" --repo "$GITHUB_REPOSITORY" \ + --json body,labels,state,author > /tmp/format-eligibility.json; then + echo "::error::could not re-check issue eligibility; refusing format work" + return 2 + fi + if [[ "$(jq -r '.state' /tmp/format-eligibility.json)" != "OPEN" ]]; then + echo "Skipping format: issue is not open." + return 1 + fi + if jq -e '[.labels[].name] | any( + . == "agents:auto-pilot" or + . == "agents:auto-pilot-pause" or + . == "needs-human" or + . == "tracker:durable" or + . == "wontfix" + )' /tmp/format-eligibility.json >/dev/null; then + echo "Skipping format: issue is held, exempt, or owned by auto-pilot." + return 1 + fi + if jq -er '.author.login // ""' /tmp/format-eligibility.json \ + | grep -qiE '(\[bot\]|^renovate$|^dependabot$)'; then + echo "Skipping format: bot-authored issues are exempt." + return 1 + fi + if jq -r '.body // ""' /tmp/format-eligibility.json \ + | grep -qiE 'do not dispatch|not a (repo|cloud) coding task'; then + echo "Skipping format: issue body explicitly forbids dispatch." + return 1 + fi + return 0 + } + + release_format_lease_after_skip() { + local issue_number="$1" + if jq -e '[.labels[].name] | any(. == "agents:format")' \ + /tmp/format-eligibility.json >/dev/null; then + gh issue edit "$issue_number" --repo "$GITHUB_REPOSITORY" \ + --remove-label "agents:format" + echo "Released agents:format lease after eligibility skip." + fi + } + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + should_run=true + if [[ "$DISPATCH_PHASE" == "format" ]]; then + if format_issue_is_eligible "$DISPATCH_ISSUE_NUMBER"; then + : + else + eligibility_rc=$? + if [[ "$eligibility_rc" -eq 2 ]]; then + exit 1 + fi + release_format_lease_after_skip "$DISPATCH_ISSUE_NUMBER" + should_run=false + fi + fi { echo "phase=$DISPATCH_PHASE" - echo "issue_number=${{ inputs.issue_number }}" - echo "should_run=true" + echo "issue_number=$DISPATCH_ISSUE_NUMBER" + echo "should_run=$should_run" } >> "$GITHUB_OUTPUT" elif [[ "$LABEL_NAME" == "agents:optimize" ]]; then # Skip if auto-pilot label is present (auto-pilot runs optimizer inline) @@ -72,18 +146,19 @@ jobs: } >> "$GITHUB_OUTPUT" fi elif [[ "$LABEL_NAME" == "agents:format" ]]; then - # Skip if auto-pilot label is present (auto-pilot runs format inline) - # Use pipefail to ensure gh errors cause check to fail-closed - if gh issue view "${{ github.event.issue.number }}" --json labels \ - --jq '.labels[].name' | grep -qx 'agents:auto-pilot'; then - echo "should_run=false" >> "$GITHUB_OUTPUT" - echo "Skipping: auto-pilot label present (runs inline)" - else + if format_issue_is_eligible "$EVENT_ISSUE_NUMBER"; then { echo "phase=format" - echo "issue_number=${{ github.event.issue.number }}" + echo "issue_number=$EVENT_ISSUE_NUMBER" echo "should_run=true" } >> "$GITHUB_OUTPUT" + else + eligibility_rc=$? + if [[ "$eligibility_rc" -eq 2 ]]; then + exit 1 + fi + release_format_lease_after_skip "$EVENT_ISSUE_NUMBER" + echo "should_run=false" >> "$GITHUB_OUTPUT" fi else echo "should_run=false" >> "$GITHUB_OUTPUT" @@ -126,6 +201,7 @@ jobs: repository: stranske/Workflows path: workflows-scripts sparse-checkout: | + .github/scripts/issue_format.py config scripts/langchain tools @@ -163,14 +239,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 +419,8 @@ jobs: }); NODE - python - <<'PY' || true + set +e + python - <<'PY' import json import sys sys.path.insert(0, 'workflows-scripts/scripts/langchain') @@ -350,33 +430,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 +551,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 @@ -496,7 +584,7 @@ jobs: result = json.load(f) formatted = result.get('formatted_body', '') if not formatted: - print('ERROR: No formatted body returned') + print('ERROR: ' + (result.get('error') or 'No formatted body returned')) import sys sys.exit(1) with open('/tmp/formatted_body.md', 'w') as f: @@ -504,6 +592,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 +643,22 @@ 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.phase == 'format' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.phase == 'format') || + (github.event_name == 'issues' && github.event.label.name == 'agents:format')) + env: + GH_TOKEN: ${{ steps.token.outputs.token || github.token }} + ISSUE_NUMBER: ${{ steps.check.outputs.issue_number || github.event.inputs.issue_number || github.event.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" + # GITHUB_TOKEN label edits do not emit issues:unlabeled workflows; dispatch explicitly. + gh workflow run agents-issue-format-guard.yml --repo "$GITHUB_REPOSITORY" \ + -f issue_number="$ISSUE_NUMBER" \ + || echo "::warning::could not dispatch format guard retry after lease release" diff --git a/.github/workflows/agents-keepalive-sweep.yml b/.github/workflows/agents-keepalive-sweep.yml index eaba281fae..abeef76dd1 100644 --- a/.github/workflows/agents-keepalive-sweep.yml +++ b/.github/workflows/agents-keepalive-sweep.yml @@ -25,6 +25,7 @@ on: permissions: contents: read + issues: read pull-requests: read actions: write # workflow_dispatch of the keepalive loop @@ -39,40 +40,142 @@ jobs: # Consumers run the loop via agents-81 only in consolidated mode. if: vars.USE_CONSOLIDATED_WORKFLOWS == 'true' steps: + - name: Checkout API client helpers + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + sparse-checkout: | + .github/actions/setup-api-client + .github/scripts/error_classifier.js + .github/scripts/github-api-with-retry.js + .github/scripts/keepalive_challenge_due.js + .github/scripts/token_load_balancer.js + sparse-checkout-cone-mode: false + + - name: Setup API client + uses: ./.github/actions/setup-api-client + with: + github_token: ${{ github.token }} + - name: Dispatch keepalive loop for open agent PRs uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + AUTHORITY_CHALLENGE_SIGNING_KEY: >- + ${{ secrets.KEEPALIVE_AUTHORITY_SIGNING_KEY || '' }} with: + # Due authority claims carry an HMAC bound to this repository, PR, + # fingerprint, nonce, and exact sweep run. The downstream loop fails + # closed when the signing key is unavailable or the claim is forged. + github-token: ${{ github.token }} script: | + const crypto = require('node:crypto'); + const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); + const { + selectDueAuthorityChallenge, + signAuthorityChallengeClaim, + } = require('./.github/scripts/keepalive_challenge_due.js'); + const { withRetry } = await createTokenAwareRetry({ + github, + core, + env: process.env, + task: 'consumer-keepalive-sweep', + }); const dryRun = String( (context.payload.inputs && context.payload.inputs.dry_run) || 'false', ) === 'true'; const { owner, repo } = context.repo; - const prs = await github.paginate(github.rest.pulls.list, { + const repository = `${owner}/${repo}`; + const sweepRunId = process.env.GITHUB_RUN_ID || ''; + const sweepRunAttempt = process.env.GITHUB_RUN_ATTEMPT || ''; + const signingKey = process.env.AUTHORITY_CHALLENGE_SIGNING_KEY || ''; + const prs = await withRetry((client) => client.paginate(client.rest.pulls.list, { owner, repo, state: 'open', per_page: 100, - }); + })); const eligible = prs.filter((pr) => !pr.draft && (pr.labels || []).some((l) => /^agent:/i.test(l.name || '')), ); core.info(`Open PRs: ${prs.length}; non-draft agent-labelled: ${eligible.length}`); let dispatched = 0; + let challenged = 0; for (const pr of eligible) { if (dryRun) { core.info(`[dry-run] would re-evaluate PR #${pr.number}`); continue; } try { - await github.rest.actions.createWorkflowDispatch({ + let dueChallenge = null; + if ((pr.labels || []).some((label) => label.name === 'agent:needs-attention')) { + const comments = await withRetry((client) => client.paginate( + client.rest.issues.listComments, + { owner, repo, issue_number: pr.number, per_page: 100 }, + )); + dueChallenge = selectDueAuthorityChallenge({ + labels: pr.labels || [], + comments, + now: new Date(), + }); + } + let authorityClaim = {}; + if (dueChallenge) { + const nonce = crypto.randomBytes(32).toString('hex'); + const signature = signAuthorityChallengeClaim({ + signingKey, + repository, + prNumber: pr.number, + boundaryFingerprint: dueChallenge.boundaryFingerprint, + nonce, + sweepRunId, + sweepRunAttempt, + }); + if (!signature) { + core.warning( + `PR #${pr.number}: due authority challenge cannot be signed; ` + + 'dispatching only an ordinary non-forced sweep recheck.', + ); + dueChallenge = null; + } else { + challenged += 1; + core.info( + `PR #${pr.number}: independently rechecking signed authority claim ` + + `(due ${dueChallenge.dueAt}; ${dueChallenge.nextAction || dueChallenge.key || 'no detail'})`, + ); + authorityClaim = { + authority_challenge_claim: JSON.stringify({ + signature, + nonce, + sweep_run_id: sweepRunId, + sweep_run_attempt: sweepRunAttempt, + }), + }; + } + } + // Retry rate-limit responses through the shared helper. Generic + // 5xx retries remain disabled for this non-idempotent POST; the + // next scheduled sweep safely retries any unconfirmed dispatch. + await withRetry((client) => client.rest.actions.createWorkflowDispatch({ owner, repo, workflow_id: 'agents-81-gate-followups.yml', ref: context.payload.repository.default_branch, - inputs: { pr_number: String(pr.number) }, - }); + inputs: { + pr_number: String(pr.number), + force_retry: String(Boolean(dueChallenge)), + sweep_recheck: 'true', + authority_challenge_fingerprint: + dueChallenge?.boundaryFingerprint || '', + ...authorityClaim, + }, + })); dispatched += 1; } catch (error) { + // # best-effort: one failed dispatch must not prevent later PRs from being re-evaluated. core.warning(`Failed to dispatch loop for PR #${pr.number}: ${error.message}`); } } await core.summary - .addRaw(`Keepalive sweep: ${eligible.length} agent PR(s) eligible, ${dispatched} re-evaluated (dry_run=${dryRun}).`) + .addRaw( + `Keepalive sweep: ${eligible.length} agent PR(s) eligible, ` + + `${dispatched} re-evaluated, ${challenged} due authority claim(s) challenged ` + + `(dry_run=${dryRun}).`, + ) .write(); diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 681d8a3ffd..6898cc6773 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -118,6 +118,7 @@ jobs: github-token: >- ${{ secrets.AGENTS_AUTOMATION_PAT || secrets.ACTIONS_BOT_PAT || github.token }} script: | + // # best-effort: this ancillary API failure is logged; reconciliation or the primary workflow result remains authoritative. const { createTokenAwareRetry } = require( './.github/scripts/github-api-with-retry.js' ); @@ -212,7 +213,7 @@ jobs: const status = Number(error?.status || error?.response?.status || 0); if (status === 403 && message.toLowerCase().includes('rate limit')) { core.warning( - 'Rate limited listing PR files; proceeding without file filter.' + 'Rate limited listing PR files; skipping autofix to preserve the file filter.' ); return null; } @@ -288,6 +289,12 @@ jobs: return; } + if (String(pr.head?.ref || '').startsWith('sync/workflows-')) { + core.info('Generated sync PR is Maint 71-owned; skipping autofix.'); + core.setOutput('should_run', 'false'); + return; + } + const headSha = pr.head?.sha; if (!headSha) { core.info('PR head SHA missing; skipping autofix.'); @@ -351,13 +358,14 @@ jobs: prNumber, }); - const hasPython = - files === null - ? true - : files.some( - (file) => - file.filename.endsWith('.py') || file.filename.endsWith('.pyi') - ); + if (files === null) { + core.setOutput('should_run', 'false'); + return; + } + const hasPython = files.some( + (file) => + file.filename.endsWith('.py') || file.filename.endsWith('.pyi') + ); if (!hasPython) { core.info('No Python files changed.'); @@ -429,6 +437,12 @@ jobs: return; } + if (String(pr.head?.ref || '').startsWith('sync/workflows-')) { + core.info('Generated sync PR is Maint 71-owned; skipping autofix.'); + core.setOutput('should_run', 'false'); + return; + } + const labels = (pr.labels || []).map(l => l.name); @@ -439,13 +453,13 @@ jobs: prNumber: pr.number, }); - const hasPython = - files === null - ? true - : files.some( - (file) => - file.filename.endsWith('.py') || file.filename.endsWith('.pyi') - ); + if (files === null) { + core.setOutput('should_run', 'false'); + return; + } + const hasPython = files.some( + (file) => file.filename.endsWith('.py') || file.filename.endsWith('.pyi') + ); if (!hasPython) { core.info('No Python files changed.'); diff --git a/.github/workflows/maint-coverage-guard.yml b/.github/workflows/maint-coverage-guard.yml index d18a023233..2629e8b738 100644 --- a/.github/workflows/maint-coverage-guard.yml +++ b/.github/workflows/maint-coverage-guard.yml @@ -59,6 +59,7 @@ jobs: RATE_LIMIT_THRESHOLD: '2000' with: script: | + // # best-effort: this ancillary API failure is logged; reconciliation or the primary workflow result remains authoritative. const threshold = parseInt(process.env.RATE_LIMIT_THRESHOLD || '2000', 10); const fs = require('fs'); const retryHelperPath = './.github/scripts/github-api-with-retry.js'; @@ -153,6 +154,7 @@ jobs: if (!runs.length) { core.warning('No Gate workflow runs found.'); core.setOutput('run_id', ''); + core.setFailed('Coverage verification requires at least one completed Gate workflow run.'); return; } @@ -216,6 +218,7 @@ jobs: 'Unable to locate a successful or neutral completed Gate workflow run.', ); core.setOutput('run_id', ''); + core.setFailed('Coverage verification could not find a successful Gate workflow run.'); return; } @@ -225,9 +228,12 @@ jobs: 'Unable to locate a recent successful Gate workflow run with required', 'coverage artifacts: gate-coverage-trend, gate-coverage-trend-history,', 'gate-coverage.', - ].join(' '), + ].join(' '), ); core.setOutput('run_id', ''); + core.setFailed( + 'Coverage verification could not find required coverage artifacts on a successful Gate run.', + ); return; } diff --git a/WORKFLOW_USER_GUIDE.md b/WORKFLOW_USER_GUIDE.md index 54f15983ec..827371778b 100644 --- a/WORKFLOW_USER_GUIDE.md +++ b/WORKFLOW_USER_GUIDE.md @@ -125,7 +125,8 @@ Issue: "Add user authentication" - 4-hour timeout - Pause anytime with `agents:auto-pilot-pause` - Stops on errors with `agents:auto-pilot-failed` -- Escalates to `needs-human` on repeated failures +- Routes repeated failures to bounded automation retry and scheduled recovery +- Uses `needs-human` only for independently confirmed external authority **Time:** 20 minutes to 2 hours depending on complexity diff --git a/docs/AGENT_ISSUE_FORMAT.md b/docs/AGENT_ISSUE_FORMAT.md index 9f54bf1989..d6a8b37939 100644 --- a/docs/AGENT_ISSUE_FORMAT.md +++ b/docs/AGENT_ISSUE_FORMAT.md @@ -150,7 +150,8 @@ A qualifying criterion names one of: - a specific test path / test id (e.g. `tests/test_verdict_policy.py::test_select_verdict_worst_policy`), **or** -- a specific runnable command and its expected observable result (e.g. +- a specific runnable command written in normal Acceptance Criteria prose (not + inside a Markdown fenced code block) and its expected observable result (e.g. `gh workflow run selftest-ci.yml` → the run log shows a non-zero collected count for the named test files), **or** - a documented live-verification step tied to behavior a human or agent can @@ -166,7 +167,9 @@ intuitive / polished) are rejected — replace with a measurable check. > Acceptance Criteria block references **no** test, smoke test, or verification > gate at all (a conservative string check for a test path/id, a runner command > like `pytest` / `gh workflow run` / `npm test` / `curl`, or a `smoke` / -> `verif` token). An acceptance section of pure adjectives will not pass. +> `verif` token). The qualifying gate must appear in normal Acceptance Criteria +> prose, not inside a Markdown fenced code block. An acceptance section of pure +> adjectives will not pass. #### The deliberate-break pattern (recommended worked form) @@ -229,7 +232,19 @@ DEFINITION OF READY — run before filing / accepting an issue Tasks [ ] Every task names a real file / function / path / command. [ ] Every cited path:line was verified against the CURRENT checkout - (or is an explicit create-path with its wire-in point named). + (or is an explicit create-path with its wire-in point named). Three or more + non-create paths that resolve nowhere in this repository are rejected; + a path is create-only only when it is the direct object of an explicit + file-creation phrase. "Add validation to path" is a modification and the + cited path must already resolve. One creation phrase may govern a + comma/conjunction-separated list of new paths until the task switches to a + different action. + quoted and unquoted task paths both count, while absolute and parent-relative + paths never count as repository evidence. +[ ] Ignore paths preserved inside the formatter's archived + `Original Issue` provenance block. Only the visible issue + body is live work-order evidence; malformed or unclosed archives remain + visible and fail closed. [ ] No banned vague verb stands alone ("fix bugs", "improve X", "update things", "clean up", "refactor", "optimize", "polish"). [ ] Each task is atomic — one checkbox = one discrete, verifiable change. diff --git a/docs/SETUP_CHECKLIST.md b/docs/SETUP_CHECKLIST.md index 4af7c9cbf7..9da54852f9 100644 --- a/docs/SETUP_CHECKLIST.md +++ b/docs/SETUP_CHECKLIST.md @@ -288,6 +288,7 @@ Navigate to: **Settings** → **Secrets and variables** → **Actions** → **Se | `GH_APP_PRIVATE_KEY` | Bot-comment GitHub App private key | Contact admin for private key | | `KEEPALIVE_APP_ID` | Keepalive App ID (preferred for keepalive loop auth) | Contact admin for App ID | | `KEEPALIVE_APP_PRIVATE_KEY` | Keepalive App private key | Contact admin for private key | +| `KEEPALIVE_AUTHORITY_SIGNING_KEY` | Dedicated random HMAC key for authority-challenge claims | Contact admin; do not reuse an App private key | | `OPENAI_API_KEY` | OpenAI API key for verify/optimizer/decompose flows | Contact admin for token | | `CLAUDE_API_STRANSKE` | Claude API key for verify/optimizer/decompose flows | Contact admin for token | | `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code OAuth token (preferred for Claude CLI runs) | `claude setup-token` | @@ -307,6 +308,7 @@ Add each secret: - [ ] `GH_APP_PRIVATE_KEY` — Bot-comment App private key - [ ] `KEEPALIVE_APP_ID` — **Required for keepalive parity** - Explicit keepalive app alias - [ ] `KEEPALIVE_APP_PRIVATE_KEY` — **Required for keepalive parity** - Explicit keepalive app key +- [ ] `KEEPALIVE_AUTHORITY_SIGNING_KEY` — **Required for terminal authority confirmation** - Dedicated random HMAC key; missing key fails closed to ordinary rechecks - [ ] `OPENAI_API_KEY` — Required for verify/decompose/optimizer workflows - [ ] `CLAUDE_API_STRANSKE` — Required for verify/decompose/optimizer workflows - [ ] `CLAUDE_CODE_OAUTH_TOKEN` (or `CLAUDE_AUTH_JSON`) — Required for Claude CLI workflow runs diff --git a/scripts/check_deliberate_break.py b/scripts/check_deliberate_break.py index d8b47fd3a4..12152e3591 100644 --- a/scripts/check_deliberate_break.py +++ b/scripts/check_deliberate_break.py @@ -123,7 +123,7 @@ def _explicit_marker(section: str) -> DeliberateBreakSpec | None: return None -def _fallback_marker(section: str) -> DeliberateBreakSpec | None: +def _fallback_marker(section: str, markdown: str = "") -> DeliberateBreakSpec | None: named_line = next( (line for line in section.splitlines() if "named test:" in line.lower()), "", @@ -140,20 +140,71 @@ def _fallback_marker(section: str) -> DeliberateBreakSpec | None: return None test_file_match = re.search(r"`([^`]*(?:test|tests)[^`]*\.py)`", named_line) - test_name_match = re.search(r"\bwith\s+`?([A-Za-z_][A-Za-z0-9_]*)`?", named_line) - break_file_match = re.search(r"`([^`]+)`", break_line) - if not test_file_match or not test_name_match or not break_file_match: + test_name_match = ( + re.search(r"\btest\s+`([^`]+)`", named_line) + or re.search(r"\bwith\s+`([^`]+)`", named_line) + or re.search(r"\bwith\s+`?([A-Za-z_][A-Za-z0-9_]*)`?", named_line) + ) + break_file = _infer_break_file(break_line, named_line, markdown) + if not test_file_match or not test_name_match or not break_file: return None test_file = test_file_match.group(1) test_id = f"{test_file}::{test_name_match.group(1)}" - break_file = break_file_match.group(1) return DeliberateBreakSpec(test_id, test_file, break_file, _pytest_command(test_id)) +def _infer_break_file(break_line: str, named_line: str, markdown: str) -> str | None: + """Pick a revert target from acceptance wording, skipping label-like backticks.""" + + def _candidate_paths(text: str) -> list[str]: + paths: list[str] = [] + for path in re.findall(r"`([^`]+)`", text): + normalized = path.strip().rstrip(":") + if not normalized: + continue + if re.fullmatch(r"[A-Za-z][\w-]*:\s*.+", normalized): + continue + if re.search(r"\s", normalized): + continue + path_only = normalized.split(":", 1)[0] + if "/" in path_only or path_only.endswith((".py", ".yml", ".yaml", ".js")): + paths.append(path_only) + return paths + + # Prefer the file explicitly described as being reverted. A deliberate-break + # line may name the test command before its actual mutation target. + for revert_match in re.finditer( + r"\brevert(?:ing|ed)?\b[^`]{0,120}`([^`]+)`", break_line, re.IGNORECASE + ): + revert_paths = _candidate_paths(f"`{revert_match.group(1)}`") + if revert_paths: + return revert_paths[0] + + # The deliberate-break line is the explicit experiment target. Only fall + # back to other acceptance prose when it does not name a path itself. + break_paths = _candidate_paths(break_line) + if break_paths: + return break_paths[0] + + ordered_paths: list[str] = [] + for text in (named_line, markdown): + ordered_paths.extend(_candidate_paths(text)) + + workflow_paths = [ + path + for path in ordered_paths + if ".github/workflows/" in path or path.endswith((".yml", ".yaml")) + ] + if workflow_paths: + return workflow_paths[0] + + return ordered_paths[0] if ordered_paths else None + + def parse_deliberate_break_spec(markdown: str) -> DeliberateBreakSpec | None: section = _acceptance_criteria(markdown) - return _explicit_marker(section) or _fallback_marker(section) + return _explicit_marker(section) or _fallback_marker(section, markdown) def _pytest_command(test_id: str) -> tuple[str, ...]: diff --git a/scripts/ci_cosmetic_repair.py b/scripts/ci_cosmetic_repair.py index 016c381dfc..6c496a2721 100644 --- a/scripts/ci_cosmetic_repair.py +++ b/scripts/ci_cosmetic_repair.py @@ -26,12 +26,10 @@ import re import subprocess import sys +from collections.abc import Iterable, Sequence from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import Iterable, Sequence - -from scripts.classify_test_failures import FailureRecord, classify_reports ROOT = Path(__file__).resolve().parent.parent GUARD_PREFIX = "# cosmetic-repair:" @@ -39,6 +37,11 @@ DEFAULT_REPORT = Path(".pytest-cosmetic-report.xml") SUMMARY_FILE = Path(".cosmetic-repair-summary.json") +from scripts.classify_test_failures import ( # noqa: E402 + FailureRecord, + classify_reports, +) + _LOG_PATH = ROOT / "docs" / "COSMETIC_REPAIR_LOG.md" _GUARD_START = "" _GUARD_END = "" @@ -304,7 +307,7 @@ def stage_and_commit( summary: str, branch_suffix: str | None, ) -> str: - branch_suffix = branch_suffix or datetime.utcnow().strftime("%Y%m%d%H%M%S") + branch_suffix = branch_suffix or datetime.now(UTC).strftime("%Y%m%d%H%M%S") branch = f"{BRANCH_PREFIX}-{branch_suffix}" _run(["git", "checkout", "-B", branch], cwd=root) _run(["git", "add", *{str(p.relative_to(root)) for p in paths}], cwd=root) @@ -560,7 +563,4 @@ def main(argv: Sequence[str] | None = None) -> int: if __name__ == "__main__": # pragma: no cover - from trend_analysis.script_logging import setup_script_logging - - setup_script_logging(module_file=__file__) raise SystemExit(main()) diff --git a/scripts/langchain/issue_formatter.py b/scripts/langchain/issue_formatter.py index 4f61d51aaa..b52aa13ad7 100755 --- a/scripts/langchain/issue_formatter.py +++ b/scripts/langchain/issue_formatter.py @@ -10,10 +10,12 @@ from __future__ import annotations import argparse +import importlib.util import json import os import re import sys +from functools import lru_cache from pathlib import Path from typing import Any @@ -46,6 +48,26 @@ # ~4 chars per token, so 50k chars ≈ 12.5k tokens, leaving headroom for prompt + output MAX_ISSUE_BODY_SIZE = 50000 + +@lru_cache(maxsize=1) +def _issue_format_validator() -> Any: + """Load the fleet's single issue-format definition without forking it.""" + validator_path = Path(__file__).resolve().parents[2] / ".github/scripts/issue_format.py" + spec = importlib.util.spec_from_file_location("_fleet_issue_format", validator_path) + if spec is None or spec.loader is None: # pragma: no cover - repository invariant + raise RuntimeError(f"Cannot load canonical issue-format validator: {validator_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + try: + spec.loader.exec_module(module) + except Exception: + # Do not leave a half-initialized validator behind: callers retry this + # loader after transient consumer-sync failures. + sys.modules.pop(spec.name, None) + raise + return module + + # Workflow tags written into the reuse marker. Tagging every stage of the # auto-pilot format -> optimize -> apply chain lets any stage detect a body it (or # a sibling stage) already formatted and skip re-deriving it, which is the @@ -151,6 +173,22 @@ def _strip_reuse_marker(text: str) -> str: LIST_ITEM_REGEX = re.compile(r"^(\s*)([-*+]|\d+[.)]|[A-Za-z][.)])\s+(.*)$") CHECKBOX_REGEX = re.compile(r"^\[([ xX])\]\s*(.*)$") +VERIFY_HINT_REGEX = re.compile(r"\(verify:\s*([^\n)]+)\)", re.IGNORECASE) +SAFE_VERIFY_COMMAND_RE = re.compile( + r"^(?:" + r"(?:" + r"(?:python(?:3)?\s+-m\s+)?pytest\b|python(?:3)?\s+-m\s+unittest\b" + r"|node\s+--test\b" + r"|(?:npm|pnpm|yarn)\s+(?:run\s+)?(?:test|vitest|jest|playwright)\b" + r"|(?:make|just|cargo)\s+(?:test|check)\b" + r"|go\s+test\b|dotnet\s+test\b" + r"|gh\s+(?:workflow\s+run|run)\s+[^\s;&|`$<>\n\r]+" + r")(?:[ \t]+[^ \t;&|`$<>\n\r]+)*" + r"|curl(?:\s+-[ILsSfk]+)*(?:\s+https?://[^\s;&|`$<>\n\r]+)" + r")\Z", + re.IGNORECASE, +) +SHELL_METACHARACTERS_RE = re.compile(r"[;&|`$<>\n\r]") def _context_token_budget() -> int: @@ -355,6 +393,30 @@ def join_or_placeholder(lines: list[str], placeholder: str) -> str: impl_text = join_or_placeholder(impl_lines, "_Not provided._") tasks_text = join_or_placeholder(tasks_lines, "- [ ] _Not provided._") acceptance_text = join_or_placeholder(acceptance_lines, "- [ ] _Not provided._") + try: + validator = _issue_format_validator() + except (ImportError, OSError, RuntimeError, SyntaxError): + # Consumer checkouts can be mid-sync or missing the canonical validator. + # Keep the pre-validator fallback usable instead of failing the formatter. + validator = None + gate = getattr(validator, "GATE", None) if validator is not None else None + if gate is not None and not gate.search(acceptance_text): + verify_hint = VERIFY_HINT_REGEX.search(tasks_text) + if verify_hint: + command = verify_hint.group(1).strip().strip("`") + if command.startswith("pytest "): + command = f"python3 -m {command}" + if ( + SAFE_VERIFY_COMMAND_RE.match(command) + and not SHELL_METACHARACTERS_RE.search(command) + and gate.search(command) + ): + if is_placeholder_checklist_text(acceptance_text) or re.fullmatch( + r"- \[ \] _Not provided\._", acceptance_text.strip() + ): + acceptance_text = "" + criterion = f"- [ ] Run `{command}` and capture the command output in PR validation evidence." + acceptance_text = "\n".join(part for part in (acceptance_text, criterion) if part) parts = [ "## Why", @@ -387,8 +449,18 @@ def join_or_placeholder(lines: list[str], placeholder: str) -> str: def _formatted_output_valid(text: str) -> bool: if not text: return False - required = ["## Tasks", "## Acceptance Criteria"] - return all(section in text for section in required) + # The archived Original Issue is provenance, not executable formatted + # content. Its stale or cross-repo path citations must not reverse a valid + # formatter result after the visible body has already passed validation. + visible_text = _strip_original_issue_blocks(text) + try: + workspace = os.environ.get("GITHUB_WORKSPACE", "").strip() + repo_root = Path(workspace).resolve() if workspace else Path.cwd().resolve() + return bool(_issue_format_validator().validate(visible_text, repo_root=repo_root).ok) + except (ImportError, OSError, RuntimeError, SyntaxError): + # Preserve the former heading-only behavior until the copy-synced + # validator becomes available again. + return all(section in visible_text for section in ("## Tasks", "## Acceptance Criteria")) def _select_code_fence(text: str) -> str: @@ -398,25 +470,68 @@ def _select_code_fence(text: str) -> str: ORIGINAL_ISSUE_SUMMARY = "Original Issue" -# Matches an Original-Issue
block (and trailing whitespace) so it can -# be replaced rather than nested. Non-greedy body, anchored to the closing tag. -_ORIGINAL_ISSUE_BLOCK_RE = re.compile( - r"
\s*Original Issue.*?
[ \t]*\n?", - re.DOTALL | re.IGNORECASE, +_ORIGINAL_ISSUE_OPEN_RE = re.compile( + r"]*>\s*Original Issue", re.IGNORECASE ) +_DETAILS_TAG_RE = re.compile(r"]*>", re.IGNORECASE) # Captures the verbatim text fenced inside an Original-Issue block, so an # already-embedded original can be recovered (and re-embedded once) instead of # being wrapped again. _ORIGINAL_ISSUE_INNER_RE = re.compile( - r"
\s*Original Issue\s*" - r"(?P`{3,})text\n(?P.*?)\n(?P=fence)\s*
", + r"]*>\s*Original Issue\s*" + r"(?P`{3,}|~{3,})text\n(?P.*?)\n(?P=fence)\s*
", re.DOTALL | re.IGNORECASE, ) def _strip_original_issue_blocks(text: str) -> str: - """Remove any embedded Original-Issue
block(s) from ``text``.""" - return _ORIGINAL_ISSUE_BLOCK_RE.sub("", text).rstrip() + """Remove complete embedded Original-Issue blocks, including nested details.""" + kept: list[str] = [] + cursor = 0 + while match := _ORIGINAL_ISSUE_OPEN_RE.search(text, cursor): + kept.append(text[cursor : match.start()]) + depth = 1 + end = match.end() + fence: tuple[str, int] | None = None + for tag in _DETAILS_TAG_RE.finditer(text, match.end()): + # Literal HTML in the verbatim Original-Issue fence is content, not + # structural markup. Only count tags outside Markdown fences. + # Include the tag itself while deciding whether this line is a + # marker-only closing fence. A same-line ``
`` is + # fenced content, so it must keep the fence open rather than be + # counted as structural markup. + before = text[end : tag.end()] + for line in before.splitlines(): + fence_match = re.match(r"\s{0,3}(`{3,}|~{3,})", line) + if not fence_match: + continue + 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, + ) + ): + # Closing fences are marker-only; language tags / trailing + # text must not toggle the fence state. + fence = None + end = tag.end() + if fence is not None: + continue + depth += -1 if tag.group(0).startswith(" str | None: @@ -592,20 +707,24 @@ def _reuse_already_formatted(issue_body: str, workflow: str) -> dict[str, Any] | """ reused = reuse_formatted_body({"body": issue_body}, workflow) if reused is not None: + body = _with_reuse_marker(reused) return { - "formatted_body": _with_reuse_marker(reused), + "formatted_body": body, "provider_used": None, "used_llm": False, "skipped": "reused_marker", "validation_audit": None, + "needs_refinement": not _formatted_output_valid(body), } if already_conformant(issue_body): + body = _with_reuse_marker(issue_body) return { - "formatted_body": _with_reuse_marker(issue_body), + "formatted_body": body, "provider_used": None, "used_llm": False, "skipped": "already_conformant", "validation_audit": None, + "needs_refinement": not _formatted_output_valid(body), } return None @@ -697,6 +816,7 @@ def format_issue_body(issue_body: str, *, use_llm: bool = True) -> dict[str, Any "provider_used": provider, "used_llm": True, "validation_audit": audit, + "needs_refinement": not _formatted_output_valid(formatted), } result.update(trace.as_dict()) return result @@ -711,11 +831,13 @@ def format_issue_body(issue_body: str, *, use_llm: bool = True) -> dict[str, Any formatted, audit = _validate_and_refine_tasks(formatted, use_llm=use_llm) formatted = _append_raw_issue_section(formatted, issue_body) formatted = _with_reuse_marker(formatted) + needs_refinement = not _formatted_output_valid(formatted) return { "formatted_body": formatted, "provider_used": None, "used_llm": False, "validation_audit": audit, + "needs_refinement": needs_refinement, } @@ -751,11 +873,15 @@ def main() -> None: if args.json: payload = { - "formatted_body": result["formatted_body"], + "formatted_body": result.get("formatted_body"), "provider_used": result.get("provider_used"), "used_llm": result.get("used_llm", False), "labels": build_label_transition(), + "needs_refinement": result.get("needs_refinement", False), + "validation_audit": result.get("validation_audit"), } + if result.get("error"): + payload["error"] = result["error"] if result.get("guard_blocked"): payload["guard_blocked"] = True payload["guard_reason"] = result.get("guard_reason") or "" diff --git a/scripts/langchain/label_matcher.py b/scripts/langchain/label_matcher.py index 2d02b74457..289276b0cb 100755 --- a/scripts/langchain/label_matcher.py +++ b/scripts/langchain/label_matcher.py @@ -347,7 +347,7 @@ def _token_matches_keyword(token: str, keyword: str) -> bool: return True # Only allow prefix matching for tokens >= 4 chars to avoid false positives # from short tokens like "d" matching "defect" or "a" matching "add" - if len(token) >= 4 and token.startswith(keyword): + if len(token) >= 4 and len(keyword) >= 4 and token.startswith(keyword): return True # Check if keyword starts with token (both must be >= 4 chars) return len(token) >= 4 and len(keyword) >= 4 and keyword.startswith(token) diff --git a/scripts/langchain/structured_output.py b/scripts/langchain/structured_output.py index 3713874c05..1f4fab815e 100644 --- a/scripts/langchain/structured_output.py +++ b/scripts/langchain/structured_output.py @@ -101,10 +101,16 @@ def _repair(schema_json: str, validation_errors: str, raw_response: str) -> str def clamp_repair_attempts(max_repair_attempts: int) -> int: - return min( - MAX_REPAIR_ATTEMPTS, - max(MIN_REPAIR_ATTEMPTS, int(max_repair_attempts)), - ) + if isinstance(max_repair_attempts, bool) or not isinstance(max_repair_attempts, int): + raise TypeError( + "max_repair_attempts must be an integer, got " f"{type(max_repair_attempts).__name__}" + ) + if not MIN_REPAIR_ATTEMPTS <= max_repair_attempts <= MAX_REPAIR_ATTEMPTS: + raise ValueError( + f"max_repair_attempts must be between {MIN_REPAIR_ATTEMPTS} and " + f"{MAX_REPAIR_ATTEMPTS}, got {max_repair_attempts}" + ) + return max_repair_attempts def _invoke_repair_loop[T: BaseModel]( diff --git a/scripts/langchain/task_validator.py b/scripts/langchain/task_validator.py index 1a60edc99f..2f5d2514d4 100755 --- a/scripts/langchain/task_validator.py +++ b/scripts/langchain/task_validator.py @@ -208,9 +208,39 @@ def to_dict(self) -> dict[str, Any]: # --------------------------------------------------------------------------- +_PATH_REFERENCE_PATTERNS = ( + # Absolute paths such as /etc/nginx.conf or /path/to/Dockerfile + r"(? str: + """Remove concrete file/directory references before subjective-word checks.""" + text = task + for pattern in _PATH_REFERENCE_PATTERNS: + text = re.sub(pattern, " ", text) + return text.lower() + + def _has_subjective_without_measurable(task: str) -> bool: """Check if task has subjective language without measurable verification.""" - lowered = task.lower() + # Paths are concrete references, not prose. A path such as + # ``tests/fast/test_api.py`` must not be rejected for its components. + # Only strip path-shaped text. A broad slash-separated-word pattern also + # removes ordinary subjective prose such as "clean/intuitive" before the + # warning check can see it. + lowered = _strip_path_references(task) has_subjective = any(word in lowered for word in SUBJECTIVE_WORDS) has_measurable = any(word in lowered for word in MEASURABLE_WORDS) return has_subjective and not has_measurable diff --git a/scripts/sync_dev_dependencies.py b/scripts/sync_dev_dependencies.py index f114718a33..80c8365f98 100755 --- a/scripts/sync_dev_dependencies.py +++ b/scripts/sync_dev_dependencies.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 -"""Sync dev tool version pins from autofix-versions.env to pyproject.toml. +"""Sync dev tool version pins from autofix-versions.env to dependency surfaces. -This script updates the [project.optional-dependencies] dev section in pyproject.toml +This script updates the [project.optional-dependencies] dev section in pyproject.toml, +supported requirements lockfiles, and (when explicitly requested) managed +.pre-commit-config.yaml hook revisions to use the pinned versions from the central autofix-versions.env file. It handles both exact pins (==) and minimum version pins (>=) in pyproject.toml, @@ -14,6 +16,7 @@ python sync_dev_dependencies.py --apply # Update pyproject.toml python sync_dev_dependencies.py --apply --create-if-missing # Create dev deps if missing python sync_dev_dependencies.py --apply # Syncs supported requirements lockfiles when present + python sync_dev_dependencies.py --apply --pre-commit # Include managed hook revisions """ from __future__ import annotations @@ -22,6 +25,7 @@ import re import sys from pathlib import Path +from urllib.parse import urlparse # Default paths (can be overridden for testing) PIN_FILE = Path(".github/workflows/autofix-versions.env") @@ -33,6 +37,7 @@ Path("requirements-dev.lock"), Path("requirements-dev.txt"), ) +PRE_COMMIT_FILE = Path(".pre-commit-config.yaml") # Map env file keys to package names # Format: ENV_KEY -> (package_name, optional_alternative_names) @@ -49,6 +54,16 @@ "HYPOTHESIS_VERSION": ("hypothesis",), } +# Only version pins for tools already governed by autofix-versions.env are managed. +# The rest of a consumer's pre-commit configuration remains consumer-owned. +PRE_COMMIT_REPO_MAPPING = { + "psf/black": "BLACK_VERSION", + "astral-sh/ruff-pre-commit": "RUFF_VERSION", + "pre-commit/mirrors-mypy": "MYPY_VERSION", + "pycqa/isort": "ISORT_VERSION", + "pycqa/docformatter": "DOCFORMATTER_VERSION", +} + # Core dev tools to include when creating a new dev section # (subset of TOOL_MAPPING - only the most essential ones) CORE_DEV_TOOLS = [ @@ -350,9 +365,7 @@ def sync_lockfile( current_version = match.group("specifier") or "(unversioned)" if current_version.startswith("=="): current_version = current_version[2:] - changes.append( - f"{lockfile_path.name}:{name}: " f"{current_version} -> =={target_version}" - ) + changes.append(f"{lockfile_path.name}:{name}: {current_version} -> =={target_version}") if apply: updated_lines.append( f"{match.group('lead')}{name}{match.group('extras') or ''}" @@ -373,6 +386,89 @@ def sync_lockfile( return changes, [] +def _pre_commit_repo_name(line: str) -> str | None: + """Return the normalized repository name from a pre-commit ``repo:`` line.""" + match = re.match(r"^\s*-\s*repo:\s*(?P[^\s#]+)", line) + if not match: + return None + + repo = match.group("repo").strip().strip("\"'") + if "://" in repo: + parsed = urlparse(repo) + if parsed.hostname and parsed.hostname.lower() == "github.com": + repo = parsed.path.lstrip("/") + repo = repo.rstrip("/").removesuffix(".git") + if repo.lower().startswith("github.com/"): + repo = repo.split("/", 1)[1] + return repo.lower() + + +def sync_pre_commit_config( + pre_commit_path: Path, pins: dict[str, str], apply: bool = False +) -> tuple[list[str], list[str]]: + """Sync managed pre-commit hook revisions while preserving all other text. + + Pre-commit configuration is intentionally not copied wholesale to consumers. + This only changes a recognized remote hook's ``rev:`` value and preserves that + hook's existing ``v`` prefix convention. + """ + if not pre_commit_path.exists(): + return [], [] + + with pre_commit_path.open(encoding="utf-8", newline="") as pre_commit_file: + content = pre_commit_file.read() + lines = content.splitlines(keepends=True) + changes: list[str] = [] + current_env_key: str | None = None + + for index, line in enumerate(lines): + repo_name = _pre_commit_repo_name(line) + if repo_name is not None: + current_env_key = PRE_COMMIT_REPO_MAPPING.get(repo_name) + continue + + if current_env_key is None or current_env_key not in pins: + continue + + line_ending = "\r\n" if line.endswith("\r\n") else "\n" if line.endswith("\n") else "" + revision_line = line[: -len(line_ending)] if line_ending else line + rev_match = re.match( + r"^(?P\s*rev:\s*)(?P['\"]?)(?P[^\s#'\"]+)" + r"(?P=quote)(?P.*)$", + revision_line, + ) + if not rev_match: + continue + + current_value = rev_match.group("value") + target_value = pins[current_env_key] + if current_value.startswith("v"): + target_value = f"v{target_value}" + if current_value == target_value: + current_env_key = None + continue + + repo_name = next( + name for name, env_key in PRE_COMMIT_REPO_MAPPING.items() if env_key == current_env_key + ) + changes.append(f"{pre_commit_path.name}:{repo_name}: {current_value} -> {target_value}") + if apply: + lines[index] = ( + f"{rev_match.group('prefix')}{rev_match.group('quote')}{target_value}" + f"{rev_match.group('quote')}{rev_match.group('suffix')}" + f"{line_ending}" + ) + current_env_key = None + + if apply: + updated = "".join(lines) + if updated != content: + with pre_commit_path.open("w", encoding="utf-8", newline="") as pre_commit_file: + pre_commit_file.write(updated) + + return changes, [] + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Sync dev dependency versions from autofix-versions.env to pyproject.toml" @@ -402,6 +498,14 @@ def main(argv: list[str] | None = None) -> int: action="store_true", help="Compatibility flag; supported requirements lockfiles are always checked", ) + parser.add_argument( + "--pre-commit", + action="store_true", + help=( + "Include managed .pre-commit-config.yaml hook revisions; Maint 52 opts in " + "after the canonical dependency wave is ready" + ), + ) parser.add_argument( "--pin-file", type=Path, @@ -443,6 +547,13 @@ def main(argv: list[str] | None = None) -> int: changes.extend(lock_changes) errors.extend(lock_errors) + if args.pre_commit: + pre_commit_changes, pre_commit_errors = sync_pre_commit_config( + PRE_COMMIT_FILE, pins, apply=args.apply + ) + changes.extend(pre_commit_changes) + errors.extend(pre_commit_errors) + if errors: for err in errors: print(f"Error: {err}", file=sys.stderr) diff --git a/tools/langchain_client.py b/tools/langchain_client.py index 48a6e60393..7261e02bb3 100644 --- a/tools/langchain_client.py +++ b/tools/langchain_client.py @@ -10,6 +10,7 @@ import contextlib import logging import os +import re from dataclasses import dataclass from tools import llm_registry as _llm_registry @@ -150,7 +151,7 @@ def _is_reasoning_model(model: str) -> bool: # o-series reasoning models use an `o` prefix followed by digits with optional # hyphen-separated suffixes: o1, o1-preview, o1-preview-2024-09-12, o3, o3-mini, # o3-pro, o4-mini, o4-mini-deep-research. - return bool(__import__("re").fullmatch(r"o[0-9]+(?:-[a-z0-9]+)*", name)) + return bool(re.fullmatch(r"o[0-9]+(?:-[a-z0-9]+)*", name)) def _build_openai_client( diff --git a/tools/llm_provider.py b/tools/llm_provider.py index 1c11875e63..83483c47ce 100644 --- a/tools/llm_provider.py +++ b/tools/llm_provider.py @@ -61,14 +61,17 @@ def _setup_langsmith_tracing() -> bool: if not api_key: return False - # Enable LangChain tracing v2 - os.environ["LANGCHAIN_TRACING_V2"] = "true" + # Respect an explicit opt-out while defaulting enabled tracing for configured users. + os.environ.setdefault("LANGCHAIN_TRACING_V2", "true") os.environ.setdefault("LANGCHAIN_PROJECT", "workflows-agents") # LangSmith uses LANGSMITH_API_KEY directly, but LangChain expects LANGCHAIN_API_KEY os.environ.setdefault("LANGCHAIN_API_KEY", api_key) os.environ.setdefault("LANGSMITH_API_KEY", api_key) project = os.environ.get("LANGCHAIN_PROJECT") + if os.environ["LANGCHAIN_TRACING_V2"].strip().lower() == "false": + logger.info("LangSmith tracing explicitly disabled for project: %s", project) + return False logger.info(f"LangSmith tracing enabled for project: {project}") return True diff --git a/tools/requirements-llm.txt b/tools/requirements-llm.txt index b83726c07c..9a599a9bb0 100644 --- a/tools/requirements-llm.txt +++ b/tools/requirements-llm.txt @@ -10,6 +10,6 @@ langchain==1.3.14 langchain-community==0.4.2 langchain-openai==1.4.1 -langchain-anthropic==1.5.3 +langchain-anthropic==1.5.4 pydantic==2.13.4 requests==2.34.2