Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/scripts/__tests__/agents-pr-meta-update-body.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const {
buildPreamble,
buildSourceContextRepairCommentBody,
buildSourceContextResolvedCommentBody,
resolveExplicitNonIssueWorkflowSourceContext,
resolveSourceContextRepairComment,
resolveAgentType,
stripPrTemplateContent,
Expand Down Expand Up @@ -443,6 +444,47 @@ test('buildSourceContextResolvedCommentBody explains sync-campaign source contex
assert.ok(result.includes('No linked GitHub issue is required'));
});

test('resolveExplicitNonIssueWorkflowSourceContext preserves explicit automation source despite stale issue preamble', () => {
const context = resolveExplicitNonIssueWorkflowSourceContext({
body: [
'<!-- pr-preamble:start -->',
'<!-- meta:issue:1940 -->',
'> **Source:** Issue #1940',
'',
'Closes #1940',
'<!-- pr-preamble:end -->',
'',
'<!-- workflow-source:automation_run -->',
'<!-- workflow-source-ref:workflows-system-review-2 slice 112 -->',
'<!-- workflow-lifecycle:implementation_slice -->',
'<!-- workflow-automation:optional_local_or_remote -->',
].join('\n'),
});

assert.deepEqual(context, {
sourceType: 'automation_run',
issueNumber: null,
sourceRef: 'workflows-system-review-2 slice 112',
lifecycle: 'implementation_slice',
automation: 'optional_local_or_remote',
isKnown: true,
isValid: true,
isExplicit: true,
requiresIssue: false,
});
});

test('resolveExplicitNonIssueWorkflowSourceContext ignores source issue markers', () => {
const context = resolveExplicitNonIssueWorkflowSourceContext({
body: [
'<!-- workflow-source:github_issue -->',
'<!-- workflow-source-ref:#123 -->',
].join('\n'),
});

assert.equal(context, null);
});

