Files
onyx/.github/workflows/reusable-agent-task-pr.yml

345 lines
14 KiB
YAML

### Reusable "agent performs a repo task and opens a reviewed PR" workflow.
###
### A caller workflow supplies a task script (backend/scripts/*.py) that
### regenerates repo files using the OpenAI Responses API, plus an optional
### deterministic fallback script. This workflow owns the shared machinery:
### App credentials, checkout, the agent-vs-fallback dance, commit + PR
### mechanics, and self-repair — when the caller's validation workflow fails
### on the task branch, the caller's workflow_run trigger re-invokes this
### workflow with the failing logs so the agent can fix its own output.
###
### Task script contract (stdlib-only is preferred, so bare python3 runs it):
### python3 <script> --write --rationale-file <path>
### [--failure-context-file <path>] [extra args]
### --rationale-file receives markdown appended to the PR body;
### --failure-context-file carries the failed run's logs on repair runs.
###
### Adopting a new task = one ~30-line caller workflow + one script.
name: Agent Task PR (reusable)
on:
workflow_call:
inputs:
task-name:
description: "Short slug for the task (concurrency group, comments)"
required: true
type: string
script:
description: "Task script to run (repo-relative path)"
required: true
type: string
script-args:
description: "Extra args for the task script"
required: false
type: string
default: ""
fallback-script:
description: "Deterministic fallback script when the agent can't run"
required: false
type: string
default: ""
fallback-args:
description: "Args for the fallback script"
required: false
type: string
default: "--write"
files:
description: "Repo paths the task may modify (git add, space-separated)"
required: true
type: string
branch:
description: "Bot-owned branch the PR is pushed to"
required: true
type: string
pr-title:
description: "Title for the generated PR"
required: true
type: string
reviewer:
description: "GitHub username to request review from (empty = none)"
required: false
type: string
default: ""
slack-channel:
description: "Slack channel ID for PR review notifications (empty = no post)"
required: false
type: string
default: ""
slack-mention:
description: "GitHub username to resolve to a Slack mention"
required: false
type: string
default: ""
agent-model:
description: "OPENAI_MODEL override for the agent call (empty = script default)"
required: false
type: string
default: ""
timeout-minutes:
description: "Job timeout; must fit the script's own retry budget"
required: false
type: number
default: 75
max-repair-commits:
description: "Max commits on the branch before repairs stop"
required: false
type: number
default: 5
secrets:
# Minted so PRs/commits come from the app bot, not github-actions[bot].
CHERRY_PICK_APP_PRIVATE_KEY:
description: "GitHub App private key for the PR token"
required: true
# Any task not needing an agent call can omit it; empty → straight to
# the fallback script.
OPENAI_API_KEY:
description: "OpenAI API key for the agent call"
required: false
SLACK_BOT_TOKEN:
description: "Slack bot token for PR review notifications"
required: false
concurrency:
group: agent-task-${{ inputs.task-name }}
cancel-in-progress: false
jobs:
run:
# Scheduled/dispatch runs always run; workflow_run only on a validation
# failure of the task branch IN THIS REPO (a fork's matching branch name
# must never reach the secrets below).
if: >-
github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'failure' &&
github.event.workflow_run.head_repository.full_name == github.repository)
runs-on: ubuntu-latest
# GITHUB_TOKEN only needs actions:read (to fetch the failed run's logs);
# PR writes go through the App token minted below.
permissions:
contents: read
actions: read
timeout-minutes: ${{ inputs.timeout-minutes }}
env:
BRANCH: ${{ inputs.branch }}
FILES: ${{ inputs.files }}
steps:
- name: Mint GitHub App installation token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.CHERRY_PICK_APP_ID }}
private-key: ${{ secrets.CHERRY_PICK_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # ratchet:actions/checkout@v6
with:
persist-credentials: true
# On a validation-failure repair, check out the PR head so the agent
# iterates on its own last attempt instead of starting from main.
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || 'main' }}
token: ${{ steps.app-token.outputs.token }}
- name: Configure git identity as App
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: |
bot_user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)"
git config user.name "${APP_SLUG}[bot]"
git config user.email "${bot_user_id}+${APP_SLUG}[bot]@users.noreply.github.com"
# A workflow_run event can fire for a stale validation run (queued behind
# a newer regeneration, or after the PR merged). Only repair when the
# failed sha is still the head of the still-open task PR, and stop after
# max-repair-commits so a stubborn failure can't loop paid agent calls
# and CI runs forever.
- name: Verify repair target is current
if: github.event_name == 'workflow_run'
id: repair-guard
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
FAILED_SHA: ${{ github.event.workflow_run.head_sha }}
MAX_REPAIR_COMMITS: ${{ inputs.max-repair-commits }}
run: |
proceed=true
# Capture first: a gh failure must abort the step, not read as
# "no open PR" and silently skip the repair.
pr_list="$(gh pr list --state open --head "${BRANCH}" \
--json number,headRefOid \
--jq '.[0] | if . == null then "" else "\(.number) \(.headRefOid)" end')"
read -r pr_number pr_head <<< "${pr_list}"
if [ -z "${pr_number}" ]; then
echo "::notice::No open PR for ${BRANCH} (merged or closed). Skipping repair."
proceed=false
elif [ "${pr_head}" != "${FAILED_SHA}" ]; then
echo "::notice::Failed run's sha ${FAILED_SHA} is not the PR head (${pr_head}); the run is stale. Skipping."
proceed=false
else
commit_count="$(gh pr view "${pr_number}" --json commits --jq '.commits | length')"
if [ "${commit_count}" -ge "${MAX_REPAIR_COMMITS}" ]; then
gh pr comment "${pr_number}" --body \
"Validation failed again, but this PR reached the automated repair limit (${MAX_REPAIR_COMMITS} commits). Stopping the repair loop — the task output needs a manual fix." \
|| echo "::warning::Could not post repair-limit comment."
echo "::notice::Repair limit reached for PR #${pr_number}. Skipping."
proceed=false
fi
fi
echo "proceed=${proceed}" >> "${GITHUB_OUTPUT}"
- name: Collect failing validation context
if: >-
github.event_name == 'workflow_run' &&
steps.repair-guard.outputs.proceed == 'true'
env:
# GITHUB_TOKEN has actions:read; the App token was minted without it.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FAILED_RUN_ID: ${{ github.event.workflow_run.id }}
run: |
# The agent gets the tail of the failing logs plus which jobs died.
gh api "repos/${{ github.repository }}/actions/runs/${FAILED_RUN_ID}/jobs?per_page=100" \
--jq '.jobs[] | select(.conclusion == "failure") | .name' \
> /tmp/failed_jobs.txt || true
{
echo "Failed jobs:"
cat /tmp/failed_jobs.txt
echo ""
gh run view "${FAILED_RUN_ID}" --repo "${{ github.repository }}" --log-failed || true
} > /tmp/failure_context.txt
wc -c /tmp/failure_context.txt
- name: Run task
if: >-
github.event_name != 'workflow_run' ||
steps.repair-guard.outputs.proceed == 'true'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_MODEL: ${{ inputs.agent-model }}
SCRIPT: ${{ inputs.script }}
SCRIPT_ARGS: ${{ inputs.script-args }}
FALLBACK_SCRIPT: ${{ inputs.fallback-script }}
FALLBACK_ARGS: ${{ inputs.fallback-args }}
# Task scripts take --write/--rationale-file/--failure-context-file
# (see header). The agent path is preferred; the deterministic
# fallback keeps the task landing when there's no key or the agent
# call fails.
run: |
set -o pipefail
args="--write --rationale-file /tmp/agent_rationale.md ${SCRIPT_ARGS}"
if [ -s /tmp/failure_context.txt ]; then
args="${args} --failure-context-file /tmp/failure_context.txt"
fi
if [ -n "${OPENAI_API_KEY:-}" ] && python3 "${SCRIPT}" ${args}; then
echo "Agent task succeeded."
elif [ -n "${FALLBACK_SCRIPT}" ]; then
echo "::warning::Agent path unavailable or failed; running deterministic fallback."
python3 "${FALLBACK_SCRIPT}" ${FALLBACK_ARGS}
else
echo "::error::Agent task failed and no fallback is configured."
exit 1
fi
- name: Open or update PR
id: pr
if: >-
github.event_name != 'workflow_run' ||
steps.repair-guard.outputs.proceed == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
FAILED_SHA: ${{ github.event.workflow_run.head_sha }}
PR_TITLE: ${{ inputs.pr-title }}
REVIEWER: ${{ inputs.reviewer }}
TASK_NAME: ${{ inputs.task-name }}
# Fixed branch + force-push: the content changes run over run, so an
# already-open PR should be refreshed, not left stale.
run: |
if git diff --quiet -- ${FILES}; then
echo "No changes to ${FILES}. Nothing to do."
exit 0
fi
git switch -C "${BRANCH}"
git add ${FILES}
git commit -m "${PR_TITLE}"
# Repair runs push on top of the sha the guard verified; refuse to
# clobber the branch if someone pushed in between.
if [ "${{ github.event_name }}" = "workflow_run" ]; then
git push --force-with-lease="${BRANCH}:${FAILED_SHA}" origin "${BRANCH}"
else
git push --force origin "${BRANCH}"
fi
body="Generated by the [${TASK_NAME} workflow run](${RUN_URL})."
if [ -s /tmp/agent_rationale.md ]; then
body="$(printf '%s\n\n%s\n' "${body}" "$(cat /tmp/agent_rationale.md)")"
fi
pr_number="$(gh pr list --state open --head "${BRANCH}" --json number --jq '.[0].number')"
if [ -n "${pr_number}" ]; then
echo "Refreshed existing PR #${pr_number} via force-push."
else
gh pr create \
--base main \
--head "${BRANCH}" \
--title "${PR_TITLE}" \
--body "${body}"
pr_number="$(gh pr list --state open --head "${BRANCH}" --json number --jq '.[0].number')"
fi
# Body refresh keeps the rationale/current run link current after a
# repair push; reviewer re-request makes sure the owner is notified.
edit_args=(--body "${body}")
if [ -n "${REVIEWER}" ]; then
edit_args+=(--add-reviewer "${REVIEWER}")
fi
gh pr edit "${pr_number}" "${edit_args[@]}" \
|| echo "::warning::Could not update PR #${pr_number}"
echo "pr_number=${pr_number}" >> "${GITHUB_OUTPUT}"
echo "pr_url=${REPO_URL}/pull/${pr_number}" >> "${GITHUB_OUTPUT}"
# Ping the reviewer in Slack when the task produced or refreshed a PR.
# Non-fatal: the PR is the artifact; a Slack hiccup must not fail the run.
#
# The action loads from a checkout of this workflow's own sha, not the
# working tree: a workflow_run repair checks out the task PR's head,
# which can predate the action.
- name: Checkout Slack actions
id: slack-actions
if: >-
inputs.slack-channel != '' &&
(github.event_name != 'workflow_run' ||
steps.repair-guard.outputs.proceed == 'true') &&
steps.pr.outputs.pr_number != ''
continue-on-error: true # notification-only; a checkout outage must not fail the run
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # ratchet:actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.sha }}
path: .ci-actions
sparse-checkout: .github/actions
- name: Post PR to Slack for review
if: >-
inputs.slack-channel != '' &&
(github.event_name != 'workflow_run' ||
steps.repair-guard.outputs.proceed == 'true') &&
steps.pr.outputs.pr_number != '' &&
steps.slack-actions.outcome == 'success'
continue-on-error: true
uses: ./.ci-actions/.github/actions/slack-post-message
with:
bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
channel: ${{ inputs.slack-channel }}
mention: ${{ inputs.slack-mention }}
text: |
${{ inputs.task-name }} PR ready for review {mention}
*PR:* <${{ steps.pr.outputs.pr_url }}|#${{ steps.pr.outputs.pr_number }}>
*Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|workflow run>