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
70 changes: 50 additions & 20 deletions actions/setup/js/check_daily_aic_workflow_guardrail.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
const { createRateLimitAwareGithub } = require("./github_rate_limit_logger.cjs");
const { scanDailyAIC } = require("./daily_aic_scan.cjs");
const { API_READ_OPERATIONS, createAPIBudget, retryNotBefore, safeResponseHeaders, isQuotaError, getAPIReadFailure } = require("./daily_aic_api_budget.cjs");
const { loadBillableJobs, allBillableJobsSkipped, sumCoveredComponents } = require("./daily_aic_component_coverage.cjs");
const { loadBillableJobs, allBillableJobsNotStarted, allBillableJobsSkipped, sumCoveredComponents } = require("./daily_aic_component_coverage.cjs");
const { readLedgerEntries } = require("./daily_aic_repo_memory_ledger.cjs");

const PRIMARY_GUARDRAIL_ARTIFACT_NAMES = ["usage"];
Expand Down Expand Up @@ -300,24 +300,44 @@ async function inspectLegacyAgentArtifact(artifactClient, artifacts, downloadRoo
*/
async function getRunAIC(artifactClient, runId, token, owner, repo, run, inspection) {
const { components, priorJobs } = run ? await loadBillableJobs(inspection, owner, repo, run) : { components: null, priorJobs: null };
if (components && allBillableJobsSkipped(components)) {
if (components && allBillableJobsNotStarted(components)) {
inspection.accountingReason = "confirmed_zero";
for (const [component, job] of components) {
core.info(
`[daily-workflow-aic] Computed component AIC: ${JSON.stringify({
runId,
component,
jobId: job.id,
runAttempt: job.run_attempt,
conclusion: job.conclusion,
aic: 0,
reason: job.conclusion === "skipped" ? "job_skipped" : "runner_not_assigned",
})}`
);
}
logDailyGuardrail("Computed run AIC", {
runId,
aic: 0,
reason: components.size === 0 ? "no_billable_jobs" : "all_billable_jobs_skipped",
reason: components.size === 0 ? "no_billable_jobs" : allBillableJobsSkipped(components) ? "all_billable_jobs_skipped" : "all_billable_jobs_not_started",
});
return 0;
}
const { artifacts } = await artifactClient.listArtifacts({
latest: true,
findBy: {
token,
workflowRunId: runId,
repositoryOwner: owner,
repositoryName: repo,
},
});
const artifactSummaries = artifacts.map(item => ({ id: item?.id ?? null, name: item?.name || "" }));
let artifacts;
try {
({ artifacts } = await artifactClient.listArtifacts({
latest: true,
findBy: {
token,
workflowRunId: runId,
repositoryOwner: owner,
repositoryName: repo,
},
}));
} catch (error) {
logDailyGuardrail("Artifact discovery failed", { runId, operation: "listArtifacts", error: getErrorMessage(error), hint: "Check Actions read permissions, artifact retention, and GitHub API availability." });
throw error;
}
const artifactSummaries = artifacts.map(item => ({ id: item?.id ?? null, name: item?.name || "", expired: !!item?.expired }));
logDailyGuardrail("Listed workflow artifacts", {
runId,
artifactCount: artifacts.length,
Expand All @@ -327,7 +347,7 @@ async function getRunAIC(artifactClient, runId, token, owner, repo, run, inspect
const artifact = artifacts.find(item => item?.name && matchesGuardrailArtifactName(item.name));
if (!artifact) {
if (run) {
throw new Error(`No usage artifact proves AIC for completed run ${runId}`);
throw new Error(`No usage artifact proves AIC for completed run ${runId}; check artifact upload and retention`);
}
logDailyGuardrail("No matching guardrail artifact found", {
runId,
Expand All @@ -346,7 +366,7 @@ async function getRunAIC(artifactClient, runId, token, owner, repo, run, inspect
return 0;
}
if (run && (artifact.expired || !artifact.createdAt || !Number.isFinite(artifact.createdAt.getTime()))) {
throw new Error(`Usage artifact does not cover the completed attempt for run ${runId}`);
throw new Error(`Usage artifact ${artifact.id} does not cover the completed attempt for run ${runId} (expired: ${!!artifact.expired}); check artifact retention and attempt timestamps`);
}

logDailyGuardrail("Selected guardrail artifact", {
Expand Down Expand Up @@ -392,6 +412,9 @@ async function getRunAIC(artifactClient, runId, token, owner, repo, run, inspect
reason: components ? "covered_components" : "usage_files",
});
return aic;
} catch (error) {
logDailyGuardrail("Artifact accounting failed", { runId, artifactId: artifact.id, artifactName: artifact.name, error: getErrorMessage(error), hint: "Check Actions read permissions, artifact retention, and accounting file coverage." });
throw error;
} finally {
fs.rmSync(downloadRoot, { recursive: true, force: true });
}
Expand Down Expand Up @@ -565,14 +588,15 @@ async function listCompletedWorkflowRunsPage(githubClient, params, budget = crea
* @param {string} workflowName
* @param {string} actorLogin
* @param {number} threshold
* @param {Array<{id:number, html_url:string, created_at:string, conclusion:string, aic:number, source?:string}>} countedRuns
* @param {Array<{id:number, html_url:string, created_at:string, conclusion:string, aic:number, source?:string, accountingReason?:string}>} countedRuns
* @param {{remaining:number,limit:number,used:number,reset:string} | null} rateLimit
* @param {{candidateRunsCount:number,inspectedRunsCount:number,truncatedByRateLimit:boolean}} meta
* @returns {string}
*/
function renderDailyAICSummary(workflowName, actorLogin, threshold, countedRuns, rateLimit, meta) {
const stats = calculateDailyAICStats(countedRuns);
const estimatedAIC = countedRuns.reduce((sum, run) => sum + (run.source === "estimated" ? run.aic : 0), 0);
const confirmedZeroRuns = countedRuns.filter(run => run.accountingReason === "confirmed_zero").length;
const remainingBudget = Math.max(0, threshold - stats.total);
const usagePercent = threshold > 0 ? ((stats.total / threshold) * 100).toFixed(2) : "0.00";
const runRows =
Expand All @@ -582,7 +606,7 @@ function renderDailyAICSummary(workflowName, actorLogin, threshold, countedRuns,
.sort((a, b) => Date.parse(b.created_at || "") - Date.parse(a.created_at || ""))
.map(
run =>
`| [#${run.id}](${run.html_url || ""}) | ${escapeMarkdownCell(run.created_at || "")} | ${escapeMarkdownCell(run.conclusion || "unknown")} | ${formatAICCredits(run.aic)} | ${run.source === "estimated" ? "Estimated" : "Recorded"} |`
`| [#${run.id}](${run.html_url || ""}) | ${escapeMarkdownCell(run.created_at || "")} | ${escapeMarkdownCell(run.conclusion || "unknown")} | ${formatAICCredits(run.aic) || "0"} | ${run.accountingReason === "confirmed_zero" ? "Confirmed zero (no AI jobs ran)" : run.source === "estimated" ? "Estimated" : "Recorded"} |`
)
.join("\n")
: "| _none_ | — | — | 0 | — |";
Expand All @@ -591,7 +615,7 @@ function renderDailyAICSummary(workflowName, actorLogin, threshold, countedRuns,
const totalAICFormatted = formatAICCredits(stats.total) || "0";
const avgAICFormatted = noRunData ? "—" : formatAICCredits(stats.average) || "0";
const stddevAICFormatted = noRunData ? "—" : formatAICCredits(stats.stddev) || "0";
const minMaxAICFormatted = noRunData ? "— / —" : `${formatAICCredits(stats.min)} / ${formatAICCredits(stats.max)}`;
const minMaxAICFormatted = noRunData ? "— / —" : `${formatAICCredits(stats.min) || "0"} / ${formatAICCredits(stats.max) || "0"}`;

const noteLines = [];
if (meta.truncatedByRateLimit && rateLimit) {
Expand All @@ -609,6 +633,7 @@ function renderDailyAICSummary(workflowName, actorLogin, threshold, countedRuns,
"| --- | ---: |",
`| 24h total AIC | ${totalAICFormatted} |`,
`| Recorded AIC | ${formatAICCredits(stats.total - estimatedAIC) || "0"} |`,
`| Confirmed-zero runs (no AI jobs ran) | ${confirmedZeroRuns} |`,
`| Estimated AIC (unresolved accounting) | ${formatAICCredits(estimatedAIC) || "0"} |`,
`| Threshold | ${formatAICCredits(threshold)} |`,
`| Threshold used | ${usagePercent}% |`,
Expand All @@ -624,6 +649,8 @@ function renderDailyAICSummary(workflowName, actorLogin, threshold, countedRuns,
"| Run | Created | Conclusion | AIC | Source |",
"| --- | --- | --- | ---: | --- |",
runRows,
"",
"Guardrail accounting is not provider billing; reconcile billing with the provider.",
...(estimatedAIC > 0
? [
"",
Expand All @@ -638,7 +665,7 @@ function renderDailyAICSummary(workflowName, actorLogin, threshold, countedRuns,
* @param {string} workflowName
* @param {string} actorLogin
* @param {number} threshold
* @param {Array<{id:number, html_url:string, created_at:string, conclusion:string, aic:number, source?:string}>} countedRuns
* @param {Array<{id:number, html_url:string, created_at:string, conclusion:string, aic:number, source?:string, accountingReason?:string}>} countedRuns
* @param {{remaining:number,limit:number,used:number,reset:string} | null} rateLimit
* @param {{candidateRunsCount:number,inspectedRunsCount:number,truncatedByRateLimit:boolean}} meta
* @returns {Promise<void>}
Expand Down Expand Up @@ -762,6 +789,7 @@ async function main(options = {}) {
});
const totalAIC = calculateDailyAICStats(countedRuns).total;
const estimatedAIC = countedRuns.reduce((sum, run) => sum + (run.source === "estimated" ? run.aic : 0), 0);
const confirmedZeroRuns = countedRuns.filter(run => run.accountingReason === "confirmed_zero").length;
actorLogin = process.env.GITHUB_TRIGGERING_ACTOR || current.triggering_actor?.login || current.actor?.login || process.env.GITHUB_ACTOR || "";
const rateLimit = budget.snapshot();

Expand Down Expand Up @@ -806,7 +834,9 @@ async function main(options = {}) {
// will skip the agent, and the conclusion job will handle reporting via the
// daily_ai_credits_exceeded flag. Failing the activation job here causes the overall
// workflow to fail even though hitting the daily limit is an expected, graceful outcome.
core.info(`Daily workflow AIC guardrail exceeded for ${workflowName}: ${totalAIC}/${threshold}.`);
core.info(
`Daily workflow AIC guardrail exceeded for ${workflowName}: ${totalAIC}/${threshold} (measured AIC: ${totalAIC - estimatedAIC}, confirmed-zero runs: ${confirmedZeroRuns}, assumed AIC: ${estimatedAIC}). Guardrail accounting is not provider billing.`
);
} catch (error) {
const status = isStructuralGuardrailError(error) ? "structural_error" : "transient_error";
const readFailure = getAPIReadFailure(error);
Expand Down
7 changes: 6 additions & 1 deletion actions/setup/js/check_daily_aic_workflow_guardrail.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -305,14 +305,19 @@ describe("check_daily_aic_workflow_guardrail", () => {
[
{ id: 1, html_url: "https://example.test/1", created_at: "2026-05-31T10:00:00Z", conclusion: "success", aic: 809.28, source: "recorded" },
{ id: 2, html_url: "https://example.test/2", created_at: "2026-05-31T11:00:00Z", conclusion: "cancelled", aic: 5000, source: "estimated" },
{ id: 3, html_url: "https://example.test/3", created_at: "2026-05-31T12:00:00Z", conclusion: "cancelled", aic: 0, source: "recorded", accountingReason: "confirmed_zero" },
],
null,
{ candidateRunsCount: 2, inspectedRunsCount: 2, truncatedByRateLimit: false }
{ candidateRunsCount: 3, inspectedRunsCount: 3, truncatedByRateLimit: false }
);
expect(markdown).toContain("| Recorded AIC | 810 |");
expect(markdown).toContain("| Estimated AIC (unresolved accounting) | 5K |");
expect(markdown).toContain("| [#2](https://example.test/2) | 2026-05-31T11:00:00Z | cancelled | 5K | Estimated |");
expect(markdown).toContain("Subsequent scans retry unresolved accounting");
expect(markdown).toContain("| Confirmed-zero runs (no AI jobs ran) | 1 |");
expect(markdown).toContain("Confirmed zero (no AI jobs ran)");
expect(markdown).toContain("| cancelled | 0 | Confirmed zero (no AI jobs ran) |");
expect(markdown).toContain("not provider billing");
});

it("renders a daily AI Credits details summary with stats and prior runs", () => {
Expand Down
4 changes: 2 additions & 2 deletions actions/setup/js/daily_aic_cache_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function readScanCache(content, repository, workflowId, now = Date.now()) {
}
if (
entry?.version !== 2 ||
entry.coverage_version !== 2 ||
entry.coverage_version !== 3 ||
entry.repository !== repository ||
entry.workflow_id !== workflowId ||
!Number.isSafeInteger(entry.run_id) ||
Expand Down Expand Up @@ -72,7 +72,7 @@ function scanCacheEntry(run, aic, repository, workflowId, now = Date.now(), sour
}
return {
version: 2,
coverage_version: 2,
coverage_version: 3,
repository,
workflow_id: workflowId,
run_id: run.id,
Expand Down
10 changes: 8 additions & 2 deletions actions/setup/js/daily_aic_component_coverage.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ async function loadBillableJobs({ github, budget }, owner, repo, run) {
if (!jobsByComponent.has(name)) jobsByComponent.set(name, []);
jobsByComponent.get(name).push(job);
const prior = components.get(name);
if (!prior || (prior.conclusion === "skipped" && job.conclusion !== "skipped") || (job.conclusion !== "skipped" && job.run_attempt > prior.run_attempt) || (prior.conclusion === "skipped" && job.run_attempt > prior.run_attempt)) {
const notStarted = job.conclusion === "skipped" || provesJobExecutionNotStarted(job);
const priorNotStarted = prior && (prior.conclusion === "skipped" || provesJobExecutionNotStarted(prior));
if (!prior || (priorNotStarted && !notStarted) || (notStarted === priorNotStarted && job.run_attempt > prior.run_attempt)) {
components.set(name, job);
}
}
Expand Down Expand Up @@ -85,6 +87,10 @@ async function loadBillableJobs({ github, budget }, owner, repo, run) {
return { components, priorJobs };
}

function allBillableJobsNotStarted(components) {
return [...components.values()].every(job => job.conclusion === "skipped" || provesJobExecutionNotStarted(job));
}

function allBillableJobsSkipped(components) {
return [...components.values()].every(job => job.conclusion === "skipped");
}
Expand Down Expand Up @@ -276,4 +282,4 @@ function sumCoveredComponents(directory, components, artifactCreatedAt, artifact
return total;
}

module.exports = { loadBillableJobs, allBillableJobsSkipped, sumCoveredComponents };
module.exports = { loadBillableJobs, allBillableJobsNotStarted, allBillableJobsSkipped, sumCoveredComponents };
12 changes: 9 additions & 3 deletions actions/setup/js/daily_aic_scan.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,17 @@ async function scanDailyAIC({ github, context, budget, artifactClient, getRunAIC
let aic;
let reason;
let source;
let accountingReason;
if (hit) {
aic = cached.aic;
reason = "scan_cache";
source = "recorded";
accountingReason = cached.accountingReason;
} else {
try {
aic = await getRunAIC(artifactClient, run.id, token, owner, repo, run, { github, budget });
const inspection = { github, budget, accountingReason: undefined };
aic = await getRunAIC(artifactClient, run.id, token, owner, repo, run, inspection);
accountingReason = inspection.accountingReason;
reason = "artifact_accounting";
source = "recorded";
} catch (error) {
Expand All @@ -129,16 +133,18 @@ async function scanDailyAIC({ github, context, budget, artifactClient, getRunAIC
runId: run.id,
runAttempt: run.run_attempt,
assumedAIC: aic,
error: error instanceof Error ? error.message : String(error),
hint: "Check the artifact discovery/download diagnostics above. This is a conservative estimate, not measured usage or provider billing.",
})}`
);
}
}
entries.set(run.id, scanCacheEntry(run, aic, repository, current.workflow_id, now, source));
entries.set(run.id, { ...scanCacheEntry(run, aic, repository, current.workflow_id, now, source), accountingReason });
if (hit) {
cacheHits++;
}
core.info(`[daily-workflow-aic] Computed run AIC: ${JSON.stringify({ runId: run.id, aic, reason })}`);
countedRuns.push({ ...run, aic, source });
countedRuns.push({ ...run, aic, source, accountingReason });
}
} finally {
// This runs even after an API error. Partial snapshots accelerate recovery but
Expand Down
Loading
Loading