test('resolveSourceContextRepairComment updates an existing warning once', async () => {
const calls = { update: 0, body: '' };
const github = {
Expand Down
63 changes: 63 additions & 0 deletions .github/scripts/agents_pr_meta_update_body.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ const childProcess = require('child_process');
const { ensureRateLimitWrapped } = require('./github-rate-limited-wrapper.js');
const {
formatSourceContextForLog,
normalizeSourceType,
resolvePrSourceContext,
SOURCE_TYPES,
VALID_SOURCE_TYPES,
} = require('./source_context.js');

class RateLimitError extends Error {
Expand Down Expand Up @@ -987,6 +990,39 @@ function buildSourceContextResolvedCommentBody(prNumber, sourceContext) {
].join('\n');
}

function parseHtmlMarker(body, name) {
const pattern = new RegExp(`<!--\\s*${name}\\s*:\\s*([\\s\\S]*?)\\s*-->`, 'i');
const match = String(body || '').match(pattern);
return match ? String(match[1] || '').trim() : '';
}

function resolveExplicitNonIssueWorkflowSourceContext(pr = {}) {
const body = String(pr.body || '');
const sourceType = normalizeSourceType(parseHtmlMarker(body, 'workflow-source'));
if (
!VALID_SOURCE_TYPES.has(sourceType)
|| sourceType === SOURCE_TYPES.GITHUB_ISSUE
|| sourceType === SOURCE_TYPES.UNKNOWN
) {
return null;
}

const sourceRef = parseHtmlMarker(body, 'workflow-source-ref');
const lifecycle = parseHtmlMarker(body, 'workflow-lifecycle');
const automation = parseHtmlMarker(body, 'workflow-automation');
return {
sourceType,
issueNumber: null,
sourceRef,
lifecycle,
automation,
isKnown: true,
isValid: true,
isExplicit: true,
requiresIssue: false,
};
}

async function resolveSourceContextRepairComment({
github,
owner,
Expand Down Expand Up @@ -1320,6 +1356,32 @@ async function run({github: rawGithub, context, core, inputs}) {
return;
}

const explicitNonIssueSourceContext = resolveExplicitNonIssueWorkflowSourceContext(pr);
if (explicitNonIssueSourceContext) {
core.info(
`PR #${pr.number} has explicit non-issue workflow source context (${formatSourceContextForLog(explicitNonIssueSourceContext)}); skipping issue-sourced body sync.`,
);
try {
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: pr.number,
});
await resolveSourceContextRepairComment({
github,
owner,
repo,
prNumber: pr.number,
comments,
sourceContext: explicitNonIssueSourceContext,
core,
});
} catch (error) {
core.warning(`Failed to resolve workflow source repair comment: ${error.message}`);
}
return;
}

const issueNumber = extractIssueNumberFromPull(pr);
const sourceContext = resolvePrSourceContext(pr);
if (!issueNumber) {
Expand Down Expand Up @@ -1566,6 +1628,7 @@ module.exports = {
buildPreamble,
buildSourceContextRepairCommentBody,
buildSourceContextResolvedCommentBody,
resolveExplicitNonIssueWorkflowSourceContext,
resolveSourceContextRepairComment,
isCampaignIssue,
buildStatusBlock,
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/maint-46-post-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ jobs:
if: ${{ steps.gate_guard.outputs.recover == 'true' }}
id: render
run: |
python tools/post_ci_summary.py
python -m tools.post_ci_summary
env:
RUNS_JSON: ${{ steps.discover.outputs.runs }}
HEAD_SHA: ${{ steps.discover.outputs.head_sha }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-00-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,7 @@ jobs:
- name: Prepare summary body
id: summary_body
run: |
python tools/post_ci_summary.py
python -m tools.post_ci_summary
env:
PYTHONPATH: ${{ github.workspace }}
RUNS_JSON: ${{ steps.gather.outputs.runs || '[]' }}
Expand Down
165 changes: 162 additions & 3 deletions scripts/aggregate_agent_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@
_DEFAULT_OUTPUT = "agent-metrics-summary.md"
_DEFAULT_JSON_OUTPUT = "agent-metrics-summary.json"
_DEFAULT_DOWNLOAD_MANIFEST_PATH = "artifacts/metric-artifact-download-manifest.json"
_DEFAULT_ARTIFACT_SELECTION_PATH = "artifacts/metric-artifacts-selection.json"
_DEFAULT_UNSUPPORTED_VERIFIER_MODELS = {"gpt-5.2-codex"}
_DEFAULT_VERIFIER_MODEL_METADATA_REQUIRED_AFTER = ""
_TERMINAL_ARTIFACT_FAMILIES = (
"review-thread-terminal-disposition",
"verifier-terminal-disposition",
)
_EXACT_ARTIFACT_FAMILIES = {
"keepalive-metrics",
"agents-autofix-metrics",
Expand Down Expand Up @@ -1022,10 +1027,142 @@ def _read_artifact_download_contract(manifest_path: Path) -> dict[str, Any] | No
return _artifact_download_contract(manifest, manifest_path)


def _compact_artifact_ref(value: Any) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
return {
"id": value.get("id"),
"name": value.get("name") or "",
"created_at": value.get("created_at") or "",
"updated_at": value.get("updated_at") or "",
}


def _artifact_selection_contract(selection: dict[str, Any], selection_path: Path) -> dict[str, Any]:
statuses: list[dict[str, Any]] = []
priority_statuses = selection.get("priority_family_statuses")
if isinstance(priority_statuses, list):
for item in priority_statuses:
if not isinstance(item, dict):
continue
family = str(item.get("family") or "")
if family not in _TERMINAL_ARTIFACT_FAMILIES:
continue
statuses.append(
{
"family": family,
"status": str(item.get("status") or "unknown"),
"candidate_count": _safe_int(item.get("candidate_count")) or 0,
"selected_count": _safe_int(item.get("selected_count")) or 0,
"latest_candidate": _compact_artifact_ref(item.get("latest_candidate")),
"selected_artifact": _compact_artifact_ref(item.get("selected_artifact")),
}
)

seen = {item["family"] for item in statuses}
candidate_counts = selection.get("candidate_family_counts")
selected_counts = selection.get("selected_family_counts")
candidate_counts = candidate_counts if isinstance(candidate_counts, dict) else {}
selected_counts = selected_counts if isinstance(selected_counts, dict) else {}
for family in _TERMINAL_ARTIFACT_FAMILIES:
if family in seen:
continue
candidate_count = _safe_int(candidate_counts.get(family)) or 0
selected_count = _safe_int(selected_counts.get(family)) or 0
status = "selected" if selected_count else "missing"
statuses.append(
{
"family": family,
"status": status,
"candidate_count": candidate_count,
"selected_count": selected_count,
"latest_candidate": None,
"selected_artifact": None,
}
)

statuses.sort(key=lambda item: _TERMINAL_ARTIFACT_FAMILIES.index(item["family"]))
missing_terminal = [
item["family"]
for item in statuses
if item["status"] == "missing" or item["selected_count"] <= 0
]
return {
"schema": selection.get("schema") or "unknown",
"path": selection_path.as_posix(),
"status": selection.get("status") or "unknown",
"selected_count": _safe_int(selection.get("selected_count")) or 0,
"candidate_count": _safe_int(selection.get("candidate_count")) or 0,
"missing_priority_families": list(selection.get("missing_priority_families") or []),
Comment on lines +1090 to +1096

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing_priority_families is coerced with list(selection.get(...)), which will turn a malformed string into a list of characters (e.g., "abc" -> ["a","b","c"]). Since this JSON is read from disk, it should be type-checked (only accept lists) and otherwise default to an empty list (or a clear error state) to keep the emitted contract stable.

Suggested change
return {
"schema": selection.get("schema") or "unknown",
"path": selection_path.as_posix(),
"status": selection.get("status") or "unknown",
"selected_count": _safe_int(selection.get("selected_count")) or 0,
"candidate_count": _safe_int(selection.get("candidate_count")) or 0,
"missing_priority_families": list(selection.get("missing_priority_families") or []),
raw_missing_priority_families = selection.get("missing_priority_families")
missing_priority_families = (
list(raw_missing_priority_families)
if isinstance(raw_missing_priority_families, list)
else []
)
return {
"schema": selection.get("schema") or "unknown",
"path": selection_path.as_posix(),
"status": selection.get("status") or "unknown",
"selected_count": _safe_int(selection.get("selected_count")) or 0,
"candidate_count": _safe_int(selection.get("candidate_count")) or 0,
"missing_priority_families": missing_priority_families,

Copilot uses AI. Check for mistakes.
"terminal_artifact_families": statuses,
"missing_terminal_artifact_families": missing_terminal,
}


def _read_artifact_selection_contract(selection_path: Path) -> dict[str, Any] | None:
if not selection_path.exists():
return None
try:
selection = json.loads(selection_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return {
"schema": "workflows-weekly-metrics-artifact-selection/v1",
"path": selection_path.as_posix(),
"status": "error",
"error_message": str(exc),
"selected_count": 0,
"candidate_count": 0,
"missing_priority_families": [],
"terminal_artifact_families": [],
"missing_terminal_artifact_families": list(_TERMINAL_ARTIFACT_FAMILIES),
}
if not isinstance(selection, dict):
return {
"schema": "workflows-weekly-metrics-artifact-selection/v1",
"path": selection_path.as_posix(),
"status": "error",
"error_message": "selection-not-object",
"selected_count": 0,
"candidate_count": 0,
"missing_priority_families": [],
"terminal_artifact_families": [],
"missing_terminal_artifact_families": list(_TERMINAL_ARTIFACT_FAMILIES),
}
return _artifact_selection_contract(selection, selection_path)


def _format_terminal_artifact_statuses(artifact_selection: dict[str, Any] | None) -> str:
if not artifact_selection:
return "n/a"
statuses = artifact_selection.get("terminal_artifact_families")
if not isinstance(statuses, list) or not statuses:
return "n/a"
parts = []
for item in statuses:
if not isinstance(item, dict):
continue
family = item.get("family") or "unknown"
status = item.get("status") or "unknown"
selected = item.get("selected_count") or 0
candidates = item.get("candidate_count") or 0
parts.append(f"{family}: {status} ({selected}/{candidates})")
return ", ".join(parts) if parts else "n/a"


def _format_missing_terminal_artifact_families(
artifact_selection: dict[str, Any] | None,
) -> str:
if not artifact_selection:
return "n/a"
missing = artifact_selection.get("missing_terminal_artifact_families") or []
return ", ".join(str(item) for item in missing) if missing else "none"


def build_summary(
entries: list[dict[str, Any]],
errors: int,
parse_error_details: list[ParseErrorDetail] | None = None,
artifact_selection: dict[str, Any] | None = None,
) -> str:
buckets = _bucket_entries(entries)
timestamps: list[_dt.datetime] = []
Expand Down Expand Up @@ -1100,6 +1237,11 @@ def build_summary(
f"- Terminal disposition records: {verifier['terminal_records']}",
f"- Terminal dispositions: {_format_counter(verifier['terminal_dispositions'])}",
f"- Terminal disposition sources: {_format_counter(verifier['terminal_sources'])}",
f"- Terminal artifact families: {_format_terminal_artifact_statuses(artifact_selection)}",
(
"- Missing terminal artifact families: "
f"{_format_missing_terminal_artifact_families(artifact_selection)}"
),
f"- Verifier follow-up ledger records: {verifier['ledger_records']}",
f"- Verifier follow-up ledger dispositions: {_format_counter(verifier['ledger_dispositions'])}",
f"- Verifier follow-up ledger PRs: {verifier['ledger_prs']}",
Expand Down Expand Up @@ -1197,6 +1339,7 @@ def build_summary_contract(
entries: list[dict[str, Any]],
parse_error_details: list[ParseErrorDetail],
artifact_downloads: dict[str, Any] | None = None,
artifact_selection: dict[str, Any] | None = None,
) -> dict[str, Any]:
entry_buckets = _bucket_entries(entries)
buckets: dict[str, int] = Counter(
Expand All @@ -1223,6 +1366,8 @@ def build_summary_contract(
}
if artifact_downloads is not None:
contract["artifact_downloads"] = artifact_downloads
if artifact_selection is not None:
contract["artifact_selection"] = artifact_selection
if timestamps:
contract["range"] = {
"earliest": min(timestamps).isoformat().replace("+00:00", "Z"),
Expand All @@ -1241,6 +1386,10 @@ def main() -> int:
os.environ.get("METRICS_ARTIFACT_DOWNLOAD_MANIFEST_JSON", _DEFAULT_DOWNLOAD_MANIFEST_PATH)
)
artifact_downloads = _read_artifact_download_contract(download_manifest_path)
artifact_selection_path = Path(
os.environ.get("METRICS_ARTIFACT_SELECTION_JSON", _DEFAULT_ARTIFACT_SELECTION_PATH)
)
artifact_selection = _read_artifact_selection_contract(artifact_selection_path)

files = _gather_metrics_files(metrics_paths, metrics_dir)
if not files:
Expand All @@ -1249,7 +1398,7 @@ def main() -> int:
output_json_path.parent.mkdir(parents=True, exist_ok=True)
output_json_path.write_text(
json.dumps(
build_summary_contract([], [], artifact_downloads),
build_summary_contract([], [], artifact_downloads, artifact_selection),
indent=2,
sort_keys=True,
)
Expand All @@ -1260,8 +1409,18 @@ def main() -> int:
return 0

entries, parse_error_details = _read_ndjson(files)
summary = build_summary(entries, _parse_error_count(parse_error_details), parse_error_details)
summary_contract = build_summary_contract(entries, parse_error_details, artifact_downloads)
summary = build_summary(
entries,
_parse_error_count(parse_error_details),
parse_error_details,
artifact_selection,
)
summary_contract = build_summary_contract(
entries,
parse_error_details,
artifact_downloads,
artifact_selection,
)

output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(summary, encoding="utf-8")
Expand Down
Loading
Loading