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
20 changes: 20 additions & 0 deletions setup/js/Dockerfile.safe-outputs-mcp
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# syntax=docker/dockerfile:1.7

ARG NODE_IMAGE=node:lts-alpine
FROM ${NODE_IMAGE}

ARG NODE_IMAGE
ARG NODE_IMAGE_DIGEST=""
ARG NODE_IMAGE_UPDATED_AT=""
ARG DOCKERFILE_HASH=""

RUN apk add --no-cache git

LABEL org.opencontainers.image.source="https://github.com/github/gh-aw" \
org.opencontainers.image.description="Node LTS Alpine with git for the local safe-outputs MCP runtime" \
org.opencontainers.image.base.name="${NODE_IMAGE}" \
org.opencontainers.image.base.digest="${NODE_IMAGE_DIGEST}" \
io.github.gh-aw.safe-outputs.node.updated-at="${NODE_IMAGE_UPDATED_AT}" \
io.github.gh-aw.safe-outputs.dockerfile-hash="${DOCKERFILE_HASH}"

ENTRYPOINT ["sh", "-lc", "node \"${GITHUB_WORKSPACE:-$PWD}/actions/setup/js/safe_outputs_mcp_server.cjs\""]
6 changes: 4 additions & 2 deletions setup/js/add_labels.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const { attachExecutionState, fetchIssueState, normalizeLabelNames } = require("
const { MAX_LABELS } = require("./constants.cjs");
const { createCountGatedHandler } = require("./handler_scaffold.cjs");
const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs");
const { resolveInvocationContext } = require("./invocation_context_helpers.cjs");

/**
* Main handler factory for add_labels
Expand Down Expand Up @@ -78,15 +79,16 @@ const main = createCountGatedHandler({
// Accept common aliases: issue_number, pr_number, and pull_number are normalised to item_number
const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE });
if (!targetResult.success) return targetResult;
const itemNumber = targetResult.number ?? context.payload?.issue?.number ?? context.payload?.pull_request?.number;
const effectiveContext = resolveInvocationContext(context);
const itemNumber = targetResult.number ?? effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number;

Comment on lines 79 to 84
if (!itemNumber || Number.isNaN(Number(itemNumber))) {
const error = "No issue/PR number available";
core.warning(error);
return { success: false, error };
}

const contextType = context.payload?.pull_request ? "pull request" : "issue";
const contextType = effectiveContext.eventPayload?.pull_request ? "pull request" : "issue";
const requestedLabels = message.labels ?? [];
core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`);

Expand Down
21 changes: 11 additions & 10 deletions setup/js/artifact_client.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -345,32 +345,33 @@ class DefaultArtifactClient {

const { workflowRunBackendId, workflowJobRunBackendId } = getBackendIdsFromRuntimeToken();
const createRequest = {
workflow_run_backend_id: workflowRunBackendId,
workflow_job_run_backend_id: workflowJobRunBackendId,
workflowRunBackendId,
workflowJobRunBackendId,
name: artifactName,
version: 7,
mime_type: { value: contentType },
mimeType: contentType,
};
const expiresAt = formatRetentionTimestamp(options.retentionDays);
if (expiresAt) {
createRequest.expires_at = expiresAt;
createRequest.expiresAt = expiresAt;
}

/** @type {any} */
const createResponse = await twirpRequest("CreateArtifact", createRequest);
if (!createResponse?.ok || !createResponse?.signed_upload_url) {
const signedUploadUrl = createResponse?.signedUploadUrl || createResponse?.signed_upload_url;
if (!createResponse?.ok || !signedUploadUrl) {
throw new Error("CreateArtifact returned an invalid response");
}

const uploadSize = await uploadFileToSignedURL(uploadPath, createResponse.signed_upload_url, contentType);
const uploadSize = await uploadFileToSignedURL(uploadPath, signedUploadUrl, contentType);
const sha256 = await hashFile(uploadPath);

const finalizeRequest = {
workflow_run_backend_id: workflowRunBackendId,
workflow_job_run_backend_id: workflowJobRunBackendId,
workflowRunBackendId,
workflowJobRunBackendId,
name: artifactName,
size: String(uploadSize),
hash: { value: `sha256:${sha256}` },
hash: `sha256:${sha256}`,
};
/** @type {any} */
const finalizeResponse = await twirpRequest("FinalizeArtifact", finalizeRequest);
Expand All @@ -379,7 +380,7 @@ class DefaultArtifactClient {
}

return {
id: Number(finalizeResponse.artifact_id || 0) || undefined,
id: Number(finalizeResponse.artifactId ?? finalizeResponse.artifact_id ?? 0) || undefined,
size: uploadSize,
digest: sha256,
};
Expand Down
130 changes: 122 additions & 8 deletions setup/js/check_daily_aic_workflow_guardrail.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@ const { createRateLimitAwareGithub, fetchAndLogRateLimit } = require("./github_r

const PRIMARY_GUARDRAIL_ARTIFACT_NAMES = ["usage"];
const DAILY_WORKFLOW_WINDOW_MS = 24 * 60 * 60 * 1000;
/** Cache entries older than this threshold (in ms) are skipped when loading. */
const CACHE_RETENTION_MS = 48 * 60 * 60 * 1000;
const MAX_WORKFLOW_RUN_PAGES = 10;
const RATE_LIMIT_RESERVE = 100;
const REQUEST_OVERHEAD_BUDGET = MAX_WORKFLOW_RUN_PAGES + 4;
const ESTIMATED_API_OPERATIONS_PER_RUN = 2;
const INTEGER_FORMATTER = new Intl.NumberFormat("en-US");

/** Path where the per-workflow usage cache is restored by the activation job's cache-restore step. */
const AIC_USAGE_CACHE_FILE_PATH = "/tmp/gh-aw/agentic-workflow-usage-cache.jsonl";

/**
* @returns {Promise<DefaultArtifactClient>}
*/
Expand Down Expand Up @@ -61,6 +66,20 @@ function logDailyGuardrail(message, details) {
* typing a slash command in a comment, and the daily guardrail should not be skipped.
*/
const SLASH_COMMAND_EVENT_TYPES = ["issue_comment", "pull_request_review_comment", "discussion_comment"];
const SLASH_COMMAND_TRIGGERING_EVENTS = ["issues", "issue_comment", "pull_request", "pull_request_review_comment", "discussion", "discussion_comment"];
const LABEL_COMMAND_TRIGGERING_EVENTS = ["issues", "pull_request", "discussion"];

/**
* @param {string | undefined} value
* @returns {boolean}
*/
function envFlagEnabled(value) {
if (typeof value !== "string") {
return false;
}
const normalized = value.trim().toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes";
}

/**
* @returns {boolean}
Expand All @@ -69,24 +88,103 @@ function shouldSkipDailyAICGuardrail() {
const eventName = process.env.GITHUB_EVENT_NAME || "";
const isWorkflowCall = eventName === "workflow_call";
const isRepositoryDispatch = eventName === "repository_dispatch";
const hasSlashCommand = envFlagEnabled(process.env.GH_AW_HAS_SLASH_COMMAND);
const hasLabelCommand = envFlagEnabled(process.env.GH_AW_HAS_LABEL_COMMAND);
const rawContext = (process.env.GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT || "").trim();
const hasDispatchContext = rawContext !== "";
if (!(isWorkflowCall || isRepositoryDispatch || (eventName === "workflow_dispatch" && hasDispatchContext))) {
return false;
if (isWorkflowCall || isRepositoryDispatch) {
return true;
}
if (eventName === "workflow_dispatch" && hasDispatchContext) {
if (eventName === "workflow_dispatch") {
// Manual user-triggered runs intentionally bypass the daily guardrail.
if (!hasDispatchContext) {
return true;
}
// Dispatch-routed slash/label commands intentionally bypass the daily guardrail.
try {
const awContext = JSON.parse(rawContext);
const isLabelCommand = typeof awContext.trigger_label === "string" && awContext.trigger_label.trim() !== "";
const isSlashCommand = SLASH_COMMAND_EVENT_TYPES.includes(awContext.event_type);
if (isLabelCommand || isSlashCommand) {
return false;
return true;
}
} catch {
// Malformed aw_context: fall through and skip as before.
// Malformed aw_context: skip guardrail as a safe fallback for manual dispatch.
}
// Existing behavior: dispatch-routed runs with aw_context bypass the guardrail.
return true;
}
if (hasSlashCommand && SLASH_COMMAND_TRIGGERING_EVENTS.includes(eventName)) {
return true;
}
return true;
if (hasLabelCommand && LABEL_COMMAND_TRIGGERING_EVENTS.includes(eventName)) {
return true;
}
return false;
}

/**
* Loads the per-workflow usage cache from the JSONL file restored by the activation job's
* cache-restore step. Each line is a JSON object `{ run_id: number, aic: number, timestamp?: string }`.
*
* Entries with a `timestamp` older than {@link CACHE_RETENTION_MS} (48 h) are skipped so that
* stale data cannot inflate the daily-AIC total. Entries without a `timestamp` (written by an
* older version of the write script) are kept for backward compatibility.
*
* Returns a `Map<runId, aic>` so that callers can check whether a prior run's AIC is already
* known without downloading the run's artifact from the GitHub API.
*
* @param {string} [filePath]
* @returns {Map<number, number>}
*/
function loadAICUsageCache(filePath) {
const cachePath = filePath || AIC_USAGE_CACHE_FILE_PATH;
/** @type {Map<number, number>} */
const cache = new Map();
try {
if (!fs.existsSync(cachePath)) {
logDailyGuardrail("No usage cache file found; all runs will be resolved via API", { path: cachePath });
return cache;
}
const content = fs.readFileSync(cachePath, "utf8");
const now = Date.now();
const cutoff = now - CACHE_RETENTION_MS;
let loaded = 0;
let skippedStale = 0;
for (const rawLine of content.split("\n")) {
const line = rawLine.trim();
if (!line || !line.startsWith("{")) {
continue;
}
try {
const entry = JSON.parse(line);
// Skip entries that have a timestamp and are older than the retention window.
if (typeof entry?.timestamp === "string") {
const ts = Date.parse(entry.timestamp);
if (Number.isFinite(ts) && ts < cutoff) {
skippedStale++;
continue;
}
}
const runId = Number(entry?.run_id);
const rawAic = entry?.aic;
const aic = typeof rawAic === "number" ? rawAic : NaN;
if (Number.isFinite(runId) && runId > 0 && Number.isFinite(aic) && aic >= 0) {
cache.set(runId, aic);
loaded++;
}
} catch {
// Ignore malformed lines.
}
}
logDailyGuardrail("Loaded usage cache", { path: cachePath, entriesLoaded: loaded, skippedStale });
} catch (err) {
logDailyGuardrail("Failed to load usage cache; proceeding without it", {
path: cachePath,
error: typeof err === "object" && err !== null && "message" in err ? String(err.message) : String(err),
});
}
return cache;
}

/**
Expand Down Expand Up @@ -322,7 +420,7 @@ async function main() {
return;
}
if (shouldSkipDailyAICGuardrail()) {
core.info("Skipping daily workflow AI Credits guardrail for workflow_call, repository_dispatch, or workflow_dispatch with aw_context.");
core.info("Skipping daily workflow AI Credits guardrail for manual or command-driven runs.");
return;
}

Expand Down Expand Up @@ -441,13 +539,28 @@ async function main() {
truncatedByRateLimit,
});

// Load the per-workflow usage cache restored by the activation job's cache-restore step.
// Entries that are already cached skip the artifact download entirely, reducing API usage.
const usageCache = module.exports.loadAICUsageCache();

const artifactClient = await module.exports.getArtifactClient();
let totalAIC = 0;
/** @type {Array<{id:number, html_url:string, created_at:string, conclusion:string, aic:number}>} */
const countedRuns = [];
for (const run of candidateRuns) {
try {
const runAIC = await module.exports.getRunAIC(artifactClient, run.id, token, owner, repo);
let runAIC;
if (usageCache.has(run.id)) {
// Cache hit: use the previously recorded AIC without downloading the artifact.
runAIC = usageCache.get(run.id) ?? 0;
logDailyGuardrail("Cache hit: using cached AIC for run", {
runId: run.id,
cachedAIC: runAIC,
});
} else {
// Cache miss: fetch AIC from the run's usage artifact.
runAIC = await module.exports.getRunAIC(artifactClient, run.id, token, owner, repo);
}
if (runAIC <= 0) {
logDailyGuardrail("Skipping run without AIC usage artifact data", {
runId: run.id,
Expand Down Expand Up @@ -536,6 +649,7 @@ module.exports = {
main,
getArtifactClient,
getRunAIC,
loadAICUsageCache,
shouldSkipDailyAICGuardrail,
matchesGuardrailArtifactName,
findJSONLFiles,
Expand Down
3 changes: 2 additions & 1 deletion setup/js/codex_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -439,10 +439,11 @@ async function main() {
}

const nonRetryableGuard = detectNonRetryableHarnessGuard(result.output);
if (nonRetryableGuard.aiCreditsExceeded || nonRetryableGuard.awfAPIProxyBlockingRequests) {
if (nonRetryableGuard.aiCreditsExceeded || nonRetryableGuard.awfAPIProxyBlockingRequests || nonRetryableGuard.goalAlreadyActive) {
const reasons = [];
if (nonRetryableGuard.aiCreditsExceeded) reasons.push("AI credits budget exceeded");
if (nonRetryableGuard.awfAPIProxyBlockingRequests) reasons.push("AWF API proxy is blocking requests");
if (nonRetryableGuard.goalAlreadyActive) reasons.push("goal is already active for this thread (use update_goal when the current goal is complete)");
log(`attempt ${attempt + 1}: ${reasons.join(" and ")} — not retrying (non-retryable guard condition)`);
break;
}
Expand Down
10 changes: 10 additions & 0 deletions setup/js/copilot_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const {
const { runSafeOutputsCLI, buildMissingToolAlternatives, emitMissingToolPermissionIssue, emitInfrastructureIncomplete, hasNoopInSafeOutputs } = require("./safeoutputs_cli.cjs");
const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, buildMissingToolPermissionIssuePayload } = require("./permission_denied_helpers.cjs");
const { detectNonRetryableHarnessGuard } = require("./harness_retry_guard.cjs");
const { isCAPIQuotaExceededError } = require("./detect_agent_errors.cjs");

// Maximum number of retry attempts after the initial run
const MAX_RETRIES = 3;
Expand Down Expand Up @@ -706,6 +707,7 @@ async function main() {
// - Null-type tool_call 400 errors poison conversation history — always restart fresh and
// permanently disable --continue so the corrupt state is never reloaded.
const isCAPIError = isTransientCAPIError(result.output);
const isQuotaExceeded = isCAPIQuotaExceededError(result.output);
const isMCPPolicy = isMCPPolicyError(result.output);
const isModelNotSupported = isModelNotSupportedError(result.output);
const isAuthErr = isNoAuthInfoError(result.output);
Expand All @@ -718,6 +720,7 @@ async function main() {
`attempt ${attempt + 1} failed:` +
` exitCode=${result.exitCode}` +
` isCAPIError400=${isCAPIError}` +
` isCAPIQuotaExceededError=${isQuotaExceeded}` +
` isMCPPolicyError=${isMCPPolicy}` +
` isModelNotSupportedError=${isModelNotSupported}` +
` isNullTypeToolCallError=${isNullTypeToolCall}` +
Expand Down Expand Up @@ -832,6 +835,12 @@ async function main() {
log(`attempt ${attempt + 1}: scheduled startup interruption detected but retry budget exhausted — no attempts remain`);
}

// The observed quota exhaustion error is not useful to retry with --continue.
if (isQuotaExceeded) {
log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);
break;
}

if (attempt < MAX_RETRIES && result.hasOutput) {
const reason = isCAPIError ? "CAPIError 400 (transient)" : "partial execution";
// --continue is only meaningful in CLI mode; SDK mode always restarts fresh.
Expand Down Expand Up @@ -910,6 +919,7 @@ if (typeof module !== "undefined" && module.exports) {
writeCopilotOutputs,
resolvePromptFileArgs,
parseCopilotSDKServerArgsFromEnv,
isCAPIQuotaExceededError,
};
}

Expand Down
Loading
Loading