From 39afdd6c8485b4f65b24f6202adf998023209bf5 Mon Sep 17 00:00:00 2001 From: "agents-workflows-bot[bot]" <251285186+agents-workflows-bot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:55:19 +0000 Subject: [PATCH] chore: sync workflow templates from Workflows repo Automated sync from stranske/Workflows Template hash: 1f3f4541b9df Changes synced from sync-manifest.yml Consumer-sync plan ID: sha256:1f3f4541b9df343e4661e6b4b22ff90043955c6d9382b82eb3fbd4a96fa25c0c Plan scope: full Scope base SHA: full Source commit: 5ef1f72907e90b6a838a10b68ac047455ebd3914 Canary evidence JSON (base64): eyJzY2hlbWEiOiJ3b3JrZmxvd3MuY29uc3VtZXItc3luYy1jYW5hcnktZXZpZGVuY2UvdjEiLCJ2ZXJzaW9uIjoxLCJyZXN1bHRzIjpbeyJyZXBvIjoic3RyYW5za2UvVHJhdmVsLVBsYW4tUGVybWlzc2lvbiIsInBsYW5faWQiOiJzaGEyNTY6MWYzZjQ1NDFiOWRmMzQzZTQ2NjFlNmI0YjIyZmY5MDA0Mzk1NWM2ZDkzODJiODJlYjNmYmQ0YTk2ZmEyNWMwYyIsInBsYW5fc2NvcGUiOiJmdWxsIiwic2NvcGVfYmFzZV9zaGEiOiIiLCJzb3VyY2VfY29tbWl0IjoiNWVmMWY3MjkwN2U5MGI2YTgzOGExMGI2OGFjMDQ3NDU1ZWJkMzkxNCIsInByIjoxNTgyLCJoZWFkX3NoYSI6IjgzMzZjOGI5NGI5ZDVjY2ZiMmNlZWQ0ZWY0MjkyMjMxNzZhZDRhMzYiLCJldmlkZW5jZV9zb3VyY2UiOiJvcGVuLWNhbmRpZGF0ZSIsInJlcXVpcmVkX2NoZWNrX3N0YXRlIjoic3VjY2VzcyIsImFjdGl2ZV9yZXZpZXdfdGhyZWFkX2NvdW50IjowfSx7InJlcG8iOiJzdHJhbnNrZS90cmlwLXBsYW5uZXIiLCJwbGFuX2lkIjoic2hhMjU2OjFmM2Y0NTQxYjlkZjM0M2U0NjYxZTZiNGIyMmZmOTAwNDM5NTVjNmQ5MzgyYjgyZWIzZmJkNGE5NmZhMjVjMGMiLCJwbGFuX3Njb3BlIjoiZnVsbCIsInNjb3BlX2Jhc2Vfc2hhIjoiIiwic291cmNlX2NvbW1pdCI6IjVlZjFmNzI5MDdlOTBiNmE4MzhhMTBiNjhhYzA0NzQ1NWViZDM5MTQiLCJwciI6MTgyMSwiaGVhZF9zaGEiOiI5Y2EwOGQwNzkyNzgxYjk0OTUxNjkyYzhlNWYwMzc5NjFmNTI5Yjk1IiwiZXZpZGVuY2Vfc291cmNlIjoib3Blbi1jYW5kaWRhdGUiLCJyZXF1aXJlZF9jaGVja19zdGF0ZSI6InN1Y2Nlc3MiLCJhY3RpdmVfcmV2aWV3X3RocmVhZF9jb3VudCI6MH0seyJyZXBvIjoic3RyYW5za2UvUG9ydGFibGUtQWxwaGEtRXh0ZW5zaW9uLU1vZGVsIiwicGxhbl9pZCI6InNoYTI1NjoxZjNmNDU0MWI5ZGYzNDNlNDY2MWU2YjRiMjJmZjkwMDQzOTU1YzZkOTM4MmI4MmViM2ZiZDRhOTZmYTI1YzBjIiwicGxhbl9zY29wZSI6ImZ1bGwiLCJzY29wZV9iYXNlX3NoYSI6IiIsInNvdXJjZV9jb21taXQiOiI1ZWYxZjcyOTA3ZTkwYjZhODM4YTEwYjY4YWMwNDc0NTVlYmQzOTE0IiwicHIiOjIyOTksImhlYWRfc2hhIjoiYWNhYmUwMWQxYjUyZTFlYjcxZjgwZmY3MDk1ZjM4OTQ4NTFjZTljMiIsImV2aWRlbmNlX3NvdXJjZSI6Im9wZW4tY2FuZGlkYXRlIiwicmVxdWlyZWRfY2hlY2tfc3RhdGUiOiJzdWNjZXNzIiwiYWN0aXZlX3Jldmlld190aHJlYWRfY291bnQiOjB9XX0= --- .../workflows/agents-81-gate-followups.yml | 22 ++++- scripts/check_deliberate_break.py | 61 ++++++++++++++ scripts/runner_lib/core.py | 83 +++++++++++++++++-- scripts/validate_run_contract.py | 19 +++-- 4 files changed, 171 insertions(+), 14 deletions(-) diff --git a/.github/workflows/agents-81-gate-followups.yml b/.github/workflows/agents-81-gate-followups.yml index de4ede9e2..bd94aa5c2 100644 --- a/.github/workflows/agents-81-gate-followups.yml +++ b/.github/workflows/agents-81-gate-followups.yml @@ -2228,6 +2228,7 @@ jobs: source_run_id: String(context.payload.workflow_run?.id || ''), }; clientPayload.active_sync_hash = activeSyncHash; + const SKIPPED_UNAUTHORIZED = 'skipped-unauthorized'; const withRetry = async (operation) => { let lastError; for (let attempt = 0; attempt < 3; attempt += 1) { @@ -2235,7 +2236,20 @@ jobs: return await operation(); } catch (error) { lastError = error; - if (![429, 500, 502, 503, 504].includes(Number(error?.status))) throw error; + const status = Number(error?.status); + // Best-effort wakeup: an unauthorized cross-repo dispatch must not fail Gate + // Followups. Maint 71 is woken by workflow_dispatch anyway (#3364). + if ([403, 404].includes(status)) { + core.warning( + `Generated-delivery wakeup skipped: repository_dispatch to stranske/Workflows ` + + `returned ${status} (token: WRITE_TOKEN fallback chain). ` + + `Error: ${error?.message || error}`, + ); + return SKIPPED_UNAUTHORIZED; + } + // Do not broaden the swallow: only 403/404 are treated as best-effort. + // Transient 429/5xx retry; any other status fails immediately. + if (![429, 500, 502, 503, 504].includes(status)) throw error; await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1))); } } @@ -2244,13 +2258,15 @@ jobs: ); throw lastError; }; - await withRetry(() => github.rest.repos.createDispatchEvent({ + const outcome = await withRetry(() => github.rest.repos.createDispatchEvent({ owner: 'stranske', repo: 'Workflows', event_type: 'merge-sync-prs', client_payload: clientPayload, })); - core.notice(`Woke Maint 71 lane ${activeSyncHash} from ${branch}`); + if (outcome !== SKIPPED_UNAUTHORIZED) { + core.notice(`Woke Maint 71 lane ${activeSyncHash} from ${branch}`); + } guarded-merge: name: Merge automerge-labelled agent PRs diff --git a/scripts/check_deliberate_break.py b/scripts/check_deliberate_break.py index ecfcf2853..ff750e460 100644 --- a/scripts/check_deliberate_break.py +++ b/scripts/check_deliberate_break.py @@ -117,6 +117,21 @@ def _explicit_marker(section: str) -> DeliberateBreakSpec | None: break_file = values.get("break-file") or values.get("revert-file") command_text = values.get("command") if not test_id or not test_file or not break_file: + # Checklist prose like "Deliberate-break: revert the X branch" matches + # MARKER_RE but is not a key=value marker; skip and keep searching. + if not any( + key in values + for key in ( + "test", + "test-id", + "test-file", + "file", + "break-file", + "revert-file", + "command", + ) + ): + continue raise ValueError("deliberate-break marker requires test, test-file, and break-file") command = tuple(shlex.split(command_text)) if command_text else _pytest_command(test_id) return DeliberateBreakSpec(test_id, test_file, break_file, command) @@ -759,6 +774,31 @@ def _run_with_runtime_deps( raise CommandUnavailableError(exc) from exc +_MISSING_MODULE_RE = re.compile(r"ModuleNotFoundError: No module named ['\"]([^'\"]+)['\"]") +# A ModuleNotFoundError only means "the test never ran" when pytest raised it while COLLECTING. +# The same exception from inside a test body is an ordinary failure of a test that did run, and +# reporting that as not-importable would hide a real acceptance failure behind an environment +# excuse. Require pytest's own collection diagnostics as corroboration. +_COLLECTION_ERROR_RE = re.compile( + r"ImportError while importing test module|ERROR collecting|errors during collection", + re.IGNORECASE, +) + + +def _missing_module_from_pytest_output(*streams: str | None) -> str | None: + """Return the module missing at COLLECTION time, or None if that is not the failure. + + Collection-time ImportErrors surface inside pytest's captured output rather than as an + exception this script can catch, which is why they previously landed in the generic + head-test-failed branch. + """ + joined = "\n".join(stream for stream in streams if stream) + if not joined or not _COLLECTION_ERROR_RE.search(joined): + return None + match = _MISSING_MODULE_RE.search(joined) + return match.group(1) if match else None + + def _runtime_dependency_error_result(error: Exception) -> dict[str, object]: """Map dependency-repair failures consistently for head and base runs.""" if isinstance(error, subprocess.TimeoutExpired): @@ -927,6 +967,27 @@ def verify_spec( ) if head_run.returncode != 0: + # "The test could not be collected" and "the test ran and failed" are different facts, + # and reporting them under one reason made the gate unactionable: Deliverable-Render #20 + # spent five autofix attempts on a missing runtime dependency while its own declaration + # was correct, because `head-test-failed` reads as an acceptance failure. Name the + # environment case so the next reader fixes the environment, not the PR. + missing = _missing_module_from_pytest_output(head_run.stdout, head_run.stderr) + if missing is not None: + return _json_result( + VERDICT_BROKEN, + reason="head-test-not-importable", + test_id=spec.test_id, + command=list(spec.command), + missing_module=missing, + detail=( + f"The named test could not be imported: no module named {missing!r}. " + "This is an environment defect, not a failed deliberate break -- the test " + "never ran. Install the project and its dependencies before this check." + ), + stdout=head_run.stdout, + stderr=head_run.stderr, + ) return _json_result( VERDICT_BROKEN, reason="head-test-failed", diff --git a/scripts/runner_lib/core.py b/scripts/runner_lib/core.py index b689dbea6..899ffd581 100644 --- a/scripts/runner_lib/core.py +++ b/scripts/runner_lib/core.py @@ -1051,6 +1051,16 @@ def _unproductive_completion_count(prior: dict[str, Any] | None) -> int: return 0 +def _workflow_attempt_id() -> str: + """Identify the reserving workflow attempt across its jobs, not just the PR head.""" + repository = os.environ.get("GITHUB_REPOSITORY", "") + run_id = os.environ.get("GITHUB_RUN_ID", "") + attempt = os.environ.get("GITHUB_RUN_ATTEMPT", "") + if repository and run_id and attempt: + return f"{repository}:{run_id}:{attempt}" + return "" + + def _reserve_dispatch( storage: RunnerDispatchStorage, pr_number: int, @@ -1070,11 +1080,16 @@ def _reserve_dispatch( "status": "pending", "started_at": _utc_now(), } + attempt_id = _workflow_attempt_id() + if attempt_id: + record["workflow_attempt_id"] = attempt_id # Carry the unproductive tally across the retry so the allowance is bounded: it is the only # thing that makes "retry an unproductive completion" terminate instead of cycling forever. unproductive = _unproductive_completion_count(prior) if unproductive and prior and prior.get("head_sha") == head_sha: record["unproductive_completions"] = unproductive + if _completion_was_unproductive(prior): + record["productive"] = False storage.write_record(pr_number, provider, record) return DebounceDecision( True, @@ -1173,6 +1188,30 @@ def should_dispatch( return _reserve_dispatch(storage, pr_number, head_sha, provider, key, prior, reason=reason) +def _log_completion_storage_failure(operation: str, exc: Exception) -> None: + # GitHubApi preserves the HTTP/network exception as its cause. Log diagnostic + # metadata, not raw exception text, which can contain URLs or response bodies. + cause = exc.__cause__ or exc + code = getattr(cause, "code", None) + status = str(code) if isinstance(code, int) and 100 <= code <= 599 else "unknown" + print( + f"warning: authoritative completion {operation} failed: " + f"error_type={type(exc).__name__} cause_type={type(cause).__name__} " + f"http_status={status}", + file=sys.stderr, + ) + + +def _unrecorded_completion(prior: dict[str, Any], key: str, reason: str) -> dict[str, Any]: + return { + "status": "unknown", + "key": key, + **prior, + "completion_recorded": False, + "completion_reason": reason, + } + + def record_completion( pr_number: int, head_sha: str, @@ -1184,7 +1223,8 @@ def record_completion( """Persist terminal runner state after a dispatch finishes. ``produced_work`` is the caller's verdict on whether the run actually moved the branch. - ``None`` means unmeasured and preserves the pre-#3433 behavior. ``False`` marks the + ``None`` means unmeasured and preserves an existing same-head unproductive retry streak; + otherwise it preserves the pre-#3433 behavior. ``False`` marks the completion unproductive so ``should_dispatch`` will grant a bounded retry on the same head instead of refusing forever (#3433). """ @@ -1196,7 +1236,31 @@ def record_completion( ) status = "completed" if result_payload.get("success") else "error" compact_result = _compact_runner_result_payload(result_payload) - prior = storage.read_record(pr_number, provider) or {} + # Dispatch may use a fallback for availability, but completion must validate and + # update the authoritative reservation. An empty/stale fallback cannot prove + # that a newer attempt does not own the primary, even if caller identity is absent. + uses_fallback = isinstance(storage, FallbackRunnerStorage) + completion_storage = storage.primary if isinstance(storage, FallbackRunnerStorage) else storage + try: + prior_record = completion_storage.read_record(pr_number, provider) + except Exception as exc: + if not uses_fallback: + raise + _log_completion_storage_failure("read", exc) + return _unrecorded_completion({}, key, "authoritative-storage-unavailable") + if uses_fallback and prior_record is None: + return _unrecorded_completion({}, key, "authoritative-reservation-missing") + prior = prior_record or {} + if prior.get("workflow_attempt_id") and ( + prior.get("workflow_attempt_id") != _workflow_attempt_id() + or (prior.get("key") != key and produced_work is not True) + ): + # A completion rerun from an earlier attempt must not overwrite a newer reservation, + # including when both attempts target the same head. The owning attempt may report + # a new head only when it explicitly measured productive work. Return an observation only. + return _unrecorded_completion(prior, key, "stale-attempt") + if produced_work is None and prior.get("key") == key and _completion_was_unproductive(prior): + produced_work = False completed_at = ( prior.get("completed_at") if prior.get("key") == key and prior.get("status") in TERMINAL_STATUSES @@ -1231,7 +1295,15 @@ def record_completion( record["unproductive_completions"] = min( previous + 1, UNPRODUCTIVE_COMPLETION_RETRY_LIMIT + 1 ) - storage.write_record(pr_number, provider, record) + try: + completion_storage.write_record(pr_number, provider, record) + except Exception as exc: + if not uses_fallback: + raise + _log_completion_storage_failure("write", exc) + # Never redirect a checked primary reservation into an unchecked fallback. + # A failed response may be ambiguous; a retry re-reads primary state first. + return _unrecorded_completion(prior, key, "authoritative-storage-unavailable") return record @@ -1369,7 +1441,8 @@ def _cmd_record_completion(args: argparse.Namespace) -> int: produced_work=_parse_produced_work(args.produced_work), ) outputs = { - "recorded": "true", + "recorded": "false" if record.get("completion_recorded") is False else "true", + "reason": str(record.get("completion_reason", "")), "status": str(record["status"]), "key": str(record["key"]), "productive": "" if "productive" not in record else str(record["productive"]).lower(), @@ -1443,7 +1516,7 @@ def build_parser() -> argparse.ArgumentParser: default="", help=( "whether the run actually moved the branch (true/false). Anything else, including " - "the default, means unmeasured and keeps the completion terminal." + "the default, means unmeasured and preserves an existing unproductive retry streak." ), ) complete.set_defaults(func=_cmd_record_completion) diff --git a/scripts/validate_run_contract.py b/scripts/validate_run_contract.py index 6f781039a..c1eda944f 100644 --- a/scripts/validate_run_contract.py +++ b/scripts/validate_run_contract.py @@ -48,6 +48,7 @@ "artifact-manifest/v1": "artifact-manifest-v1.schema.json", "evidence-object/v1": "evidence-object-v1.schema.json", "tracked-variable/v1": "tracked-variable-v1.schema.json", + "capability-bundle/v1": "capability-bundle-v1.schema.json", } # Tokens that are convention-only (no JSON Schema to load); accepted as declared # ingest surfaces but not schema-validated here. @@ -483,12 +484,18 @@ def _self_smoke(schema_dir: Path, registry_path: Path) -> int: PASS/FAIL line per case and returns non-zero if any case is unexpected. """ registry = _load_json(registry_path) - # Load all three schemas (must be valid Draft 2020-12). - for name in ( - "run-contract-v1.schema.json", - "artifact-manifest-v1.schema.json", - "evidence-object-v1.schema.json", - ): + # Load EVERY bundled schema (must be valid Draft 2020-12). Discovering them + # rather than naming three means a schema added to the directory is checked + # the day it lands; the previous hardcoded triple silently skipped + # tracked-variable-v1 and capability-bundle-v1. + schema_names = sorted(path.name for path in schema_dir.glob("*.schema.json")) + if not schema_names: + print( + f"FAIL schema dir {schema_dir}: no *.schema.json files found; " + "self-smoke cannot validate schemas" + ) + return 1 + for name in schema_names: schema = _load_schema(schema_dir, name) Draft202012Validator.check_schema(schema) print(f"PASS schema loads + valid Draft202012: {name}")