mirror of
https://gh-proxy.org/https://github.com/klinsmaya/portal-template-default.git
synced 2026-11-08 00:57:57 +08:00
2292 lines
118 KiB
YAML
2292 lines
118 KiB
YAML
name: Code Agent NocoBase Task
|
|
run-name: "Factory issue #${{ github.event.issue.number || github.event.client_payload.issue_number || inputs.issue_number }} build ${{ github.event.client_payload.build_comment_id || '0' }} from ${{ github.event.client_payload.previous_run_id || inputs.recovery_run_id || '0' }}${{ inputs.external_run_id && format(' request {0}', inputs.external_run_id) || '' }}"
|
|
|
|
on:
|
|
issues:
|
|
types: [opened, reopened]
|
|
repository_dispatch:
|
|
types: [code-agent-task, code-agent-continue]
|
|
workflow_dispatch:
|
|
inputs:
|
|
issue_number:
|
|
description: Issue number to run (closed external submissions are supported)
|
|
required: true
|
|
type: number
|
|
# factory:external-closed-v1: explicit external runs use archived Issues.
|
|
external_run_id:
|
|
description: External system request identity for dispatch reconciliation
|
|
required: false
|
|
type: string
|
|
failure_smoke:
|
|
description: Maintainer-only controlled failure using the pinned counter fixture (no model or QA)
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
recovery_run_id:
|
|
description: Recover a failed run's saved work (blank starts a normal build)
|
|
required: false
|
|
type: string
|
|
recovery_base_sha:
|
|
description: Original application base SHA (required only for legacy checkpoints)
|
|
required: false
|
|
type: string
|
|
|
|
env:
|
|
# Only the public Agent settings agent-configuration.mjs reads. Never pass
|
|
# the whole vars context: each step prints its env in the public log, so
|
|
# every repository variable would reach every step, model runs included.
|
|
FACTORY_AGENT_CONFIG_JSON: >-
|
|
{
|
|
"CODE_AGENT_ENGINE": ${{ toJSON(vars.CODE_AGENT_ENGINE) }},
|
|
"CODE_AGENT_VERSION": ${{ toJSON(vars.CODE_AGENT_VERSION) }},
|
|
"PI_VERSION": ${{ toJSON(vars.PI_VERSION) }},
|
|
"CODEBUDDY_VERSION": ${{ toJSON(vars.CODEBUDDY_VERSION) }},
|
|
"CLAUDE_CODE_VERSION": ${{ toJSON(vars.CLAUDE_CODE_VERSION) }},
|
|
"CODEX_VERSION": ${{ toJSON(vars.CODEX_VERSION) }},
|
|
"OPENCODE_VERSION": ${{ toJSON(vars.OPENCODE_VERSION) }},
|
|
"CODE_AGENT_MODEL": ${{ toJSON(vars.CODE_AGENT_MODEL) }},
|
|
"PI_MODEL": ${{ toJSON(vars.PI_MODEL) }},
|
|
"CODEBUDDY_MODEL": ${{ toJSON(vars.CODEBUDDY_MODEL) }},
|
|
"CLAUDE_CODE_MODEL": ${{ toJSON(vars.CLAUDE_CODE_MODEL) }},
|
|
"CODEX_MODEL": ${{ toJSON(vars.CODEX_MODEL) }},
|
|
"OPENCODE_MODEL": ${{ toJSON(vars.OPENCODE_MODEL) }},
|
|
"CODE_AGENT_API_TYPE": ${{ toJSON(vars.CODE_AGENT_API_TYPE) }},
|
|
"PI_API_TYPE": ${{ toJSON(vars.PI_API_TYPE) }},
|
|
"CODE_AGENT_THINKING": ${{ toJSON(vars.CODE_AGENT_THINKING) }},
|
|
"PI_THINKING": ${{ toJSON(vars.PI_THINKING) }},
|
|
"CODEBUDDY_THINKING": ${{ toJSON(vars.CODEBUDDY_THINKING) }},
|
|
"CODE_AGENT_AUTH_HEADER": ${{ toJSON(vars.CODE_AGENT_AUTH_HEADER) }},
|
|
"PI_AUTH_HEADER": ${{ toJSON(vars.PI_AUTH_HEADER) }},
|
|
"CODE_AGENT_SUPPORTS_DEVELOPER_ROLE": ${{ toJSON(vars.CODE_AGENT_SUPPORTS_DEVELOPER_ROLE) }},
|
|
"PI_SUPPORTS_DEVELOPER_ROLE": ${{ toJSON(vars.PI_SUPPORTS_DEVELOPER_ROLE) }},
|
|
"CODE_AGENT_SUPPORTS_REASONING_EFFORT": ${{ toJSON(vars.CODE_AGENT_SUPPORTS_REASONING_EFFORT) }},
|
|
"PI_SUPPORTS_REASONING_EFFORT": ${{ toJSON(vars.PI_SUPPORTS_REASONING_EFFORT) }},
|
|
"CODE_AGENT_MODEL_REASONING": ${{ toJSON(vars.CODE_AGENT_MODEL_REASONING) }},
|
|
"CODE_AGENT_INVOCATION_TIMEOUT_SECONDS": ${{ toJSON(vars.CODE_AGENT_INVOCATION_TIMEOUT_SECONDS) }},
|
|
"PI_INVOCATION_TIMEOUT_SECONDS": ${{ toJSON(vars.PI_INVOCATION_TIMEOUT_SECONDS) }},
|
|
"CODE_AGENT_IDLE_TIMEOUT_SECONDS": ${{ toJSON(vars.CODE_AGENT_IDLE_TIMEOUT_SECONDS) }},
|
|
"CLAUDE_CODE_EFFORT": ${{ toJSON(vars.CLAUDE_CODE_EFFORT) }},
|
|
"CLAUDE_CODE_QA_EFFORT": ${{ toJSON(vars.CLAUDE_CODE_QA_EFFORT) }},
|
|
"CODEX_REASONING_EFFORT": ${{ toJSON(vars.CODEX_REASONING_EFFORT) }},
|
|
"CODEX_QA_REASONING_EFFORT": ${{ toJSON(vars.CODEX_QA_REASONING_EFFORT) }},
|
|
"OPENCODE_VARIANT": ${{ toJSON(vars.OPENCODE_VARIANT) }},
|
|
"OPENCODE_QA_VARIANT": ${{ toJSON(vars.OPENCODE_QA_VARIANT) }},
|
|
"CODEBUDDY_BASE_URL": ${{ toJSON(vars.CODEBUDDY_BASE_URL) }},
|
|
"CODEBUDDY_INTERNET_ENVIRONMENT": ${{ toJSON(vars.CODEBUDDY_INTERNET_ENVIRONMENT) }},
|
|
"ANTHROPIC_BASE_URL": ${{ toJSON(vars.ANTHROPIC_BASE_URL) }},
|
|
"CODEX_BASE_URL": ${{ toJSON(vars.CODEX_BASE_URL) }},
|
|
"OPENCODE_BASE_URL": ${{ toJSON(vars.OPENCODE_BASE_URL) }},
|
|
"CODE_AGENT_PROVIDER_ID": ${{ toJSON(vars.CODE_AGENT_PROVIDER_ID) }},
|
|
"FACTORY_QA_THINKING": ${{ toJSON(vars.FACTORY_QA_THINKING) }},
|
|
"FACTORY_REVIEW_THINKING": ${{ toJSON(vars.FACTORY_REVIEW_THINKING) }},
|
|
"FACTORY_BUILD_REVIEW": ${{ toJSON(vars.FACTORY_BUILD_REVIEW) }},
|
|
"FACTORY_BUILD_REVIEW_TIMEOUT_SECONDS": ${{ toJSON(vars.FACTORY_BUILD_REVIEW_TIMEOUT_SECONDS) }},
|
|
"FACTORY_BUILD_REVIEW_IDLE_TIMEOUT_SECONDS": ${{ toJSON(vars.FACTORY_BUILD_REVIEW_IDLE_TIMEOUT_SECONDS) }},
|
|
"AGENT_BROWSER_VERSION": ${{ toJSON(vars.AGENT_BROWSER_VERSION) }}
|
|
}
|
|
|
|
permissions: {}
|
|
|
|
# Retries and cross-run handoffs for one Issue must stay serialized, but
|
|
# independent application tasks should not block each other.
|
|
concurrency:
|
|
group: code-agent-task-${{ github.event.issue.number || github.event.client_payload.issue_number || inputs.issue_number || github.run_id }}
|
|
queue: max
|
|
|
|
jobs:
|
|
prepare:
|
|
# External task managers save an Issue snapshot, then explicitly dispatch.
|
|
# Read the event's labels so a later Issue update cannot create a second run.
|
|
# The repository is public: an Issue only starts a model run when its author
|
|
# has repository access. Maintainers can still dispatch any Issue manually.
|
|
# Preset definitions and maintenance Issues (factory:preset, factory:manual)
|
|
# are never built from an Issue event: prepare-task only reported them as
|
|
# `preset`/`manual` after two checkouts, so their events start no job.
|
|
if: >-
|
|
github.event_name != 'issues' ||
|
|
(!contains(github.event.issue.labels.*.name, 'factory:external') &&
|
|
!contains(github.event.issue.labels.*.name, 'factory:preset') &&
|
|
!contains(github.event.issue.labels.*.name, 'factory:manual') &&
|
|
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association))
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 10
|
|
permissions:
|
|
actions: read
|
|
contents: write
|
|
issues: write
|
|
pull-requests: read
|
|
outputs:
|
|
control_sha: ${{ steps.baseline.outputs.sha }}
|
|
status: ${{ steps.prepare.outputs.status }}
|
|
build_comment_id: ${{ steps.prepare.outputs.build_comment_id }}
|
|
comment_kind: ${{ steps.prepare.outputs.comment_kind }}
|
|
issue_number: ${{ steps.prepare.outputs.issue_number }}
|
|
work_branch: ${{ steps.prepare.outputs.work_branch }}
|
|
base_ref: ${{ steps.base.outputs.ref }}
|
|
base_sha: ${{ steps.base.outputs.sha }}
|
|
# Set when a recovery replaces the commit its failed run published.
|
|
expected_work_sha: ${{ steps.base.outputs.expected_work_sha }}
|
|
rerun_rejected: ${{ steps.rerun_guard.outputs.rerun_rejected }}
|
|
steps:
|
|
# "Re-run all jobs" would record attempt N as the task's run, upload a
|
|
# second factory-task-N (which a recovery downloads instead of the one
|
|
# matching the checkpoint), relabel the Issue and post a new status. Once
|
|
# an earlier attempt ran the build (its agent job started), stop before
|
|
# any of that, so the run stays recoverable as the failure notice says.
|
|
# A question round (whose agent job is skipped) re-runs this way, and so
|
|
# does a build whose earlier attempts never started the agent job (for
|
|
# example prepare failed on a transient error): the agent job's own guard
|
|
# accepts that attempt as the task's first execution.
|
|
# rerun_rejected lets dispatch-reports mark the reports as handled, so the
|
|
# workflow_run copies publish no empty usage or history for this attempt.
|
|
- name: Reject a GitHub Re-run of a prepared task
|
|
id: rerun_guard
|
|
if: github.run_attempt > 1
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
# Fails closed, also toward the reports: rerun_rejected is written
|
|
# once, on exit, and stays true unless every earlier attempt's jobs
|
|
# were read and none started the build. An unreadable job list stops
|
|
# the re-run (which changes nothing) and still lets dispatch-reports
|
|
# mark its reports handled.
|
|
verdict=true
|
|
trap 'echo "rerun_rejected=$verdict" >> "$GITHUB_OUTPUT"' EXIT
|
|
built=''
|
|
for (( attempt = 1; attempt < GITHUB_RUN_ATTEMPT; attempt++ )); do
|
|
built+="$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$attempt/jobs?per_page=100" \
|
|
--jq '.jobs[] | select(.name == "agent" and .started_at != null and .conclusion != "skipped") | .name')"
|
|
done
|
|
if [[ -n "$built" ]]; then
|
|
echo "::error::An earlier attempt of this run already ran the build. GitHub Re-run cannot continue it; use an explicit recovery Run (workflow_dispatch with recovery_run_id=${GITHUB_RUN_ID}) or start a new build."
|
|
exit 1
|
|
fi
|
|
verdict=false
|
|
|
|
# repository_dispatch loads the entry workflow from the default branch.
|
|
# Only this small handoff protocol follows that revision; task code is pinned.
|
|
- name: Check out handoff bootstrap
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ github.sha }}
|
|
path: bootstrap
|
|
sparse-checkout-cone-mode: false
|
|
sparse-checkout: |
|
|
/.github/scripts/handoff.mjs
|
|
/.github/scripts/handoff-control.mjs
|
|
/.github/scripts/handoff-recovery.mjs
|
|
/.github/scripts/source-baseline-ref.mjs
|
|
/.github/scripts/pipeline-state.mjs
|
|
/.github/scripts/task-policy.mjs
|
|
/.github/scripts/acceptance-criteria.mjs
|
|
/.github/scripts/review-history.mjs
|
|
/.github/scripts/history-redaction.mjs
|
|
/.github/scripts/evaluation-sample.mjs
|
|
persist-credentials: false
|
|
|
|
# The composite steps below come from this workflow's own commit, like the
|
|
# bootstrap checkout: an in-flight task's pinned control_sha may predate
|
|
# them. The scripts they run still come from control/ or bootstrap/.
|
|
- name: Check out the workflow's composite actions
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ github.sha }}
|
|
path: factory-actions
|
|
sparse-checkout-cone-mode: false
|
|
sparse-checkout: |
|
|
/.github/actions/
|
|
persist-credentials: false
|
|
|
|
# A continuation that fails here has no checkpoint of its own, and its
|
|
# source run concluded success, so one transient artifact error would
|
|
# discard the handed-off work: each download below is retried once.
|
|
- name: Download source task control pin
|
|
id: previous_task_download
|
|
if: (github.event_name == 'repository_dispatch' && github.event.action == 'code-agent-continue') || inputs.recovery_run_id != ''
|
|
uses: ./factory-actions/.github/actions/download-with-retry
|
|
with:
|
|
name: factory-task-${{ inputs.recovery_run_id != '' && inputs.issue_number || github.event.client_payload.issue_number }}
|
|
path: previous-task
|
|
run-id: ${{ inputs.recovery_run_id || github.event.client_payload.previous_run_id }}
|
|
github-token: ${{ github.token }}
|
|
|
|
# New continuations carry a SHA bound to the small source task artifact:
|
|
# do not download the large checkpoint twice per run.
|
|
# This one-time fallback adopts pre-pin tasks.
|
|
- name: Download legacy handoff control metadata
|
|
if: github.event_name == 'repository_dispatch' && github.event.action == 'code-agent-continue' && !github.event.client_payload.control_sha
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
# The checkpoint; older runs uploaded the same files as factory-handoff-N too.
|
|
name: factory-agent-${{ github.event.client_payload.issue_number }}
|
|
path: legacy-handoff
|
|
run-id: ${{ github.event.client_payload.previous_run_id }}
|
|
github-token: ${{ github.token }}
|
|
|
|
- name: Download failed-run checkpoint
|
|
id: recovery_download
|
|
if: inputs.recovery_run_id != ''
|
|
uses: ./factory-actions/.github/actions/download-with-retry
|
|
with:
|
|
# The checkpoint; older runs uploaded the same files as factory-handoff-N too.
|
|
name: factory-agent-${{ inputs.issue_number }}
|
|
path: recovery
|
|
run-id: ${{ inputs.recovery_run_id }}
|
|
github-token: ${{ github.token }}
|
|
|
|
# The commit the failed run's publication pushed to the work branch, if
|
|
# it pushed any. Absent, the work branch counts as moved, which refuses
|
|
# rather than overwrites; so a missing record can only refuse a recovery.
|
|
- name: Download the failed run's publication record
|
|
if: inputs.recovery_run_id != ''
|
|
continue-on-error: true
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: factory-published-${{ inputs.issue_number }}
|
|
path: recovery-published
|
|
run-id: ${{ inputs.recovery_run_id }}
|
|
github-token: ${{ github.token }}
|
|
|
|
- name: Validate and normalize explicit recovery
|
|
id: recovery
|
|
if: inputs.recovery_run_id != ''
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: |
|
|
node bootstrap/.github/scripts/handoff-recovery.mjs normalize \
|
|
--event "$GITHUB_EVENT_PATH" --task previous-task/task-metadata.json \
|
|
--checkpoint recovery --published recovery-published/publication.json --output "$GITHUB_OUTPUT"
|
|
|
|
# An evaluation-batch sample starts on its batch's frozen control plane,
|
|
# proven by bot receipts and the checksummed batch manifest (not by any
|
|
# dispatch field). Ordinary Issues output nothing and keep the default.
|
|
- name: Resolve a frozen evaluation sample control plane
|
|
id: sample
|
|
if: github.event.action != 'code-agent-continue' && inputs.recovery_run_id == ''
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
SAMPLE_ISSUE: ${{ github.event.issue.number || github.event.client_payload.issue_number || inputs.issue_number }}
|
|
run: node bootstrap/.github/scripts/evaluation-sample.mjs pin --issue "$SAMPLE_ISSUE" --output "$GITHUB_OUTPUT"
|
|
|
|
- name: Pin the task chain's control plane
|
|
id: baseline
|
|
env:
|
|
SAMPLE_CONTROL_SHA: ${{ steps.sample.outputs.control_sha }}
|
|
TASK_EVENT: ${{ steps.recovery.outputs.event_path || github.event_path }}
|
|
CHECKPOINT: ${{ inputs.recovery_run_id != '' && 'recovery' || 'legacy-handoff' }}
|
|
run: |
|
|
node bootstrap/.github/scripts/handoff-control.mjs resolve \
|
|
--event "$TASK_EVENT" \
|
|
--current-sha "${SAMPLE_CONTROL_SHA:-$(git -C bootstrap rev-parse HEAD)}" \
|
|
--checkpoint "$CHECKPOINT" \
|
|
--previous-task previous-task/task-metadata.json \
|
|
--output "$GITHUB_OUTPUT"
|
|
|
|
- name: Check out factory control plane
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ steps.baseline.outputs.sha }}
|
|
path: control
|
|
persist-credentials: false
|
|
|
|
- name: Validate explicit failure smoke request
|
|
if: inputs.failure_smoke == true
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: node control/.github/scripts/failure-smoke.mjs validate
|
|
|
|
- name: Resolve Issue and target branch
|
|
id: prepare
|
|
env:
|
|
FACTORY_CONTROL_SHA: ${{ steps.baseline.outputs.sha }}
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
# The status comment of a continuation shows the base it keeps.
|
|
FACTORY_PREVIOUS_TASK: ${{ github.event.action == 'code-agent-continue' && 'previous-task/task-metadata.json' || '' }}
|
|
TASK_EVENT: ${{ steps.recovery.outputs.event_path || github.event_path }}
|
|
run: |
|
|
node control/.github/scripts/prepare-task.mjs \
|
|
--event "$TASK_EVENT" \
|
|
--metadata "$RUNNER_TEMP/task-metadata.json" \
|
|
--output "$GITHUB_OUTPUT"
|
|
|
|
# A recovery keeps the base its failed run recorded. A work branch at the
|
|
# commit that run's publication pushed counts as unmoved, and the recovery
|
|
# replaces that commit (expected_work_sha, the push lease); any other move
|
|
# is refused. See liveRecoveryBase in handoff-recovery.mjs.
|
|
- name: Reject changed recovery input or application base
|
|
id: recovery_base
|
|
if: inputs.recovery_run_id != '' && steps.prepare.outputs.status == 'ready'
|
|
env:
|
|
BASE_REF: ${{ steps.prepare.outputs.base_ref }}
|
|
BASE_SHA: ${{ steps.prepare.outputs.base_sha }}
|
|
run: |
|
|
node bootstrap/.github/scripts/handoff-recovery.mjs check-base \
|
|
--checkpoint recovery --metadata "$RUNNER_TEMP/task-metadata.json" \
|
|
--base-ref "$BASE_REF" --base-sha "$BASE_SHA" --output "$GITHUB_OUTPUT"
|
|
|
|
# A continuation keeps the application base its source run recorded; see
|
|
# continuationBase in handoff-control.mjs. A recovery uses the base the
|
|
# step above validated. Everything else uses prepare's.
|
|
- name: Resolve the application base
|
|
id: base
|
|
if: steps.prepare.outputs.status == 'ready'
|
|
env:
|
|
BASE_REF: ${{ steps.prepare.outputs.base_ref }}
|
|
BASE_SHA: ${{ steps.prepare.outputs.base_sha }}
|
|
CONTINUATION: ${{ github.event_name == 'repository_dispatch' && github.event.action == 'code-agent-continue' }}
|
|
RECOVERY: ${{ inputs.recovery_run_id != '' }}
|
|
RECOVERY_REF: ${{ steps.recovery_base.outputs.ref }}
|
|
RECOVERY_SHA: ${{ steps.recovery_base.outputs.sha }}
|
|
RECOVERY_EXPECTED_WORK_SHA: ${{ steps.recovery_base.outputs.expected_work_sha }}
|
|
run: |
|
|
if [[ "$CONTINUATION" == true ]]; then
|
|
node bootstrap/.github/scripts/handoff-control.mjs base \
|
|
--previous-task previous-task/task-metadata.json \
|
|
--base-ref "$BASE_REF" --base-sha "$BASE_SHA" --output "$GITHUB_OUTPUT"
|
|
elif [[ "$RECOVERY" == true ]]; then
|
|
printf 'ref=%s\nsha=%s\nexpected_work_sha=%s\n' "$RECOVERY_REF" "$RECOVERY_SHA" "$RECOVERY_EXPECTED_WORK_SHA" >> "$GITHUB_OUTPUT"
|
|
else
|
|
printf 'ref=%s\nsha=%s\n' "$BASE_REF" "$BASE_SHA" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
# Only the files normalize wrote: the agent job downloads the failed
|
|
# run's checkpoint itself (it is already stored there) and checks it is
|
|
# the one validated here (handoff-recovery.mjs match), instead of this
|
|
# job uploading a second copy of the whole checkpoint.
|
|
- name: Save normalized recovery checkpoint
|
|
if: inputs.recovery_run_id != '' && steps.prepare.outputs.status == 'ready'
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: factory-recovery-${{ steps.prepare.outputs.issue_number }}
|
|
path: |
|
|
recovery/task-event.json
|
|
recovery/handoff.json
|
|
recovery/recovery.json
|
|
if-no-files-found: error
|
|
retention-days: 14
|
|
|
|
# Use the bootstrap even when an older pinned prepare-task predates this
|
|
# field, so its next continuation can use the small artifact as provenance.
|
|
- name: Record the task chain control pin
|
|
if: steps.prepare.outputs.status == 'ready'
|
|
env:
|
|
FACTORY_CONTROL_SHA: ${{ steps.baseline.outputs.sha }}
|
|
FACTORY_ENTRY_SHA: ${{ github.sha }}
|
|
FACTORY_APPLICATION_BASE_REF: ${{ steps.base.outputs.ref }}
|
|
FACTORY_APPLICATION_BASE_SHA: ${{ steps.base.outputs.sha }}
|
|
run: node bootstrap/.github/scripts/handoff-control.mjs record --metadata "$RUNNER_TEMP/task-metadata.json"
|
|
|
|
- name: Save normalized task metadata
|
|
if: steps.prepare.outputs.status == 'ready'
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: factory-task-${{ steps.prepare.outputs.issue_number }}
|
|
path: ${{ runner.temp }}/task-metadata.json
|
|
if-no-files-found: error
|
|
retention-days: 14
|
|
|
|
# prepare-task.mjs has already set agent:running once status is ready,
|
|
# and report-failure is skipped when this job fails, so any later
|
|
# failure here (a rejected recovery, a failed artifact upload) must
|
|
# clear the label itself. Replies never set it.
|
|
- name: Report a failure after task preparation
|
|
if: failure() && steps.prepare.outputs.status == 'ready' && steps.prepare.outputs.comment_kind != 'reply'
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
ISSUE_NUMBER: ${{ steps.prepare.outputs.issue_number }}
|
|
run: node control/.github/scripts/mark-failure.mjs "$ISSUE_NUMBER"
|
|
|
|
agent:
|
|
needs: prepare
|
|
env:
|
|
FACTORY_CONTROL_SHA: ${{ needs.prepare.outputs.control_sha }}
|
|
# An explicit recovery is continuation 1, as the handoff step counts it
|
|
# below; pipeline-state takes this only as a floor, so it never adds twice.
|
|
FACTORY_TASK_CONTINUATION: ${{ inputs.recovery_run_id != '' && '1' || github.event.client_payload.continuation || '0' }}
|
|
FACTORY_QA_THINKING: ${{ vars.FACTORY_QA_THINKING }}
|
|
FACTORY_BUILD_REVIEW: ${{ vars.FACTORY_BUILD_REVIEW || 'full' }}
|
|
FACTORY_REVIEW_THINKING: ${{ vars.FACTORY_REVIEW_THINKING || 'medium' }}
|
|
FACTORY_BUILD_REVIEW_TIMEOUT_SECONDS: ${{ vars.FACTORY_BUILD_REVIEW_TIMEOUT_SECONDS || '3600' }}
|
|
FACTORY_BUILD_REVIEW_IDLE_TIMEOUT_SECONDS: ${{ vars.FACTORY_BUILD_REVIEW_IDLE_TIMEOUT_SECONDS || '600' }}
|
|
CODE_AGENT_ENGINE: ${{ vars.CODE_AGENT_ENGINE }}
|
|
if: needs.prepare.outputs.status == 'ready' && needs.prepare.outputs.comment_kind != 'reply'
|
|
runs-on: ubuntu-24.04
|
|
# GitHub-hosted runners allow at most six hours per job. The factory uses
|
|
# five hours as a soft runner budget, then checkpoints and continues in a
|
|
# new run at most once. Task-wide repair/failure counters survive Handoff.
|
|
timeout-minutes: 360
|
|
permissions:
|
|
actions: read
|
|
contents: write
|
|
# Read-only: re-checks a batch sample's live state before any model call.
|
|
issues: read
|
|
outputs:
|
|
handoff: ${{ steps.outcome.outputs.handoff }}
|
|
timed_out: ${{ steps.outcome.outputs.timed_out }}
|
|
checkpoint_available: ${{ steps.checkpoint.outcome == 'success' }}
|
|
# A kept handed-off checkpoint (Keep the handed-off checkpoint) is a sealed patch too.
|
|
patch_available: ${{ steps.patch.outcome == 'success' || steps.keep_checkpoint.outcome == 'success' }}
|
|
# A failed build with nothing to publish: see Check for a publishable diff.
|
|
empty_patch: ${{ steps.diff.outputs.empty == 'true' }}
|
|
# A handoff whose continuation was not dispatched: its checkpoint is recoverable.
|
|
handoff_undispatched: ${{ steps.outcome.outputs.handoff_undispatched }}
|
|
# A later attempt counts as rejected unless the guard accepted it: a
|
|
# control checkout that failed first skips the guard, and that attempt
|
|
# still must not publish reports or answer a comment.
|
|
rerun_rejected: ${{ steps.rerun_guard.outcome == 'failure' || (github.run_attempt > 1 && steps.rerun_guard.outcome != 'success') }}
|
|
steps:
|
|
# Checked out first: the re-run guard below reads the pinned scripts.
|
|
# Nothing here writes agent-artifacts.
|
|
- name: Check out factory control plane
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.prepare.outputs.control_sha }}
|
|
path: control
|
|
persist-credentials: false
|
|
|
|
# A GitHub Re-run starts a fresh runner without the previous attempt's
|
|
# checkpoint, so it can never continue the task (requireFreshRunAttempt
|
|
# rejects it too). Stop it before anything writes agent-artifacts: its
|
|
# always() uploads would otherwise publish a second, nearly empty
|
|
# factory-agent-N, and a recovery downloading that name by run would read
|
|
# it instead of the first attempt's checkpoint.
|
|
# A later attempt is accepted only when no earlier attempt started this
|
|
# job (for example prepare failed on a transient error, as prepare's own
|
|
# guard accepts): there is no checkpoint it could lose, and this attempt
|
|
# is the task's first execution. FACTORY_FIRST_AGENT_ATTEMPT tells
|
|
# pipeline-state.mjs so. An unreadable job list rejects.
|
|
- name: Reject a GitHub Re-run of this job
|
|
id: rerun_guard
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
if (( ${GITHUB_RUN_ATTEMPT:-1} > 1 )); then
|
|
set -euo pipefail
|
|
started=''
|
|
for (( attempt = 1; attempt < GITHUB_RUN_ATTEMPT; attempt++ )); do
|
|
started+="$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$attempt/jobs?per_page=100" \
|
|
--jq '.jobs[] | select(.name == "agent" and .started_at != null and .conclusion != "skipped") | .name')"
|
|
done
|
|
if [[ -n "$started" ]]; then
|
|
echo "::error::GitHub Re-run cannot preserve task repair limits. Use an explicit recovery Run (workflow_dispatch with recovery_run_id=${GITHUB_RUN_ID}) instead; this attempt uploads no checkpoint."
|
|
exit 1
|
|
fi
|
|
# A task pinned to an older control plane restores with a
|
|
# pipeline-state.mjs that ignores the verdict and would throw only
|
|
# after agent-artifacts exist: reject it here, as a re-run.
|
|
if ! grep -q FACTORY_FIRST_AGENT_ATTEMPT control/.github/scripts/pipeline-state.mjs; then
|
|
echo "::error::This task is pinned to a control plane that cannot run a GitHub Re-run as its first execution. Start a new build instead."
|
|
exit 1
|
|
fi
|
|
echo "FACTORY_FIRST_AGENT_ATTEMPT=true" >> "$GITHUB_ENV"
|
|
echo "::notice::No earlier attempt of this run started the agent job; attempt ${GITHUB_RUN_ATTEMPT} is the task's first execution."
|
|
fi
|
|
|
|
# Keep the handoff protocol available even when the pinned task scripts
|
|
# predate control_sha propagation. Never use this checkout for task code.
|
|
- name: Check out handoff bootstrap
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ github.sha }}
|
|
path: bootstrap
|
|
sparse-checkout-cone-mode: false
|
|
sparse-checkout: |
|
|
/.github/scripts/handoff.mjs
|
|
/.github/scripts/handoff-control.mjs
|
|
/.github/scripts/handoff-recovery.mjs
|
|
/.github/scripts/source-baseline-ref.mjs
|
|
/.github/scripts/pipeline-state.mjs
|
|
/.github/scripts/task-policy.mjs
|
|
/.github/scripts/acceptance-criteria.mjs
|
|
/.github/scripts/review-history.mjs
|
|
/.github/scripts/history-redaction.mjs
|
|
persist-credentials: false
|
|
|
|
- name: Start five-hour runner budget
|
|
run: |
|
|
echo "FACTORY_JOB_STARTED_EPOCH_SECONDS=$(date +%s)" >> "$GITHUB_ENV"
|
|
echo "FACTORY_RUN_DEADLINE_EPOCH_SECONDS=$(($(date +%s) + 18000 - 120))" >> "$GITHUB_ENV"
|
|
echo "FACTORY_TIMINGS_FILE=$RUNNER_TEMP/agent-artifacts/timings.jsonl" >> "$GITHUB_ENV"
|
|
|
|
# The composite steps below come from this workflow's own commit, like the
|
|
# bootstrap checkout: an in-flight task's pinned control_sha may predate
|
|
# them. The scripts they run still come from control/ or bootstrap/.
|
|
- name: Check out the workflow's composite actions
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ github.sha }}
|
|
path: factory-actions
|
|
sparse-checkout-cone-mode: false
|
|
sparse-checkout: |
|
|
/.github/actions/
|
|
persist-credentials: false
|
|
|
|
# A continuation that fails here has nothing of its own to save, and its
|
|
# source run concluded success, so no recovery can start from either run:
|
|
# one transient artifact error would discard the handed-off work. Each
|
|
# download is retried once after a pause, and they all run before the
|
|
# application checkout, so a failure from that checkout on is kept (see
|
|
# "Keep the handed-off checkpoint" below).
|
|
- name: Download handoff checkpoint
|
|
id: checkpoint_download
|
|
if: github.event_name == 'repository_dispatch' && github.event.action == 'code-agent-continue'
|
|
uses: ./factory-actions/.github/actions/download-with-retry
|
|
with:
|
|
# The checkpoint; older runs uploaded the same files as factory-handoff-N too.
|
|
name: factory-agent-${{ needs.prepare.outputs.issue_number }}
|
|
path: handoff
|
|
run-id: ${{ github.event.client_payload.previous_run_id }}
|
|
github-token: ${{ github.token }}
|
|
|
|
# A recovery restores the failed run's own checkpoint, which prepare
|
|
# validated, plus the small files prepare's normalize wrote beside it.
|
|
- name: Download the failed run's checkpoint
|
|
id: recovery_checkpoint_download
|
|
if: inputs.recovery_run_id != ''
|
|
uses: ./factory-actions/.github/actions/download-with-retry
|
|
with:
|
|
name: factory-agent-${{ needs.prepare.outputs.issue_number }}
|
|
path: handoff
|
|
run-id: ${{ inputs.recovery_run_id }}
|
|
github-token: ${{ github.token }}
|
|
|
|
- name: Download normalized recovery checkpoint
|
|
if: inputs.recovery_run_id != ''
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: factory-recovery-${{ needs.prepare.outputs.issue_number }}
|
|
path: recovery-normalized
|
|
|
|
# The normalized files replace any of the same name the checkpoint holds,
|
|
# and the checkpoint must be the one prepare validated: same patch,
|
|
# state, input and control plane as its recovery.json records.
|
|
- name: Apply the normalized recovery files
|
|
if: inputs.recovery_run_id != ''
|
|
run: |
|
|
set -euo pipefail
|
|
for file in task-event.json handoff.json recovery.json; do
|
|
if [[ ! -f "recovery-normalized/$file" || -L "recovery-normalized/$file" ]]; then
|
|
echo "::error::The normalized recovery lacks $file." >&2
|
|
exit 1
|
|
fi
|
|
rm -f "handoff/$file"
|
|
cp "recovery-normalized/$file" "handoff/$file"
|
|
done
|
|
node bootstrap/.github/scripts/handoff-recovery.mjs match --checkpoint handoff
|
|
|
|
# Downloaded before the checkpoint is verified and applied, so a failing
|
|
# patch restore still finds the task metadata and keeps its checkpoint.
|
|
- name: Download normalized task
|
|
id: task_download
|
|
uses: ./factory-actions/.github/actions/download-with-retry
|
|
with:
|
|
name: factory-task-${{ needs.prepare.outputs.issue_number }}
|
|
path: task
|
|
|
|
- name: Verify the pinned handoff control plane
|
|
id: verify_control
|
|
if: (github.event_name == 'repository_dispatch' && github.event.action == 'code-agent-continue') || inputs.recovery_run_id != ''
|
|
env:
|
|
TASK_EVENT: ${{ inputs.recovery_run_id != '' && 'handoff/task-event.json' || github.event_path }}
|
|
run: |
|
|
node bootstrap/.github/scripts/handoff-control.mjs verify \
|
|
--event "$TASK_EVENT" --checkpoint handoff
|
|
|
|
# Only the Agent sees full history: it may read `git log` to locate how the
|
|
# template and earlier task rounds changed a file. Later jobs apply the
|
|
# sealed patch to the base tree and check out a single commit.
|
|
- name: Check out application base
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.prepare.outputs.base_sha }}
|
|
path: workspace
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- name: Restore handoff checkpoint
|
|
if: (github.event_name == 'repository_dispatch' && github.event.action == 'code-agent-continue') || inputs.recovery_run_id != ''
|
|
env:
|
|
WORK_BRANCH: ${{ needs.prepare.outputs.work_branch }}
|
|
run: |
|
|
node control/.github/scripts/apply-patch.mjs \
|
|
--workspace workspace \
|
|
--patch handoff/agent.patch \
|
|
--branch "$WORK_BRANCH"
|
|
# Restore retrospective only here; stage state is validated after task download.
|
|
mkdir -p "$RUNNER_TEMP/agent-artifacts"
|
|
if [[ -f handoff/retro.json && ! -L handoff/retro.json ]]; then
|
|
cp handoff/retro.json "$RUNNER_TEMP/agent-artifacts/retro.json"
|
|
fi
|
|
|
|
# A batch sample is admitted again right before model work, so a sample
|
|
# cancelled or released since prepare, or a spent budget, starts no model.
|
|
# A refusal (cancelled or released sample, spent budget, a receipt that no
|
|
# longer matches the task) is recorded as
|
|
# an output, so a continuation never keeps its checkpoint as recoverable
|
|
# work; an API error leaves it unset. Pinned older scripts print the same
|
|
# refusal text, so it is read from their output.
|
|
- name: Admit the evaluation sample for this execution
|
|
id: admit
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: |
|
|
if [[ -f control/.github/scripts/evaluation-sample.mjs ]]; then
|
|
status=0
|
|
node control/.github/scripts/evaluation-sample.mjs admit --metadata task/task-metadata.json --output "$GITHUB_ENV" \
|
|
2> "$RUNNER_TEMP/sample-admission.err" || status=$?
|
|
cat "$RUNNER_TEMP/sample-admission.err" >&2
|
|
if (( status != 0 )); then
|
|
if grep -Eq 'Evaluation sample not admitted|Batch sample receipt no longer matches the task metadata' "$RUNNER_TEMP/sample-admission.err"; then
|
|
echo "refused=true" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
exit "$status"
|
|
fi
|
|
fi
|
|
|
|
- name: Restore pipeline progress
|
|
id: resume
|
|
env:
|
|
CONTINUATION_RUN: ${{ (github.event_name == 'repository_dispatch' && github.event.action == 'code-agent-continue') || inputs.recovery_run_id != '' }}
|
|
run: |
|
|
mkdir -p "$RUNNER_TEMP/agent-artifacts"
|
|
if [[ "$CONTINUATION_RUN" == true ]]; then
|
|
node control/.github/scripts/pipeline-state.mjs restore handoff "$RUNNER_TEMP/agent-artifacts" task/task-metadata.json
|
|
else
|
|
node control/.github/scripts/pipeline-state.mjs init "$RUNNER_TEMP/agent-artifacts/pipeline-state.json" task/task-metadata.json
|
|
fi
|
|
# The checkpoint, the failed PR, retro and visual reports all read the
|
|
# task metadata beside the state. Copied here, not only when the
|
|
# prompt is built, so a failure during setup (dependency or Agent
|
|
# install, preflight, fonts) still leaves a complete checkpoint.
|
|
cp task/task-metadata.json "$RUNNER_TEMP/agent-artifacts/task-metadata.json"
|
|
echo "phase=$(node control/.github/scripts/pipeline-state.mjs phase "$RUNNER_TEMP/agent-artifacts/pipeline-state.json")" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Check independent evaluation prerequisites before model work
|
|
env:
|
|
FACTORY_TEST_API_KEY_PRESENT: ${{ secrets.FACTORY_TEST_API_KEY != '' }}
|
|
FACTORY_TEST_ADMIN_KEY_PRESENT: ${{ secrets.FACTORY_TEST_ADMIN_KEY != '' }}
|
|
FACTORY_BUSINESS_MODEL_PRESENT: ${{ vars.FACTORY_BUSINESS_MODEL != '' }}
|
|
FACTORY_BUSINESS_API_KEY_PRESENT: ${{ secrets.FACTORY_BUSINESS_API_KEY != '' }}
|
|
FACTORY_BUSINESS_MODEL_ENDPOINT_PRESENT: ${{ vars.FACTORY_BUSINESS_MODEL_ENDPOINT != '' }}
|
|
FACTORY_TEST_CHANNEL_PRESENT: ${{ vars.FACTORY_TEST_CHANNEL != '' }}
|
|
FACTORY_TEST_RECEIVER_URL_PRESENT: ${{ vars.FACTORY_TEST_RECEIVER_URL != '' }}
|
|
run: |
|
|
if [[ -f control/.github/scripts/required-checks.mjs ]]; then
|
|
node control/.github/scripts/required-checks.mjs preflight task/task-metadata.json "$RUNNER_TEMP/agent-artifacts/required-checks.preflight.json" || {
|
|
status=$?
|
|
node control/.github/scripts/pipeline-state.mjs outcome "$RUNNER_TEMP/agent-artifacts/pipeline-state.json" blocked
|
|
exit "$status"
|
|
}
|
|
fi
|
|
|
|
# Every task is bounded; a batch plan can tighten the factory ceilings.
|
|
- name: Apply the task and evaluation budgets
|
|
run: |
|
|
if [[ -f control/.github/scripts/evaluation-budget.mjs ]]; then
|
|
node control/.github/scripts/evaluation-budget.mjs deadline "$RUNNER_TEMP/agent-artifacts/pipeline-state.json" >> "$GITHUB_ENV"
|
|
fi
|
|
|
|
- name: Set up pnpm
|
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
|
with:
|
|
version: 11.7.0
|
|
run_install: false
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
with:
|
|
node-version: 24.x
|
|
# Source tarballs use an isolated local store, not the shared registry cache.
|
|
# Check the already checked-out descriptor before setup-node arms its post step.
|
|
cache: ${{ hashFiles('workspace/factory-source.json') == '' && 'pnpm' || '' }}
|
|
cache-dependency-path: workspace/pnpm-lock.yaml
|
|
|
|
# The official CLI installs dist/ as its own pnpm root, which never reads the
|
|
# application's .npmrc, and pnpm 11 ignores scoped registries passed in
|
|
# the environment. Only the user-level file reaches that nested install.
|
|
- name: Expose the factory registry to nested pnpm roots
|
|
run: cat control/.npmrc >> ~/.npmrc
|
|
|
|
# The pinned font package is cached under its own SHA-256 and verified
|
|
# again before dpkg installs it, so most runs skip apt and its index
|
|
# update entirely (FACTORY_FONT_DEB_CACHE in install-browser-fonts.sh).
|
|
# Restored and saved here, before any model call; verify-final only
|
|
# restores it.
|
|
- name: Read the pinned Chinese font package
|
|
id: font_pin
|
|
env:
|
|
FONT_SCRIPT: control/.github/scripts/install-browser-fonts.sh
|
|
run: echo "sha=$(sed -n 's/^FONT_DEB_SHA256=\([0-9a-f]\{64\}\)$/\1/p' "$FONT_SCRIPT")" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Restore the pinned Chinese font package
|
|
id: font_cache
|
|
if: steps.font_pin.outputs.sha != ''
|
|
continue-on-error: true
|
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
with:
|
|
path: browser-font-cache
|
|
key: browser-font-deb-${{ runner.os }}-${{ steps.font_pin.outputs.sha }}
|
|
|
|
- name: Install Chinese browser fonts
|
|
timeout-minutes: 10
|
|
env:
|
|
FACTORY_FONT_DEB_CACHE: ${{ github.workspace }}/browser-font-cache
|
|
run: bash control/.github/scripts/install-browser-fonts.sh
|
|
|
|
- name: Save the pinned Chinese font package
|
|
if: steps.font_pin.outputs.sha != '' && steps.font_cache.outputs.cache-hit != 'true' && hashFiles('browser-font-cache/*.deb') != ''
|
|
continue-on-error: true
|
|
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
with:
|
|
path: browser-font-cache
|
|
key: browser-font-deb-${{ runner.os }}-${{ steps.font_pin.outputs.sha }}
|
|
|
|
- name: Restore the selected source baseline packages
|
|
run: |
|
|
if [[ -f control/.github/scripts/source-snapshot.mjs ]]; then
|
|
node control/.github/scripts/source-snapshot.mjs restore workspace task/task-metadata.json
|
|
elif [[ -f workspace/factory-source.json ]]; then
|
|
echo "Pinned control plane cannot restore the selected source baseline." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Restored work (a continuation, a recovery, or a comment round on the
|
|
# published work branch) can carry a package.json its lockfile does not
|
|
# match: an interrupted repair or a failed task published it that way. A
|
|
# frozen install would then fail before any model could fix it, on every
|
|
# later round. Only for restored work and only for
|
|
# ERR_PNPM_OUTDATED_LOCKFILE, install again without freezing: the updated
|
|
# lockfile becomes part of the patch, and verify.sh's lockfile stage still
|
|
# sends any remaining mismatch to repair. A fresh task stays frozen.
|
|
- name: Install application dependencies
|
|
timeout-minutes: 15
|
|
working-directory: workspace
|
|
env:
|
|
RESTORED_WORK: ${{ (github.event_name == 'repository_dispatch' && github.event.action == 'code-agent-continue') || inputs.recovery_run_id != '' || needs.prepare.outputs.base_ref == needs.prepare.outputs.work_branch }}
|
|
run: |
|
|
set -o pipefail
|
|
node ../control/.github/scripts/assert-current-template.mjs .
|
|
status=0
|
|
node ../control/.github/scripts/timed-command.mjs install pnpm install --frozen-lockfile 2>&1 | tee "$RUNNER_TEMP/install.log" || status=$?
|
|
if (( status != 0 )); then
|
|
if [[ "$RESTORED_WORK" != true ]] || ! grep -q ERR_PNPM_OUTDATED_LOCKFILE "$RUNNER_TEMP/install.log"; then
|
|
exit "$status"
|
|
fi
|
|
echo "::warning::The restored work's pnpm-lock.yaml does not match its package.json; installing without --frozen-lockfile so the task can repair it."
|
|
node ../control/.github/scripts/timed-command.mjs install-refresh-lockfile pnpm install --no-frozen-lockfile
|
|
fi
|
|
node ../control/.github/scripts/timed-command.mjs skills-sync pnpm exec nocobase skills sync
|
|
|
|
- name: Capture the actual installed package and Skill baseline
|
|
run: |
|
|
if [[ -f control/.github/scripts/baseline-record.mjs ]]; then
|
|
node control/.github/scripts/baseline-record.mjs workspace "$RUNNER_TEMP/agent-artifacts/baseline.json"
|
|
fi
|
|
|
|
- name: Build the implementation prompt
|
|
run: |
|
|
mkdir -p "$RUNNER_TEMP/agent-artifacts"
|
|
cp task/task-metadata.json "$RUNNER_TEMP/agent-artifacts/task-metadata.json"
|
|
node control/.github/scripts/build-prompt.mjs \
|
|
--metadata task/task-metadata.json \
|
|
--template control/.github/prompts/implement.md \
|
|
--retro-path "$RUNNER_TEMP/agent-artifacts/retro.json" \
|
|
--output "$RUNNER_TEMP/implement.md"
|
|
|
|
- name: Explain restored work without exposing QA context
|
|
if: inputs.recovery_run_id != '' && steps.resume.outputs.phase == 'implementation'
|
|
run: node bootstrap/.github/scripts/handoff-recovery.mjs context --checkpoint handoff --prompt "$RUNNER_TEMP/implement.md"
|
|
|
|
- name: Explain handed-off work without exposing QA context
|
|
if: github.event_name == 'repository_dispatch' && github.event.action == 'code-agent-continue' && steps.resume.outputs.phase == 'implementation'
|
|
env:
|
|
PREVIOUS_RUN_ID: ${{ github.event.client_payload.previous_run_id }}
|
|
run: node bootstrap/.github/scripts/handoff-recovery.mjs continuation-context --previous-run "$PREVIOUS_RUN_ID" --prompt "$RUNNER_TEMP/implement.md"
|
|
|
|
- name: Install pinned Code Agent
|
|
timeout-minutes: 10
|
|
if: inputs.failure_smoke != true
|
|
env: &agent-install-env
|
|
CODE_AGENT_ENGINE: ${{ vars.CODE_AGENT_ENGINE }}
|
|
CODE_AGENT_VERSION: ${{ vars.CODE_AGENT_VERSION }}
|
|
PI_VERSION: ${{ vars.PI_VERSION }}
|
|
CODEBUDDY_VERSION: ${{ vars.CODEBUDDY_VERSION }}
|
|
CLAUDE_CODE_VERSION: ${{ vars.CLAUDE_CODE_VERSION }}
|
|
CODEX_VERSION: ${{ vars.CODEX_VERSION }}
|
|
OPENCODE_VERSION: ${{ vars.OPENCODE_VERSION }}
|
|
FACTORY_AGENT_INSTALL_RECORD: ${{ runner.temp }}/agent-install.json
|
|
run: node control/.github/scripts/install-agent.mjs
|
|
|
|
# The npm cache is restored and saved again here, before any model call,
|
|
# so what the model does to the runner never reaches a later run's cache.
|
|
# It is a directory of its own: ~/.npm also receives the Code Agent CLI
|
|
# installed above, which a key naming only the browser version would keep
|
|
# forever while never serving it.
|
|
- name: Restore the Agent Browser npm cache
|
|
id: browser_cache
|
|
if: inputs.failure_smoke != true
|
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
with:
|
|
path: ${{ runner.temp }}/agent-browser-npm
|
|
key: agent-browser-npm-v2-${{ runner.os }}-${{ vars.AGENT_BROWSER_VERSION || '0.36.0' }}
|
|
|
|
- name: Install pinned Agent Browser
|
|
timeout-minutes: 10
|
|
if: inputs.failure_smoke != true
|
|
env:
|
|
AGENT_BROWSER_VERSION: ${{ vars.AGENT_BROWSER_VERSION || '0.36.0' }}
|
|
npm_config_cache: ${{ runner.temp }}/agent-browser-npm
|
|
run: |
|
|
npm install --global --prefer-offline "agent-browser@${AGENT_BROWSER_VERSION}"
|
|
agent-browser --version
|
|
|
|
- name: Save the Agent Browser npm cache
|
|
if: inputs.failure_smoke != true && steps.browser_cache.outputs.cache-hit != 'true'
|
|
continue-on-error: true
|
|
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
with:
|
|
path: ${{ runner.temp }}/agent-browser-npm
|
|
key: ${{ steps.browser_cache.outputs.cache-primary-key }}
|
|
|
|
# The package index is refreshed only when installing from the current
|
|
# one fails: the font install usually refreshed it already, and a cached
|
|
# font package leaves the image's index, which is often still current.
|
|
- name: Install optional video encoder
|
|
if: inputs.failure_smoke != true
|
|
continue-on-error: true
|
|
timeout-minutes: 5
|
|
run: |
|
|
if ! command -v ffmpeg >/dev/null 2>&1; then
|
|
apt_options=(-o Acquire::Retries=2 -o Acquire::http::Timeout=30)
|
|
if ! sudo apt-get install "${apt_options[@]}" --yes --no-install-recommends ffmpeg; then
|
|
sudo apt-get update "${apt_options[@]}"
|
|
sudo apt-get install "${apt_options[@]}" --yes --no-install-recommends ffmpeg
|
|
fi
|
|
fi
|
|
|
|
- name: Prepare and validate browser environment
|
|
if: inputs.failure_smoke != true
|
|
run: |
|
|
mkdir -p "$RUNNER_TEMP/agent-artifacts/browser-environment"
|
|
export AGENT_BROWSER_EXECUTABLE_PATH="$(command -v google-chrome || command -v google-chrome-stable || command -v chromium || true)"
|
|
python3 control/.github/scripts/prepare-browser-fixtures.py "$RUNNER_TEMP/agent-artifacts/browser-environment/fixtures"
|
|
node control/.github/scripts/browser-preflight.mjs "$RUNNER_TEMP/agent-artifacts/browser-environment/preflight.json"
|
|
echo "AGENT_BROWSER_EXECUTABLE_PATH=$AGENT_BROWSER_EXECUTABLE_PATH" >> "$GITHUB_ENV"
|
|
echo "FACTORY_BROWSER_PREFLIGHT=$RUNNER_TEMP/agent-artifacts/browser-environment/preflight.json" >> "$GITHUB_ENV"
|
|
echo "FACTORY_BROWSER_FIXTURES_MANIFEST=$RUNNER_TEMP/agent-artifacts/browser-environment/fixtures/manifest.json" >> "$GITHUB_ENV"
|
|
|
|
- name: Start live progress observer
|
|
if: inputs.failure_smoke != true
|
|
continue-on-error: true
|
|
env:
|
|
# The observer starts before model credentials exist. It can dispatch
|
|
# snapshots, but only the independent reporter can write Issue comments.
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: |
|
|
nohup node control/.github/scripts/task-progress.mjs watch \
|
|
"$RUNNER_TEMP/agent-artifacts" task/task-metadata.json \
|
|
>"$RUNNER_TEMP/agent-artifacts/live-progress.log" 2>&1 < /dev/null &
|
|
|
|
- name: Run Code Agent implementation
|
|
id: implementation
|
|
if: >-
|
|
inputs.failure_smoke != true && (
|
|
((needs.prepare.outputs.base_ref != needs.prepare.outputs.work_branch || needs.prepare.outputs.build_comment_id != '') &&
|
|
!(github.event_name == 'repository_dispatch' && github.event.action == 'code-agent-continue') && inputs.recovery_run_id == '') ||
|
|
steps.resume.outputs.phase == 'implementation')
|
|
env: &agent-run-env
|
|
CODE_AGENT_ENGINE: ${{ vars.CODE_AGENT_ENGINE }}
|
|
FACTORY_AGENT_INSTALL_RECORD: ${{ runner.temp }}/agent-install.json
|
|
# Compatibility bridge for older pinned control code (before the shared normalizer).
|
|
# Explicit engine selection; adding a CLI must not fall through to Pi.
|
|
# This mapping is shared by implementation, repair/QA, and comment reply.
|
|
CODE_AGENT_API_ENDPOINT: ${{ (vars.CODE_AGENT_ENGINE == '' || vars.CODE_AGENT_ENGINE == 'pi') && (secrets.CODE_AGENT_API_ENDPOINT || secrets.PI_API_ENDPOINT) || '' }}
|
|
CODE_AGENT_API_KEY: ${{ (vars.CODE_AGENT_ENGINE == '' || vars.CODE_AGENT_ENGINE == 'pi') && (secrets.CODE_AGENT_API_KEY || secrets.PI_API_KEY) || '' }}
|
|
CODEBUDDY_AUTH_TOKEN: ${{ vars.CODE_AGENT_ENGINE == 'codebuddy' && secrets.CODEBUDDY_AUTH_TOKEN || '' }}
|
|
CODEBUDDY_API_KEY: ${{ vars.CODE_AGENT_ENGINE == 'codebuddy' && secrets.CODEBUDDY_API_KEY || '' }}
|
|
CODEBUDDY_MODEL: ${{ vars.CODE_AGENT_ENGINE == 'codebuddy' && vars.CODEBUDDY_MODEL || '' }}
|
|
CODEBUDDY_THINKING: ${{ vars.CODEBUDDY_THINKING || 'max' }}
|
|
CODEBUDDY_BASE_URL: ${{ vars.CODE_AGENT_ENGINE == 'codebuddy' && vars.CODEBUDDY_BASE_URL || '' }}
|
|
CODEBUDDY_INTERNET_ENVIRONMENT: ${{ vars.CODE_AGENT_ENGINE == 'codebuddy' && vars.CODEBUDDY_INTERNET_ENVIRONMENT || '' }}
|
|
CODE_AGENT_API_TYPE: ${{ vars.CODE_AGENT_API_TYPE || vars.PI_API_TYPE || 'openai-completions' }}
|
|
CODE_AGENT_MODEL: ${{ vars.CODE_AGENT_MODEL || vars.PI_MODEL }}
|
|
CODE_AGENT_THINKING: ${{ vars.CODE_AGENT_THINKING || vars.PI_THINKING || 'max' }}
|
|
CODE_AGENT_AUTH_HEADER: ${{ vars.CODE_AGENT_AUTH_HEADER || vars.PI_AUTH_HEADER || 'true' }}
|
|
CODE_AGENT_SUPPORTS_DEVELOPER_ROLE: ${{ vars.CODE_AGENT_SUPPORTS_DEVELOPER_ROLE || vars.PI_SUPPORTS_DEVELOPER_ROLE }}
|
|
CODE_AGENT_SUPPORTS_REASONING_EFFORT: ${{ vars.CODE_AGENT_SUPPORTS_REASONING_EFFORT || vars.PI_SUPPORTS_REASONING_EFFORT || 'true' }}
|
|
CODE_AGENT_INVOCATION_TIMEOUT_SECONDS: ${{ vars.CODE_AGENT_INVOCATION_TIMEOUT_SECONDS || vars.PI_INVOCATION_TIMEOUT_SECONDS || '0' }}
|
|
ANTHROPIC_API_KEY: ${{ vars.CODE_AGENT_ENGINE == 'claude-code' && secrets.ANTHROPIC_API_KEY || '' }}
|
|
CLAUDE_CODE_OAUTH_TOKEN: ${{ vars.CODE_AGENT_ENGINE == 'claude-code' && secrets.CLAUDE_CODE_OAUTH_TOKEN || '' }}
|
|
ANTHROPIC_BASE_URL: ${{ vars.CODE_AGENT_ENGINE == 'claude-code' && vars.ANTHROPIC_BASE_URL || '' }}
|
|
CLAUDE_CODE_MODEL: ${{ vars.CLAUDE_CODE_MODEL }}
|
|
CLAUDE_CODE_EFFORT: ${{ vars.CLAUDE_CODE_EFFORT }}
|
|
CLAUDE_CODE_QA_EFFORT: ${{ vars.CLAUDE_CODE_QA_EFFORT }}
|
|
CODEX_API_KEY: ${{ vars.CODE_AGENT_ENGINE == 'codex' && secrets.CODEX_API_KEY || '' }}
|
|
CODEX_MODEL: ${{ vars.CODEX_MODEL }}
|
|
CODEX_BASE_URL: ${{ vars.CODE_AGENT_ENGINE == 'codex' && vars.CODEX_BASE_URL || '' }}
|
|
CODEX_REASONING_EFFORT: ${{ vars.CODEX_REASONING_EFFORT }}
|
|
CODEX_QA_REASONING_EFFORT: ${{ vars.CODEX_QA_REASONING_EFFORT }}
|
|
OPENCODE_API_KEY: ${{ vars.CODE_AGENT_ENGINE == 'opencode' && secrets.OPENCODE_API_KEY || '' }}
|
|
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
|
|
OPENCODE_BASE_URL: ${{ vars.CODE_AGENT_ENGINE == 'opencode' && vars.OPENCODE_BASE_URL || '' }}
|
|
OPENCODE_VARIANT: ${{ vars.OPENCODE_VARIANT }}
|
|
OPENCODE_QA_VARIANT: ${{ vars.OPENCODE_QA_VARIANT }}
|
|
CODE_AGENT_IDLE_TIMEOUT_SECONDS: ${{ vars.CODE_AGENT_IDLE_TIMEOUT_SECONDS }}
|
|
run: |
|
|
node control/.github/scripts/pipeline-state.mjs set "$RUNNER_TEMP/agent-artifacts/pipeline-state.json" implementation
|
|
set +e
|
|
node control/.github/scripts/pipeline-state.mjs budget "$RUNNER_TEMP/agent-artifacts/pipeline-state.json" implementation
|
|
status=$?
|
|
if [[ "$status" -eq 0 ]]; then
|
|
node control/.github/scripts/run-agent.mjs \
|
|
--workspace workspace \
|
|
--prompt "$RUNNER_TEMP/implement.md" \
|
|
--log "$RUNNER_TEMP/agent-artifacts/agent-implement.jsonl" \
|
|
--agentDir "$RUNNER_TEMP/code-agent"
|
|
status=$?
|
|
fi
|
|
if [[ "$status" -eq 75 ]]; then
|
|
if [[ -f control/.github/scripts/task-policy.mjs ]]; then
|
|
node control/.github/scripts/pipeline-state.mjs handoff "$RUNNER_TEMP/agent-artifacts/pipeline-state.json"
|
|
status=$?
|
|
else
|
|
# Older tasks keep their pinned control API; bootstrap still bounds dispatch.
|
|
node control/.github/scripts/pipeline-state.mjs outcome "$RUNNER_TEMP/agent-artifacts/pipeline-state.json" handoff
|
|
fi
|
|
fi
|
|
set -e
|
|
if [[ "$status" -eq 75 ]]; then
|
|
echo "handoff=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
if [[ "$status" -ne 0 && "$status" -ne 76 ]]; then
|
|
node control/.github/scripts/pipeline-state.mjs outcome "$RUNNER_TEMP/agent-artifacts/pipeline-state.json" failed
|
|
fi
|
|
if [[ "$status" -eq 0 ]]; then
|
|
node control/.github/scripts/pipeline-state.mjs set "$RUNNER_TEMP/agent-artifacts/pipeline-state.json" verify
|
|
fi
|
|
exit "$status"
|
|
|
|
- name: Verify and repair within task limits
|
|
id: verify
|
|
if: inputs.failure_smoke != true && steps.implementation.outputs.handoff != 'true'
|
|
env: *agent-run-env
|
|
run: |
|
|
set +e
|
|
control/.github/scripts/verify-and-repair.sh \
|
|
control \
|
|
workspace \
|
|
"$RUNNER_TEMP/implement.md" \
|
|
task/task-metadata.json \
|
|
"$RUNNER_TEMP/agent-artifacts" \
|
|
"$RUNNER_TEMP/repair-state"
|
|
status=$?
|
|
set -e
|
|
if [[ "$status" -eq 75 ]]; then
|
|
echo "handoff=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
exit "$status"
|
|
|
|
- name: Inject disclosed failure before business QA
|
|
id: failure_smoke
|
|
if: inputs.failure_smoke == true
|
|
env:
|
|
WORK_BRANCH: ${{ needs.prepare.outputs.work_branch }}
|
|
run: node control/.github/scripts/failure-smoke.mjs inject workspace "$RUNNER_TEMP/agent-artifacts" "$WORK_BRANCH"
|
|
|
|
# A job timeout is a cancellation: success() and failure() are both false
|
|
# then, so without the cancelled() clause hours of work left no patch and
|
|
# no checkpoint. Only work that actually started is sealed.
|
|
# A continuation or recovery that fails while setting up (dependency or
|
|
# Agent install, preflight, fonts) never reaches implementation or
|
|
# verification, yet its workspace already holds the restored work. Record
|
|
# the run as failed so the patch below seals it and the work stays
|
|
# publishable and recoverable instead of vanishing with the runner.
|
|
# A continuation that failed after both the checkpoint and the task
|
|
# downloads but before "Restore pipeline progress" (application checkout,
|
|
# patch restore, admission, the restore itself) has a workspace of
|
|
# uncertain state, yet the handed-off work is intact in handoff/. Save
|
|
# that checkpoint as this run's own, marked failed, so publish-failed and
|
|
# an explicit recovery from this run (recovery_run_id) keep it: the source
|
|
# run concluded success after its handoff, so a recovery could never start
|
|
# from there. A cancellation (the runner limit included) keeps it too. A
|
|
# refused control-plane verification is deliberate and keeps nothing. The
|
|
# elapsed time stays the source run's (no job start), so no counter is reset.
|
|
- name: Keep the handed-off checkpoint
|
|
id: keep_checkpoint
|
|
if: >-
|
|
(failure() || cancelled()) && steps.rerun_guard.outcome == 'success' && steps.resume.outcome != 'success' &&
|
|
github.event_name == 'repository_dispatch' && github.event.action == 'code-agent-continue' &&
|
|
steps.checkpoint_download.outcome == 'success' &&
|
|
steps.task_download.outcome == 'success' &&
|
|
steps.verify_control.outcome == 'success' &&
|
|
steps.admit.outputs.refused != 'true'
|
|
run: |
|
|
set -euo pipefail
|
|
artifacts="$RUNNER_TEMP/agent-artifacts"
|
|
mkdir -p "$artifacts"
|
|
for file in handoff/agent.patch handoff/pipeline-state.json task/task-metadata.json; do
|
|
if [[ ! -f "$file" || -L "$file" ]]; then
|
|
echo "::error::The handed-off checkpoint lacks $file; nothing to keep."
|
|
exit 1
|
|
fi
|
|
done
|
|
# Only a checkpoint this run could have restored is kept. A deliberate
|
|
# rejection (input edited, patch or control plane changed, task
|
|
# stopped, budget changed, repair context lost) stays a plain failure
|
|
# with nothing published and no recovery offered. The check comes from
|
|
# this workflow's own revision, like the handoff protocol.
|
|
node bootstrap/.github/scripts/pipeline-state.mjs keepable \
|
|
handoff/pipeline-state.json handoff/agent.patch task/task-metadata.json
|
|
# The whole checkpoint, regular files only (find -type f neither lists
|
|
# nor follows symlinks): repair context, review and agent history and
|
|
# the retro survive for the recovery and the reports. Not the handoff
|
|
# request; not the source runner's progress observer files, whose pid
|
|
# the stop step below would otherwise signal; and not the source run's
|
|
# invocation results or timings, which mark-failure, usage and timing
|
|
# reports would otherwise present as this run's.
|
|
(cd handoff && find . -type f ! -path ./handoff.json ! -name 'live-progress.*' \
|
|
! -name 'agent*.jsonl.result.json' ! -name 'timings.jsonl' \
|
|
-exec cp --parents {} "$artifacts" \;)
|
|
cp task/task-metadata.json "$artifacts/task-metadata.json"
|
|
# What is published must pass the same check as what was validated.
|
|
node bootstrap/.github/scripts/pipeline-state.mjs keepable \
|
|
"$artifacts/pipeline-state.json" "$artifacts/agent.patch" "$artifacts/task-metadata.json"
|
|
env -u FACTORY_JOB_STARTED_EPOCH_SECONDS \
|
|
node control/.github/scripts/pipeline-state.mjs outcome "$artifacts/pipeline-state.json" failed
|
|
echo "::warning::This continuation failed before restoring its checkpoint. The handed-off work is saved as this run's checkpoint; recover it with recovery_run_id=${GITHUB_RUN_ID}."
|
|
|
|
- name: Mark a continuation that failed during setup
|
|
id: setup_failure
|
|
if: >-
|
|
(failure() || cancelled()) && steps.resume.outcome == 'success' &&
|
|
steps.implementation.outcome == 'skipped' && steps.verify.outcome == 'skipped' &&
|
|
((github.event_name == 'repository_dispatch' && github.event.action == 'code-agent-continue') || inputs.recovery_run_id != '')
|
|
run: |
|
|
state="$RUNNER_TEMP/agent-artifacts/pipeline-state.json"
|
|
outcome="$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).outcome ?? "")' "$state")"
|
|
# A preflight block or an exhausted budget keeps its own verdict.
|
|
if [[ "$outcome" != blocked && "$outcome" != budget-exhausted && "$outcome" != failed ]]; then
|
|
node control/.github/scripts/pipeline-state.mjs outcome "$state" failed
|
|
fi
|
|
|
|
- name: Create deterministic patch
|
|
id: patch
|
|
if: >-
|
|
success() ||
|
|
failure() && (steps.implementation.outcome == 'failure' || steps.verify.outcome == 'failure' || steps.failure_smoke.outcome == 'failure') ||
|
|
(failure() || cancelled()) && steps.setup_failure.outcome == 'success' ||
|
|
cancelled() && (steps.implementation.outcome != 'skipped' || steps.verify.outcome != 'skipped' || steps.failure_smoke.outcome != 'skipped') ||
|
|
steps.implementation.outputs.handoff == 'true' ||
|
|
steps.verify.outputs.handoff == 'true'
|
|
env:
|
|
ALLOW_EMPTY_PATCH: ${{ steps.implementation.outcome == 'failure' || steps.verify.outcome == 'failure' || steps.implementation.outcome == 'cancelled' || steps.verify.outcome == 'cancelled' || needs.prepare.outputs.base_ref == needs.prepare.outputs.work_branch || steps.implementation.outputs.handoff == 'true' || steps.verify.outputs.handoff == 'true' }}
|
|
BASE_REF: ${{ needs.prepare.outputs.base_ref }}
|
|
WORK_BRANCH: ${{ needs.prepare.outputs.work_branch }}
|
|
run: |
|
|
# The root AGENTS.md/CLAUDE.md are reset to the template text: from
|
|
# this base, or on an existing work branch from where it forked from
|
|
# the target branch, so a branch an earlier build diverged carries
|
|
# the template text again. The fork point, not the target head: a
|
|
# later template refresh must not pull newer guidance into an old branch.
|
|
guidance_source=HEAD
|
|
if [[ "$BASE_REF" == "$WORK_BRANCH" ]]; then
|
|
target="$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).task?.targetBranch ?? "")' task/task-metadata.json)"
|
|
if [[ -z "$target" ]]; then
|
|
echo "::warning::The task records no target branch; the root AGENTS.md/CLAUDE.md are kept as this work branch has them."
|
|
elif ! fork="$(git -C workspace merge-base HEAD "refs/remotes/origin/${target}" 2>/dev/null)"; then
|
|
echo "::warning::Cannot find where this work branch forked from ${target}; the root AGENTS.md/CLAUDE.md are kept as this work branch has them."
|
|
else
|
|
guidance_source="$fork"
|
|
fi
|
|
fi
|
|
node control/.github/scripts/create-patch.mjs \
|
|
--workspace workspace \
|
|
--patch "$RUNNER_TEMP/agent-artifacts/agent.patch" \
|
|
--summary "$RUNNER_TEMP/agent-artifacts/change-summary.json" \
|
|
--allow-empty "$ALLOW_EMPTY_PATCH" \
|
|
--protect-template-guidance true \
|
|
--guidance-source "$guidance_source"
|
|
node control/.github/scripts/pipeline-state.mjs seal "$RUNNER_TEMP/agent-artifacts/pipeline-state.json" "$RUNNER_TEMP/agent-artifacts/agent.patch"
|
|
|
|
- name: Review build quality and framework feedback
|
|
# The patch is already sealed. A failed assessment never starts repair
|
|
# or changes the independent final verification / publication verdict.
|
|
if: >-
|
|
!cancelled() && inputs.failure_smoke != true && steps.patch.outcome == 'success' &&
|
|
steps.setup_failure.outcome != 'success' &&
|
|
steps.implementation.outputs.handoff != 'true' && steps.verify.outputs.handoff != 'true'
|
|
continue-on-error: true
|
|
# The runner accepts at most 60 minutes for the whole review, reruns and
|
|
# their waits included, and saves a validated partial result on its own
|
|
# timeout; leave time for snapshot setup and cleanup.
|
|
timeout-minutes: 70
|
|
env: *agent-run-env
|
|
run: node control/.github/scripts/run-build-review.mjs workspace "$RUNNER_TEMP/agent-artifacts"
|
|
|
|
- name: Prepare runner handoff metadata
|
|
id: handoff
|
|
if: steps.implementation.outputs.handoff == 'true' || steps.verify.outputs.handoff == 'true'
|
|
env:
|
|
CURRENT_CONTINUATION: ${{ inputs.recovery_run_id != '' && '1' || github.event.client_payload.continuation || '0' }}
|
|
ISSUE_NUMBER: ${{ needs.prepare.outputs.issue_number }}
|
|
run: |
|
|
# Bash arithmetic evaluates command substitutions, and this value comes
|
|
# from the dispatch payload: accept digits only.
|
|
if [[ ! "$CURRENT_CONTINUATION" =~ ^[0-9]{1,3}$ ]]; then
|
|
echo "::error::Invalid continuation counter in the dispatch payload." >&2
|
|
exit 1
|
|
fi
|
|
next_continuation=$((10#$CURRENT_CONTINUATION + 1))
|
|
# A spent evaluation budget ends the chain here: the checkpoint is kept,
|
|
# no continuation is sent. evaluation-budget.mjs records the stop
|
|
# (budget-exhausted, with its diagnostic), so the failure notice reads
|
|
# "stopped for diagnosis", not a recoverable handoff.
|
|
if [[ -f control/.github/scripts/evaluation-budget.mjs ]] && \
|
|
! node control/.github/scripts/evaluation-budget.mjs handoff "$RUNNER_TEMP/agent-artifacts/pipeline-state.json" "$next_continuation"; then
|
|
echo "::warning::Evaluation sample budget exhausted; facts are saved without an automatic continuation."
|
|
exit 1
|
|
fi
|
|
node bootstrap/.github/scripts/handoff.mjs prepare \
|
|
--issue "$ISSUE_NUMBER" \
|
|
--run-id "$GITHUB_RUN_ID" \
|
|
--continuation "$next_continuation" \
|
|
--phase "$(node control/.github/scripts/pipeline-state.mjs phase "$RUNNER_TEMP/agent-artifacts/pipeline-state.json")" \
|
|
--output "$RUNNER_TEMP/agent-artifacts/handoff.json"
|
|
echo "continuation=$next_continuation" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Stop the task source registry
|
|
if: always()
|
|
continue-on-error: true
|
|
run: |
|
|
if [[ -f workspace/factory-source.json ]]; then
|
|
node control/.github/scripts/source-snapshot.mjs stop
|
|
fi
|
|
|
|
- name: Flush and stop live progress observer
|
|
if: always()
|
|
continue-on-error: true
|
|
timeout-minutes: 1
|
|
run: node control/.github/scripts/task-progress.mjs stop "$RUNNER_TEMP/agent-artifacts"
|
|
|
|
# Also the handoff and recovery checkpoint: see Confirm the handoff checkpoint.
|
|
- name: Upload Code Agent patch and diagnostics
|
|
id: agent-upload
|
|
if: always() && steps.rerun_guard.outcome == 'success'
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: factory-agent-${{ needs.prepare.outputs.issue_number }}
|
|
path: ${{ runner.temp }}/agent-artifacts
|
|
if-no-files-found: warn
|
|
retention-days: 14
|
|
|
|
# A failed build whose patch is empty on a new work branch has nothing a
|
|
# failed PR could show, and its push would only fail: publish-failed is
|
|
# skipped and the failure notice says so (FACTORY_EMPTY_PATCH). An empty
|
|
# patch on an existing work branch still marks that branch's PR failed,
|
|
# and so does a recovery's (expected_work_sha): its base is the target
|
|
# branch, yet the failed run's publication already created the branch.
|
|
- name: Check for a publishable diff
|
|
id: diff
|
|
if: always() && (steps.patch.outcome == 'success' || steps.keep_checkpoint.outcome == 'success')
|
|
# Must not fail the job or block the handoff dispatch below.
|
|
continue-on-error: true
|
|
env:
|
|
BASE_REF: ${{ needs.prepare.outputs.base_ref }}
|
|
WORK_BRANCH: ${{ needs.prepare.outputs.work_branch }}
|
|
EXPECTED_WORK_SHA: ${{ needs.prepare.outputs.expected_work_sha }}
|
|
run: |
|
|
patch="$RUNNER_TEMP/agent-artifacts/agent.patch"
|
|
if [[ "$BASE_REF" != "$WORK_BRANCH" && -z "$EXPECTED_WORK_SHA" && -f "$patch" && ! -L "$patch" && ! -s "$patch" ]]; then
|
|
echo "empty=true" >> "$GITHUB_OUTPUT"
|
|
echo "::notice::The saved patch is empty and ${WORK_BRANCH} does not exist yet, so no failed build PR is published."
|
|
echo "No application diff was saved; ${WORK_BRANCH} does not exist yet, so no failed build PR is published." >> "$GITHUB_STEP_SUMMARY"
|
|
fi
|
|
|
|
# Final verification, publication, failed preview packaging and the
|
|
# failure notice read only these files. They download this small copy;
|
|
# factory-agent-N stays the complete record for reports and diagnosis.
|
|
# Downstream jobs fall back to factory-agent-N when this copy is missing,
|
|
# so a transient artifact error here must not fail the job or block the
|
|
# handoff below.
|
|
- name: Stage the patch for downstream jobs
|
|
if: always() && steps.rerun_guard.outcome == 'success'
|
|
continue-on-error: true
|
|
run: |
|
|
mkdir -p "$RUNNER_TEMP/patch-artifacts"
|
|
[[ -d "$RUNNER_TEMP/agent-artifacts" ]] || exit 0
|
|
cd "$RUNNER_TEMP/agent-artifacts"
|
|
for file in agent.patch change-summary.json task-metadata.json pipeline-state.json; do
|
|
if [[ -f "$file" && ! -L "$file" ]]; then
|
|
cp "$file" "$RUNNER_TEMP/patch-artifacts/$file"
|
|
fi
|
|
done
|
|
# mark-failure.mjs explains a stop from the invocation results.
|
|
find . -type f -name 'agent*.jsonl.result.json' \
|
|
-exec cp --parents {} "$RUNNER_TEMP/patch-artifacts" \;
|
|
|
|
- name: Upload the patch for downstream jobs
|
|
if: always() && steps.rerun_guard.outcome == 'success'
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: factory-patch-${{ needs.prepare.outputs.issue_number }}
|
|
path: ${{ runner.temp }}/patch-artifacts
|
|
if-no-files-found: warn
|
|
retention-days: 14
|
|
|
|
# The checkpoint is factory-agent-N itself: it already holds this exact
|
|
# directory, so a second upload of it only doubled the time and storage of
|
|
# every failed or handed-off run. Continuations and explicit recoveries
|
|
# download factory-agent-N. This step keeps the contract the dispatch and
|
|
# the failure notice rely on: a checkpoint exists only when that upload
|
|
# succeeded and carries what a continuation restores.
|
|
- name: Confirm the handoff checkpoint
|
|
id: checkpoint
|
|
# Preserve a recovery checkpoint on failure, without treating it as
|
|
# successful verification or automatically retrying a broken runner.
|
|
if: always() && (steps.patch.outcome == 'success' || steps.keep_checkpoint.outcome == 'success') && (steps.handoff.outcome == 'success' || failure() || cancelled())
|
|
env:
|
|
AGENT_UPLOAD_OUTCOME: ${{ steps.agent-upload.outcome }}
|
|
ISSUE_NUMBER: ${{ needs.prepare.outputs.issue_number }}
|
|
run: |
|
|
if [[ "$AGENT_UPLOAD_OUTCOME" != 'success' ]]; then
|
|
echo "::error::factory-agent-${ISSUE_NUMBER} was not uploaded ($AGENT_UPLOAD_OUTCOME); no checkpoint was saved."
|
|
exit 1
|
|
fi
|
|
for file in agent.patch pipeline-state.json task-metadata.json; do
|
|
if [[ ! -f "$RUNNER_TEMP/agent-artifacts/$file" ]]; then
|
|
echo "::error::The checkpoint lacks $file."
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
- name: Dispatch continuation run
|
|
id: dispatch
|
|
# Explicit status function: without one, `success() &&` is implied and
|
|
# any earlier always() upload failure would skip the dispatch silently.
|
|
if: ${{ !cancelled() && steps.handoff.outcome == 'success' && steps.checkpoint.outcome == 'success' }}
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
BUILD_COMMENT_ID: ${{ needs.prepare.outputs.build_comment_id }}
|
|
ISSUE_NUMBER: ${{ needs.prepare.outputs.issue_number }}
|
|
CONTINUATION: ${{ steps.handoff.outputs.continuation }}
|
|
run: |
|
|
node bootstrap/.github/scripts/handoff.mjs dispatch \
|
|
--issue "$ISSUE_NUMBER" \
|
|
--previous-run-id "$GITHUB_RUN_ID" \
|
|
--continuation "$CONTINUATION"
|
|
|
|
- name: Record agent outcome
|
|
id: outcome
|
|
if: always()
|
|
# A handoff only counts once its checkpoint is saved and the successor
|
|
# was dispatched. Otherwise no continuation exists, so this run must
|
|
# still report its failure and publish what it has.
|
|
env:
|
|
HANDOFF_REQUESTED: ${{ steps.implementation.outputs.handoff == 'true' || steps.verify.outputs.handoff == 'true' }}
|
|
HANDOFF_OUTCOME: ${{ steps.handoff.outcome }}
|
|
CHECKPOINT_OUTCOME: ${{ steps.checkpoint.outcome }}
|
|
DISPATCH_OUTCOME: ${{ steps.dispatch.outcome }}
|
|
# Keep in step with this job's timeout-minutes.
|
|
JOB_TIMEOUT_SECONDS: '21600'
|
|
run: |
|
|
if [[ "$HANDOFF_OUTCOME" == "success" && "$CHECKPOINT_OUTCOME" == "success" && "$DISPATCH_OUTCOME" == "success" ]]; then
|
|
echo "handoff=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
if [[ "$HANDOFF_OUTCOME" == "success" ]]; then
|
|
echo "::error::Handoff was prepared but its checkpoint upload ($CHECKPOINT_OUTCOME) or continuation dispatch ($DISPATCH_OUTCOME) did not succeed; reporting this run as failed."
|
|
fi
|
|
echo "handoff=false" >> "$GITHUB_OUTPUT"
|
|
# The checkpoint still records the handoff and no successor runs:
|
|
# the failure notice offers recovery from it, and an explicit
|
|
# recovery accepts it once no continuation of this run exists
|
|
# (handoffDispatched in handoff-recovery.mjs). A refused handoff
|
|
# (spent budget) recorded its stop instead and stays unrecoverable.
|
|
if [[ "$HANDOFF_REQUESTED" == true ]]; then
|
|
echo "handoff_undispatched=true" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
fi
|
|
# GitHub reports a job that hit timeout-minutes as cancelled, the same
|
|
# as a manual cancel. Elapsed time tells them apart for the notice;
|
|
# five minutes of slack covers the cancellation itself.
|
|
timed_out=false
|
|
if [[ "${FACTORY_JOB_STARTED_EPOCH_SECONDS:-}" =~ ^[0-9]+$ ]] && \
|
|
(( $(date +%s) - FACTORY_JOB_STARTED_EPOCH_SECONDS >= JOB_TIMEOUT_SECONDS - 300 )); then
|
|
timed_out=true
|
|
echo "::error::The agent job reached its ${JOB_TIMEOUT_SECONDS}s runner limit before finishing."
|
|
fi
|
|
echo "timed_out=$timed_out" >> "$GITHUB_OUTPUT"
|
|
|
|
# A summary is diagnostics only; it must never fail the task.
|
|
- name: Summarize implementation and repair timings
|
|
if: always()
|
|
continue-on-error: true
|
|
run: node control/.github/scripts/timing-summary.mjs
|
|
|
|
verify-final:
|
|
needs: [prepare, agent]
|
|
if: needs.agent.result == 'success' && needs.agent.outputs.handoff != 'true'
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 90
|
|
outputs:
|
|
# A failed verification left its deployable build; see the upload below.
|
|
failed_dist: ${{ steps.failed_dist_upload.outcome == 'success' }}
|
|
env:
|
|
FACTORY_BUILD_TARGET: linux-x64
|
|
FACTORY_BUILD_NODE_VERSION: '24'
|
|
# Verification builds for the explicit deployment target and archives
|
|
# that same build with `--tar`; nothing is rebuilt for the preview.
|
|
FACTORY_BUILD_ARCHIVE: '1'
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Initialize final timing output
|
|
run: echo "FACTORY_TIMINGS_FILE=$RUNNER_TEMP/final-artifacts/timings.jsonl" >> "$GITHUB_ENV"
|
|
|
|
- name: Check out factory control plane
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.prepare.outputs.control_sha }}
|
|
path: control
|
|
persist-credentials: false
|
|
|
|
# The composite steps below come from this workflow's own commit, like the
|
|
# bootstrap checkout in prepare and agent: an in-flight task's pinned
|
|
# control_sha may predate them. The scripts they run still come from
|
|
# control/, so task code stays pinned.
|
|
- name: Check out the workflow's composite actions
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ github.sha }}
|
|
path: factory-actions
|
|
sparse-checkout-cone-mode: false
|
|
sparse-checkout: |
|
|
/.github/actions/
|
|
persist-credentials: false
|
|
|
|
- name: Apply the verified patch to a fresh application base
|
|
uses: ./factory-actions/.github/actions/apply-task-patch
|
|
with:
|
|
base-sha: ${{ needs.prepare.outputs.base_sha }}
|
|
issue-number: ${{ needs.prepare.outputs.issue_number }}
|
|
work-branch: ${{ needs.prepare.outputs.work_branch }}
|
|
|
|
# Retried once: a transient artifact error here would fail a build whose
|
|
# repairs already passed, with no checkpoint left to recover from.
|
|
- name: Download trusted final verification task
|
|
uses: ./factory-actions/.github/actions/download-with-retry
|
|
with:
|
|
name: factory-task-${{ needs.prepare.outputs.issue_number }}
|
|
path: final-task
|
|
|
|
# Restore only: the agent job of this run saved the pinned package (see
|
|
# its font steps); a miss installs through apt as before.
|
|
- name: Read the pinned Chinese font package
|
|
id: font_pin
|
|
env:
|
|
FONT_SCRIPT: control/.github/scripts/install-browser-fonts.sh
|
|
run: echo "sha=$(sed -n 's/^FONT_DEB_SHA256=\([0-9a-f]\{64\}\)$/\1/p' "$FONT_SCRIPT")" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Restore the pinned Chinese font package
|
|
if: steps.font_pin.outputs.sha != ''
|
|
continue-on-error: true
|
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
with:
|
|
path: browser-font-cache
|
|
key: browser-font-deb-${{ runner.os }}-${{ steps.font_pin.outputs.sha }}
|
|
|
|
- name: Install Chinese browser fonts
|
|
timeout-minutes: 10
|
|
env:
|
|
FACTORY_FONT_DEB_CACHE: ${{ github.workspace }}/browser-font-cache
|
|
run: bash control/.github/scripts/install-browser-fonts.sh
|
|
|
|
# pnpm, Node.js, the agent job's pnpm store (restored, never saved), the
|
|
# registry for nested pnpm roots and the selected source baseline.
|
|
- name: Restore the task toolchain and package sources
|
|
uses: ./factory-actions/.github/actions/restore-task-toolchain
|
|
with:
|
|
metadata: agent-artifacts/task-metadata.json
|
|
|
|
- name: Install application dependencies
|
|
timeout-minutes: 15
|
|
working-directory: workspace
|
|
run: node ../control/.github/scripts/timed-command.mjs install pnpm install --frozen-lockfile
|
|
|
|
- name: Create clean final verification configuration
|
|
run: |
|
|
mkdir -p "$RUNNER_TEMP/final-artifacts"
|
|
node control/.github/scripts/create-runtime-config.mjs \
|
|
--output "$RUNNER_TEMP/final.yml" \
|
|
--database "$RUNNER_TEMP/final/database.sqlite"
|
|
|
|
- name: Independently verify the applied patch
|
|
env:
|
|
FACTORY_REQUIRED_CHECKS_METADATA: ${{ github.workspace }}/final-task/task-metadata.json
|
|
FACTORY_REQUIRED_CHECKS_PATCH: ${{ github.workspace }}/agent-artifacts/agent.patch
|
|
FACTORY_TEST_API_KEY: ${{ secrets.FACTORY_TEST_API_KEY }}
|
|
FACTORY_TEST_ADMIN_KEY: ${{ secrets.FACTORY_TEST_ADMIN_KEY }}
|
|
# One more run of a failed `test` check only (verify.sh records it in
|
|
# test-retried); a template test flake must not fail a build whose
|
|
# repair loop already passed.
|
|
FACTORY_RETRY_TEST_ONCE: '1'
|
|
run: |
|
|
set -o pipefail
|
|
control/.github/scripts/verify.sh \
|
|
workspace \
|
|
"$RUNNER_TEMP/final.yml" \
|
|
"$RUNNER_TEMP/final-artifacts/verify-final" \
|
|
2>&1 | tee "$RUNNER_TEMP/final-artifacts/verify-final.log"
|
|
|
|
# The preview workflow downloads this artifact and reads both files from its
|
|
# root: `dist.tar.gz` to deploy and `task-metadata.json` to know which PR and
|
|
# target branch the build belongs to. They are staged into one directory
|
|
# first because `upload-artifact` keeps the structure below the paths' common
|
|
# ancestor, and these two live in unrelated directories of the job.
|
|
# The package only feeds the preview, which never decides acceptance:
|
|
# a transient artifact error here must not turn a passed verification
|
|
# into a failed delivery. deploy-preview reports the missing package
|
|
# (selectDistArtifact keys on "Report a missing deployable build").
|
|
- name: Stage the deployable build and its metadata
|
|
id: dist_stage
|
|
continue-on-error: true
|
|
run: |
|
|
mkdir -p "$RUNNER_TEMP/deployable"
|
|
cp workspace/storage/exports/dist.tar.gz "$RUNNER_TEMP/deployable/dist.tar.gz"
|
|
cp agent-artifacts/task-metadata.json "$RUNNER_TEMP/deployable/task-metadata.json"
|
|
|
|
- name: Upload the deployable build
|
|
id: dist_upload
|
|
if: steps.dist_stage.outcome == 'success'
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: factory-dist-${{ needs.prepare.outputs.issue_number }}
|
|
path: ${{ runner.temp }}/deployable
|
|
if-no-files-found: error
|
|
retention-days: 14
|
|
|
|
- name: Report a missing deployable build
|
|
if: steps.dist_stage.outcome == 'failure' || steps.dist_upload.outcome == 'failure'
|
|
run: |
|
|
echo "::warning::The verified build could not be packaged for the preview; verification and delivery are unaffected, but this run has no preview."
|
|
echo 'The verified build could not be packaged for the preview; delivery is unaffected.' >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
# verify.sh builds and archives before its database, browser and required
|
|
# checks. When one of those fails, the archive is the same linux-x64 build
|
|
# preview-build-failed would install and build a second time for the failed
|
|
# PR's preview; keep it instead, and that job is skipped.
|
|
- name: Stage the failed verification's build for the preview
|
|
id: failed_dist
|
|
if: failure() && hashFiles('workspace/storage/exports/dist.tar.gz') != ''
|
|
continue-on-error: true
|
|
run: |
|
|
mkdir -p "$RUNNER_TEMP/deployable"
|
|
cp workspace/storage/exports/dist.tar.gz "$RUNNER_TEMP/deployable/dist.tar.gz"
|
|
cp agent-artifacts/task-metadata.json "$RUNNER_TEMP/deployable/task-metadata.json"
|
|
|
|
- name: Upload the failed verification's build
|
|
id: failed_dist_upload
|
|
if: failure() && steps.failed_dist.outcome == 'success'
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: factory-dist-${{ needs.prepare.outputs.issue_number }}
|
|
path: ${{ runner.temp }}/deployable
|
|
if-no-files-found: error
|
|
retention-days: 14
|
|
|
|
- name: Stop the final verifier source registry
|
|
if: always()
|
|
continue-on-error: true
|
|
run: |
|
|
if [[ -f workspace/factory-source.json ]]; then
|
|
node control/.github/scripts/source-snapshot.mjs stop
|
|
fi
|
|
|
|
# Diagnostics only: publish requires this job to succeed, so a malformed
|
|
# timing row or a transient artifact error here must not turn a passed
|
|
# verification into a failed delivery.
|
|
- name: Summarize final timings
|
|
if: always()
|
|
continue-on-error: true
|
|
run: node control/.github/scripts/timing-summary.mjs
|
|
|
|
- name: Upload final verification diagnostics
|
|
if: always()
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: factory-final-${{ needs.prepare.outputs.issue_number }}
|
|
path: ${{ runner.temp }}/final-artifacts
|
|
if-no-files-found: warn
|
|
retention-days: 14
|
|
|
|
publish:
|
|
needs: [prepare, agent, verify-final]
|
|
if: needs.verify-final.result == 'success'
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 15
|
|
permissions:
|
|
contents: write
|
|
issues: write
|
|
pull-requests: write
|
|
env:
|
|
FACTORY_DELIVERY_STATUS: success
|
|
steps: &publication-steps
|
|
- name: Check out factory control plane
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.prepare.outputs.control_sha }}
|
|
path: control
|
|
persist-credentials: false
|
|
|
|
# The composite steps below come from this workflow's own commit, like the
|
|
# bootstrap checkout in prepare and agent: an in-flight task's pinned
|
|
# control_sha may predate them. The scripts they run still come from
|
|
# control/, so task code stays pinned.
|
|
- name: Check out the workflow's composite actions
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ github.sha }}
|
|
path: factory-actions
|
|
sparse-checkout-cone-mode: false
|
|
sparse-checkout: |
|
|
/.github/actions/
|
|
persist-credentials: false
|
|
|
|
# One commit is enough to apply the patch. The lease below names the
|
|
# expected remote value explicitly instead of reading a tracking ref;
|
|
# publication-base.mjs fetches whatever a new branch's push needs. The
|
|
# checkout keeps its token: this job pushes the task branch.
|
|
- name: Apply the verified patch to a fresh application base
|
|
uses: ./factory-actions/.github/actions/apply-task-patch
|
|
with:
|
|
base-sha: ${{ needs.prepare.outputs.base_sha }}
|
|
issue-number: ${{ needs.prepare.outputs.issue_number }}
|
|
work-branch: ${{ needs.prepare.outputs.work_branch }}
|
|
persist-credentials: 'true'
|
|
|
|
# A new work branch whose base fell behind only on factory files is
|
|
# published on the current branch head, so its workflow files match the
|
|
# repository's; otherwise the verified base stays. See QA_PIPELINE.md.
|
|
- name: Choose the publication base
|
|
env:
|
|
BASE_REF: ${{ needs.prepare.outputs.base_ref }}
|
|
BASE_SHA: ${{ needs.prepare.outputs.base_sha }}
|
|
WORK_BRANCH: ${{ needs.prepare.outputs.work_branch }}
|
|
run: |
|
|
node control/.github/scripts/publication-base.mjs \
|
|
--workspace workspace \
|
|
--patch agent-artifacts/agent.patch \
|
|
--branch "$WORK_BRANCH" \
|
|
--base-ref "$BASE_REF" \
|
|
--base-sha "$BASE_SHA" \
|
|
--record agent-artifacts/publication-base.json
|
|
|
|
# A re-run attempt reads the commit an earlier attempt of this run
|
|
# pushed (the record is uploaded right after the push): the push below
|
|
# keeps that commit when it holds the same tree, even after the target
|
|
# branch moved and the publication base was restacked onto a new parent.
|
|
- name: Download this run's publication record
|
|
if: github.run_attempt > 1
|
|
continue-on-error: true
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: factory-published-${{ needs.prepare.outputs.issue_number }}
|
|
path: previous-publication
|
|
|
|
- name: Commit and push task branch
|
|
id: push
|
|
working-directory: workspace
|
|
env:
|
|
BASE_REF: ${{ needs.prepare.outputs.base_ref }}
|
|
BASE_SHA: ${{ needs.prepare.outputs.base_sha }}
|
|
ISSUE_NUMBER: ${{ needs.prepare.outputs.issue_number }}
|
|
WORK_BRANCH: ${{ needs.prepare.outputs.work_branch }}
|
|
# A recovery replacing the commit its failed run published.
|
|
EXPECTED_WORK_SHA: ${{ needs.prepare.outputs.expected_work_sha }}
|
|
run: |
|
|
git config user.name "github-actions[bot]"
|
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
# A recovery keeps the target as its base even though the failed run's
|
|
# publication created the work branch (EXPECTED_WORK_SHA): its empty
|
|
# diff goes on to replace that published commit below.
|
|
if git diff --cached --quiet && [[ "$BASE_REF" != "$WORK_BRANCH" ]] && \
|
|
[[ ! "$EXPECTED_WORK_SHA" =~ ^[a-f0-9]{40}$ ]]; then
|
|
echo "::error::No application diff was saved; GitHub cannot create a build PR. The failed report and logs remain available."
|
|
exit 1
|
|
fi
|
|
push_needed=true
|
|
if git diff --cached --quiet; then
|
|
# The verified tree is the base itself. Reuse the branch only when
|
|
# it is already at that commit: a recovery whose repair emptied the
|
|
# patch must still replace the failed run's published commit, or the
|
|
# PR would keep showing unverified content.
|
|
if [[ "$EXPECTED_WORK_SHA" =~ ^[a-f0-9]{40}$ ]] && \
|
|
[[ "$(git ls-remote origin "refs/heads/${WORK_BRANCH}" | cut -f1)" != "$(git rev-parse HEAD)" ]]; then
|
|
echo "The verified tree equals ${BASE_SHA}; replacing the failed run's published commit ${EXPECTED_WORK_SHA} with it."
|
|
else
|
|
echo "No new application changes; reusing the existing work branch commit."
|
|
push_needed=false
|
|
fi
|
|
else
|
|
git commit -m "feat: apply Code Agent task #${ISSUE_NUMBER}"
|
|
fi
|
|
# A GitHub Re-run of this job after its push landed (and, say, the PR
|
|
# creation failed) commits the same tree again under a new commit id,
|
|
# which the lease below refuses. A remote work branch that already
|
|
# holds exactly this commit's tree is that publication when it sits on
|
|
# the same parent, or when this run's publication record names it
|
|
# (the target moved between attempts and the base was restacked):
|
|
# keep its commit rather than pushing an identical tree. Any other
|
|
# commit with that tree is not provably this run's and is still pushed
|
|
# under the lease, which refuses it.
|
|
if [[ "$push_needed" == true ]]; then
|
|
remote="$(git ls-remote origin "refs/heads/${WORK_BRANCH}" | cut -f1)"
|
|
parents() { git cat-file -p "$1" | sed -n 's/^parent //p'; }
|
|
recorded=''
|
|
record=../previous-publication/publication.json
|
|
if [[ -f "$record" && ! -L "$record" ]]; then
|
|
recorded="$(node -e '
|
|
const [file, runId, branch] = process.argv.slice(1);
|
|
const value = JSON.parse(require("fs").readFileSync(file, "utf8"));
|
|
if (value?.version === 1 && String(value.sourceRunId) === runId && value.workBranch === branch &&
|
|
/^[a-f0-9]{40}$/.test(value.commit ?? "")) console.log(value.commit);
|
|
' "$record" "$GITHUB_RUN_ID" "$WORK_BRANCH" 2>/dev/null || true)"
|
|
fi
|
|
if [[ "$remote" == "$(git rev-parse HEAD)" ]]; then
|
|
echo "${WORK_BRANCH} is already at this commit."
|
|
push_needed=false
|
|
elif [[ -n "$remote" ]] && \
|
|
git fetch --quiet --no-tags --depth=1 origin "refs/heads/${WORK_BRANCH}" && \
|
|
[[ "$(git rev-parse FETCH_HEAD)" == "$remote" ]] && \
|
|
[[ "$(git rev-parse 'FETCH_HEAD^{tree}')" == "$(git rev-parse 'HEAD^{tree}')" ]] && \
|
|
{ [[ "$(parents FETCH_HEAD)" == "$(parents HEAD)" ]] || [[ "$remote" == "$recorded" ]]; }; then
|
|
echo "${WORK_BRANCH} already holds this verified tree at ${remote}, published by this task; keeping that commit instead of pushing again."
|
|
git reset --quiet --soft "$remote"
|
|
push_needed=false
|
|
fi
|
|
fi
|
|
if [[ "$push_needed" == true ]]; then
|
|
if [[ "$EXPECTED_WORK_SHA" =~ ^[a-f0-9]{40}$ ]]; then
|
|
# Only the failed run's own published commit may be replaced.
|
|
lease="refs/heads/${WORK_BRANCH}:${EXPECTED_WORK_SHA}"
|
|
elif [[ "$BASE_REF" == "$WORK_BRANCH" ]]; then
|
|
lease="refs/heads/${WORK_BRANCH}:${BASE_SHA}"
|
|
else
|
|
lease="refs/heads/${WORK_BRANCH}:"
|
|
fi
|
|
# A transient network or server error is retried; a rejection is
|
|
# final: a refused workflow change, a lease that no longer matches or
|
|
# a non-fast-forward. A push whose response was lost may have landed,
|
|
# so a retry first checks whether the remote already has this commit.
|
|
pushed=false
|
|
for attempt in 1 2 3; do
|
|
if (( attempt > 1 )) && [[ "$(git ls-remote origin "refs/heads/${WORK_BRANCH}" | cut -f1)" == "$(git rev-parse HEAD)" ]]; then
|
|
echo "The previous push attempt reached ${WORK_BRANCH}."
|
|
pushed=true
|
|
break
|
|
fi
|
|
if git push --force-with-lease="$lease" origin "HEAD:refs/heads/${WORK_BRANCH}" 2> "$RUNNER_TEMP/push.log"; then
|
|
pushed=true
|
|
break
|
|
fi
|
|
cat "$RUNNER_TEMP/push.log" >&2
|
|
if grep -qE 'without `workflows` permission|stale info|\[rejected\]|\[remote rejected\]|non-fast-forward|Permission to .* denied' "$RUNNER_TEMP/push.log"; then
|
|
break
|
|
fi
|
|
if (( attempt < 3 )); then
|
|
echo "::warning::Push attempt $attempt failed; retrying."
|
|
sleep $((attempt * 10))
|
|
fi
|
|
done
|
|
if [[ "$pushed" != true ]]; then
|
|
if grep -q 'without `workflows` permission' "$RUNNER_TEMP/push.log"; then
|
|
echo "::error::GitHub refused ${WORK_BRANCH} because the pushed commits would change workflow files, which the task token may not do. The task was verified on ${BASE_SHA}; the target branch moved since with changes the publisher could not restack onto (see Choose the publication base). The verified patch is in the factory-patch-${ISSUE_NUMBER} artifact: apply it with git apply --3way on the current target branch, push ${WORK_BRANCH} with credentials allowed to change workflows, and open the PR by hand."
|
|
fi
|
|
exit 1
|
|
fi
|
|
cat "$RUNNER_TEMP/push.log" >&2
|
|
fi
|
|
# What the work branch now holds, for a recovery from this run: a
|
|
# branch still at this commit is this run's own publication, not
|
|
# newer work (liveRecoveryBase in handoff-recovery.mjs).
|
|
mkdir -p "$RUNNER_TEMP/publication-record"
|
|
node -e '
|
|
const [file, issue, branch, commit, runId, attempt, status] = process.argv.slice(1);
|
|
require("fs").writeFileSync(file, JSON.stringify({ version: 1, issue: Number(issue), workBranch: branch,
|
|
commit, sourceRunId: Number(runId), attempt: Number(attempt), status }, null, 2) + "\n");
|
|
' "$RUNNER_TEMP/publication-record/publication.json" "$ISSUE_NUMBER" "$WORK_BRANCH" \
|
|
"$(git rev-parse HEAD)" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$FACTORY_DELIVERY_STATUS"
|
|
|
|
# Small and best effort: without it a recovery refuses (the branch looks
|
|
# moved) instead of overwriting anything.
|
|
- name: Upload the publication record
|
|
if: steps.push.outcome == 'success'
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: factory-published-${{ needs.prepare.outputs.issue_number }}
|
|
path: ${{ runner.temp }}/publication-record/publication.json
|
|
if-no-files-found: warn
|
|
retention-days: 14
|
|
|
|
- name: Create or update Pull Request
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: |
|
|
node control/.github/scripts/publish-pr.mjs \
|
|
--metadata agent-artifacts/task-metadata.json \
|
|
--summary agent-artifacts/change-summary.json --status "$FACTORY_DELIVERY_STATUS" \
|
|
--publication agent-artifacts/publication-base.json
|
|
|
|
# Preserve failed work without treating publication as successful acceptance.
|
|
publish-failed:
|
|
needs: [prepare, agent, verify-final]
|
|
if: >-
|
|
always() && !cancelled() &&
|
|
needs.prepare.outputs.status == 'ready' &&
|
|
needs.prepare.outputs.comment_kind != 'reply' &&
|
|
needs.agent.outputs.handoff != 'true' &&
|
|
needs.agent.outputs.patch_available == 'true' &&
|
|
needs.agent.outputs.empty_patch != 'true' &&
|
|
(needs.agent.result != 'success' || needs.verify-final.result != 'success')
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 15
|
|
permissions:
|
|
contents: write
|
|
issues: write
|
|
pull-requests: write
|
|
env:
|
|
FACTORY_DELIVERY_STATUS: failed
|
|
steps: *publication-steps
|
|
|
|
# FACTORY_PREVIEW_ENABLED=false skips packaging when no preview host is
|
|
# configured: deploy-preview would discard the package anyway. A job
|
|
# condition cannot read secrets, hence the variable; unset keeps it on.
|
|
preview-build-failed:
|
|
needs: [prepare, agent, verify-final, publish-failed]
|
|
# Skipped when verify-final already uploaded the build it made before a
|
|
# later check failed: deploy-preview takes that factory-dist-N instead.
|
|
if: always() && !cancelled() && needs.publish-failed.result == 'success' && vars.FACTORY_PREVIEW_ENABLED != 'false' && needs.verify-final.outputs.failed_dist != 'true'
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 30
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Initialize failed preview timing output
|
|
run: echo "FACTORY_TIMINGS_FILE=$RUNNER_TEMP/final-artifacts/timings.jsonl" >> "$GITHUB_ENV"
|
|
|
|
- name: Check out factory control plane
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.prepare.outputs.control_sha }}
|
|
path: control
|
|
persist-credentials: false
|
|
|
|
# The composite steps below come from this workflow's own commit, like the
|
|
# bootstrap checkout in prepare and agent: an in-flight task's pinned
|
|
# control_sha may predate them. The scripts they run still come from
|
|
# control/, so task code stays pinned.
|
|
- name: Check out the workflow's composite actions
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ github.sha }}
|
|
path: factory-actions
|
|
sparse-checkout-cone-mode: false
|
|
sparse-checkout: |
|
|
/.github/actions/
|
|
persist-credentials: false
|
|
|
|
- name: Apply the verified patch to a fresh application base
|
|
uses: ./factory-actions/.github/actions/apply-task-patch
|
|
with:
|
|
base-sha: ${{ needs.prepare.outputs.base_sha }}
|
|
issue-number: ${{ needs.prepare.outputs.issue_number }}
|
|
work-branch: ${{ needs.prepare.outputs.work_branch }}
|
|
|
|
# pnpm, Node.js, the agent job's pnpm store (restored, never saved), the
|
|
# registry for nested pnpm roots and the selected source baseline.
|
|
- name: Restore the task toolchain and package sources
|
|
uses: ./factory-actions/.github/actions/restore-task-toolchain
|
|
with:
|
|
metadata: agent-artifacts/task-metadata.json
|
|
|
|
# A failed task can leave package.json and its lockfile out of step. The
|
|
# preview of that work then installs without freezing, but only for
|
|
# ERR_PNPM_OUTDATED_LOCKFILE; any other install failure still fails.
|
|
- name: Install application dependencies
|
|
timeout-minutes: 15
|
|
working-directory: workspace
|
|
run: |
|
|
set -o pipefail
|
|
status=0
|
|
node ../control/.github/scripts/timed-command.mjs install pnpm install --frozen-lockfile 2>&1 | tee "$RUNNER_TEMP/install.log" || status=$?
|
|
if (( status != 0 )); then
|
|
grep -q ERR_PNPM_OUTDATED_LOCKFILE "$RUNNER_TEMP/install.log" || exit "$status"
|
|
echo "::warning::The failed work's pnpm-lock.yaml does not match its package.json; installing the preview without --frozen-lockfile."
|
|
node ../control/.github/scripts/timed-command.mjs install-refresh-lockfile pnpm install --no-frozen-lockfile
|
|
fi
|
|
|
|
- name: Create isolated failed preview build configuration
|
|
run: |
|
|
mkdir -p "$RUNNER_TEMP/final-artifacts"
|
|
node control/.github/scripts/create-runtime-config.mjs \
|
|
--output "$RUNNER_TEMP/final.yml" \
|
|
--database "$RUNNER_TEMP/final/database.sqlite"
|
|
|
|
- name: Attempt packaging the failed implementation
|
|
working-directory: workspace
|
|
env:
|
|
APP_CONFIG_FILE: ${{ runner.temp }}/final.yml
|
|
NODE_ENV: production
|
|
run: |
|
|
set -o pipefail
|
|
node ../control/.github/scripts/timed-command.mjs preview-build pnpm build --target linux-x64 --node-version 24 --tar 2>&1 | tee $RUNNER_TEMP/final-artifacts/preview-build.log
|
|
|
|
# The preview workflow downloads this artifact and reads both files from its
|
|
# root: `dist.tar.gz` to deploy and `task-metadata.json` to know which PR and
|
|
# target branch the build belongs to. They are staged into one directory
|
|
# first because `upload-artifact` keeps the structure below the paths' common
|
|
# ancestor, and these two live in unrelated directories of the job.
|
|
- name: Stage the deployable build and its metadata
|
|
run: |
|
|
mkdir -p "$RUNNER_TEMP/deployable"
|
|
cp workspace/storage/exports/dist.tar.gz "$RUNNER_TEMP/deployable/dist.tar.gz"
|
|
cp agent-artifacts/task-metadata.json "$RUNNER_TEMP/deployable/task-metadata.json"
|
|
|
|
- name: Upload the deployable build
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: factory-dist-${{ needs.prepare.outputs.issue_number }}
|
|
overwrite: true
|
|
path: ${{ runner.temp }}/deployable
|
|
if-no-files-found: error
|
|
retention-days: 14
|
|
|
|
- name: Stop the failed preview source registry
|
|
if: always()
|
|
continue-on-error: true
|
|
run: |
|
|
if [[ -f workspace/factory-source.json ]]; then
|
|
node control/.github/scripts/source-snapshot.mjs stop
|
|
fi
|
|
|
|
- name: Summarize failed preview packaging timings
|
|
if: always()
|
|
# Diagnostics only: a failure here must not report a package that was
|
|
# uploaded above as missing.
|
|
continue-on-error: true
|
|
run: node control/.github/scripts/timing-summary.mjs
|
|
|
|
- name: Upload failed preview packaging diagnostics
|
|
if: always()
|
|
# Diagnostics only: a failure here must not report a package that was
|
|
# uploaded above as missing.
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: factory-preview-build-${{ needs.prepare.outputs.issue_number }}
|
|
path: ${{ runner.temp }}/final-artifacts
|
|
if-no-files-found: warn
|
|
retention-days: 14
|
|
|
|
# Runs only when the run itself was cancelled. A job that hit its
|
|
# timeout-minutes is also 'cancelled' in needs.*, so report-failure cannot
|
|
# tell the two apart from its needs, and a step-level cancelled() there is
|
|
# always false: an always() job is not cancelled with the run.
|
|
run-cancelled:
|
|
needs:
|
|
[
|
|
prepare,
|
|
agent,
|
|
verify-final,
|
|
publish,
|
|
publish-failed,
|
|
preview-build-failed,
|
|
]
|
|
if: cancelled()
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 2
|
|
permissions: {}
|
|
steps:
|
|
- name: Record that the run was cancelled
|
|
run: 'true'
|
|
|
|
report-failure:
|
|
needs:
|
|
[
|
|
prepare,
|
|
agent,
|
|
verify-final,
|
|
publish,
|
|
publish-failed,
|
|
preview-build-failed,
|
|
run-cancelled,
|
|
]
|
|
if: >-
|
|
always() &&
|
|
needs.prepare.result == 'success' &&
|
|
needs.prepare.outputs.status == 'ready' &&
|
|
needs.prepare.outputs.comment_kind != 'reply' &&
|
|
needs.agent.outputs.handoff != 'true' &&
|
|
(needs.agent.result != 'success' ||
|
|
needs.verify-final.result != 'success' ||
|
|
needs.publish.result != 'success')
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 10
|
|
permissions:
|
|
contents: read
|
|
issues: write
|
|
steps:
|
|
- name: Check out factory control plane
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.prepare.outputs.control_sha }}
|
|
path: control
|
|
persist-credentials: false
|
|
# mark-failure.mjs and everything it imports live here.
|
|
sparse-checkout: .github/scripts
|
|
|
|
- name: Download failure diagnostics
|
|
id: patch_copy
|
|
continue-on-error: true
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: factory-patch-${{ needs.prepare.outputs.issue_number }}
|
|
path: failure-artifacts
|
|
|
|
# The agent job tolerates a failed upload of the small copy; the complete
|
|
# factory-agent-N record carries the same files.
|
|
- name: Fall back to the complete Code Agent artifact
|
|
if: steps.patch_copy.outcome == 'failure'
|
|
continue-on-error: true
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: factory-agent-${{ needs.prepare.outputs.issue_number }}
|
|
path: failure-artifacts
|
|
|
|
# This job deliberately runs on cancellation (always()) so the Issue
|
|
# leaves agent:running; the notice says it was cancelled. Only a real
|
|
# cancellation of the run counts (run-cancelled): GitHub also reports a
|
|
# job that hit its timeout-minutes as cancelled, which is a failure.
|
|
- name: Mark Issue as failed
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
FACTORY_CHECKPOINT_AVAILABLE: ${{ needs.agent.outputs.checkpoint_available }}
|
|
FACTORY_PREVIEW_BUILD_RESULT: ${{ needs.preview-build-failed.result }}
|
|
FACTORY_RUN_CANCELLED: ${{ needs.run-cancelled.result == 'success' }}
|
|
FACTORY_RUN_TIMED_OUT: ${{ needs.agent.outputs.timed_out }}
|
|
FACTORY_RERUN_REJECTED: ${{ needs.agent.outputs.rerun_rejected }}
|
|
FACTORY_HANDOFF_UNDISPATCHED: ${{ needs.agent.outputs.handoff_undispatched }}
|
|
FACTORY_EMPTY_PATCH: ${{ needs.agent.outputs.empty_patch }}
|
|
ISSUE_NUMBER: ${{ needs.prepare.outputs.issue_number }}
|
|
run: node control/.github/scripts/mark-failure.mjs "$ISSUE_NUMBER" failure-artifacts
|
|
|
|
dispatch-reports:
|
|
# The dispatched report runs wait only a few minutes for this run to
|
|
# complete, so dispatch after the comment reply jobs have finished too.
|
|
needs:
|
|
[
|
|
prepare,
|
|
agent,
|
|
verify-final,
|
|
publish,
|
|
publish-failed,
|
|
preview-build-failed,
|
|
report-failure,
|
|
reply,
|
|
publish-reply,
|
|
]
|
|
# prepare exposes status=ready before its artifact upload; a failed prepare
|
|
# has no factory-task-N for the reports to read. A "Re-run all jobs" that
|
|
# prepare rejected still runs this job with every request marked handled
|
|
# and none dispatched, so the workflow_run copies skip that empty attempt.
|
|
if: >-
|
|
always() &&
|
|
((needs.prepare.result == 'success' && needs.prepare.outputs.status == 'ready' && needs.prepare.outputs.comment_kind != 'reply') ||
|
|
needs.prepare.outputs.rerun_rejected == 'true')
|
|
runs-on: ubuntu-24.04
|
|
# Up to seven requests at 70 s each (dispatch-task-reports.sh) plus checkout.
|
|
# Continuations have no workflow_run fallback, so the last reports must fit.
|
|
timeout-minutes: 10
|
|
continue-on-error: true
|
|
permissions:
|
|
contents: read
|
|
actions: write
|
|
steps:
|
|
# Isolated from application execution and from all model/media secrets.
|
|
# The dispatcher comes from this workflow file's own revision, not the
|
|
# task's pinned control SHA: continuations, recoveries and evaluation
|
|
# samples pin an older control plane whose dispatcher ignores its
|
|
# arguments, and each step below would then request every report. It
|
|
# carries no task logic, and the steps below are its only callers.
|
|
- name: Check out the report dispatcher
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ github.workflow_sha }}
|
|
path: dispatcher
|
|
persist-credentials: false
|
|
sparse-checkout-cone-mode: false
|
|
sparse-checkout: |
|
|
/.github/scripts/dispatch-task-reports.sh
|
|
# One step per report (including continuations): report-dispatch-gate.yml
|
|
# reads each step by name, so a failed request makes only that report's
|
|
# workflow_run copy do the work, not all six. Each step runs even after an
|
|
# earlier one failed; the job's continue-on-error keeps the task result.
|
|
- name: Request report-task-progress.yml
|
|
id: request-report-task-progress
|
|
if: ${{ !cancelled() }}
|
|
env: &report-dispatch-env
|
|
GH_TOKEN: ${{ github.token }}
|
|
SOURCE_RUN_ID: ${{ github.run_id }}
|
|
SOURCE_ATTEMPT: ${{ github.run_attempt }}
|
|
FACTORY_REPORT_REF: ${{ github.event.repository.default_branch }}
|
|
FACTORY_TASK_PUBLISHED: ${{ needs.publish.result == 'success' || needs.publish-failed.result == 'success' }}
|
|
# A rejected Re-run requests no report; see dispatch-task-reports.sh.
|
|
# Rejected in prepare ("Re-run all jobs") it requests nothing at all.
|
|
FACTORY_RERUN_REJECTED: ${{ needs.agent.outputs.rerun_rejected == 'true' || needs.prepare.outputs.rerun_rejected == 'true' }}
|
|
FACTORY_PREPARE_REJECTED: ${{ needs.prepare.outputs.rerun_rejected == 'true' }}
|
|
# Names report-task-progress.yml's per-Issue concurrency group.
|
|
ISSUE_NUMBER: ${{ needs.prepare.outputs.issue_number }}
|
|
run: bash dispatcher/.github/scripts/dispatch-task-reports.sh report-task-progress.yml
|
|
- name: Request report-task-usage.yml
|
|
id: request-report-task-usage
|
|
if: ${{ !cancelled() }}
|
|
env: *report-dispatch-env
|
|
run: bash dispatcher/.github/scripts/dispatch-task-reports.sh report-task-usage.yml
|
|
- name: Request publish-agent-history.yml
|
|
id: request-publish-agent-history
|
|
if: ${{ !cancelled() }}
|
|
env: *report-dispatch-env
|
|
run: bash dispatcher/.github/scripts/dispatch-task-reports.sh publish-agent-history.yml
|
|
- name: Request publish-retro.yml
|
|
id: request-publish-retro
|
|
if: ${{ !cancelled() }}
|
|
env: *report-dispatch-env
|
|
run: bash dispatcher/.github/scripts/dispatch-task-reports.sh publish-retro.yml
|
|
- name: Request publish-visual-report.yml
|
|
id: request-publish-visual-report
|
|
if: ${{ !cancelled() }}
|
|
env: *report-dispatch-env
|
|
run: bash dispatcher/.github/scripts/dispatch-task-reports.sh publish-visual-report.yml
|
|
# A comment round was started with GITHUB_TOKEN, so its completion emits
|
|
# no workflow_run for the comment queue; request the queue directly so the
|
|
# next instruction starts now instead of at a late hourly sweep. The queue
|
|
# waits for this run to complete before it reconciles.
|
|
- name: Request comment-build-queue.yml
|
|
id: request-comment-build-queue
|
|
if: ${{ !cancelled() && needs.prepare.outputs.build_comment_id != '' }}
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
SOURCE_RUN_ID: ${{ github.run_id }}
|
|
SOURCE_ATTEMPT: ${{ github.run_attempt }}
|
|
FACTORY_REPORT_REF: ${{ github.event.repository.default_branch }}
|
|
ISSUE_NUMBER: ${{ needs.prepare.outputs.issue_number }}
|
|
run: bash dispatcher/.github/scripts/dispatch-task-reports.sh comment-build-queue.yml
|
|
|
|
reply:
|
|
env:
|
|
FACTORY_CONTROL_SHA: ${{ needs.prepare.outputs.control_sha }}
|
|
needs: [prepare, agent, verify-final, publish, publish-failed]
|
|
if: >-
|
|
always() && !cancelled() &&
|
|
needs.prepare.result == 'success' &&
|
|
needs.prepare.outputs.status == 'ready' &&
|
|
needs.prepare.outputs.build_comment_id != '' &&
|
|
needs.agent.outputs.handoff != 'true' &&
|
|
needs.agent.outputs.rerun_rejected != 'true'
|
|
runs-on: ubuntu-24.04
|
|
# 10 install + 15 reply budget, plus checkouts, setup and the upload.
|
|
timeout-minutes: 30
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.prepare.outputs.control_sha }}
|
|
path: control
|
|
persist-credentials: false
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ (needs.publish.result == 'success' || needs.publish-failed.result == 'success') && needs.prepare.outputs.work_branch || needs.prepare.outputs.base_sha }}
|
|
path: workspace
|
|
persist-credentials: false
|
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
with:
|
|
node-version: 24.x
|
|
# A comment round whose download fails once would otherwise end with no
|
|
# reply at all: both reply jobs retry their downloads (see verify-final).
|
|
- name: Check out the workflow's composite actions
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ github.sha }}
|
|
path: factory-actions
|
|
sparse-checkout-cone-mode: false
|
|
sparse-checkout: |
|
|
/.github/actions/
|
|
persist-credentials: false
|
|
- name: Download the task metadata
|
|
uses: ./factory-actions/.github/actions/download-with-retry
|
|
with:
|
|
name: factory-task-${{ needs.prepare.outputs.issue_number }}
|
|
path: task
|
|
- name: Build reply prompt
|
|
env:
|
|
BUILD_RESULT: ${{ needs.publish-failed.result == 'success' && 'failure' || needs.publish.result }}
|
|
run: node control/.github/scripts/comment-reply.mjs prompt task/task-metadata.json "$RUNNER_TEMP"
|
|
- name: Install pinned Code Agent
|
|
timeout-minutes: 10
|
|
env: *agent-install-env
|
|
run: node control/.github/scripts/install-agent.mjs
|
|
- name: Answer source comment from current code
|
|
env: *agent-run-env
|
|
run: |
|
|
CODE_AGENT_INVOCATION_TIMEOUT_SECONDS=900 node control/.github/scripts/run-agent.mjs \
|
|
--workspace workspace \
|
|
--prompt "$RUNNER_TEMP/comment-prompt.md" \
|
|
--log "$RUNNER_TEMP/comment-artifacts/comment-agent.jsonl" \
|
|
--agentDir "$RUNNER_TEMP/comment-agent"
|
|
- name: Collect comment reply diagnostics
|
|
if: always()
|
|
env: *agent-run-env
|
|
run: node control/.github/scripts/agent-invocation-record.mjs stage-reply "$RUNNER_TEMP" task/task-metadata.json
|
|
|
|
- name: Save reply for trusted publisher
|
|
if: always()
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: factory-comment-reply-${{ needs.prepare.outputs.build_comment_id }}
|
|
path: ${{ runner.temp }}/comment-artifacts
|
|
if-no-files-found: error
|
|
retention-days: 14
|
|
|
|
publish-reply:
|
|
needs: [prepare, reply]
|
|
if: ${{ !cancelled() && needs.reply.result == 'success' }}
|
|
runs-on: ubuntu-24.04
|
|
# Two retried downloads may each wait 30 seconds before their retry.
|
|
timeout-minutes: 7
|
|
permissions:
|
|
contents: read
|
|
issues: write
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.prepare.outputs.control_sha }}
|
|
persist-credentials: false
|
|
# comment-reply.mjs and everything it imports live here.
|
|
sparse-checkout: .github/scripts
|
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
with:
|
|
node-version: 24.x
|
|
- name: Check out the workflow's composite actions
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ github.sha }}
|
|
path: factory-actions
|
|
sparse-checkout-cone-mode: false
|
|
sparse-checkout: |
|
|
/.github/actions/
|
|
persist-credentials: false
|
|
- name: Download the task metadata
|
|
uses: ./factory-actions/.github/actions/download-with-retry
|
|
with:
|
|
name: factory-task-${{ needs.prepare.outputs.issue_number }}
|
|
path: task
|
|
- name: Download the reply
|
|
uses: ./factory-actions/.github/actions/download-with-retry
|
|
with:
|
|
name: factory-comment-reply-${{ needs.prepare.outputs.build_comment_id }}
|
|
path: reply
|
|
- name: Publish quoted reply
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: node .github/scripts/comment-reply.mjs publish task/task-metadata.json reply
|
|
|
|
# A comment round whose prepare ended without status ready (waiting behind
|
|
# another PR, a rejected or duplicate instruction) or failed after reporting
|
|
# it (a refused recovery base, a failed upload) runs
|
|
# neither dispatch-reports nor dispatch-reply-history, the only other
|
|
# requesters of the comment queue. It was started with GITHUB_TOKEN, so its
|
|
# completion emits no workflow_run either: request the queue here, or the
|
|
# Issue's next instruction waits for the late hourly sweep. A re-run prepare
|
|
# rejected claimed nothing and wakes nothing.
|
|
wake-comment-queue:
|
|
needs: prepare
|
|
if: >-
|
|
!cancelled() &&
|
|
needs.prepare.outputs.build_comment_id != '' &&
|
|
(needs.prepare.outputs.status != 'ready' || needs.prepare.result != 'success') &&
|
|
needs.prepare.outputs.rerun_rejected != 'true'
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 5
|
|
permissions:
|
|
contents: read
|
|
actions: write
|
|
steps:
|
|
# From this workflow file's revision, as in dispatch-reports.
|
|
- name: Check out the report dispatcher
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ github.workflow_sha }}
|
|
path: dispatcher
|
|
persist-credentials: false
|
|
sparse-checkout-cone-mode: false
|
|
sparse-checkout: |
|
|
/.github/scripts/dispatch-task-reports.sh
|
|
- name: Request comment-build-queue.yml
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
SOURCE_RUN_ID: ${{ github.run_id }}
|
|
SOURCE_ATTEMPT: ${{ github.run_attempt }}
|
|
FACTORY_REPORT_REF: ${{ github.event.repository.default_branch }}
|
|
ISSUE_NUMBER: ${{ needs.prepare.outputs.issue_number }}
|
|
run: bash dispatcher/.github/scripts/dispatch-task-reports.sh comment-build-queue.yml
|
|
|
|
# Question rounds skip dispatch-reports, so they request their history here.
|
|
# A /build round's history is already requested by dispatch-reports.
|
|
# Uses the same retrying dispatcher and step name as dispatch-reports, and
|
|
# report-dispatch-gate.yml reads this job too, so the workflow_run copy of
|
|
# publish-agent-history does not publish the round a second time.
|
|
dispatch-reply-history:
|
|
needs: [prepare, reply, publish-reply]
|
|
if: ${{ !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.status == 'ready' && needs.prepare.outputs.build_comment_id != '' && needs.prepare.outputs.comment_kind == 'reply' }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 5
|
|
permissions:
|
|
contents: read
|
|
actions: write
|
|
steps:
|
|
# From this workflow file's revision, as in dispatch-reports.
|
|
- name: Check out the report dispatcher
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ github.workflow_sha }}
|
|
path: dispatcher
|
|
persist-credentials: false
|
|
sparse-checkout-cone-mode: false
|
|
sparse-checkout: |
|
|
/.github/scripts/dispatch-task-reports.sh
|
|
- name: Request publish-agent-history.yml
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
SOURCE_RUN_ID: ${{ github.run_id }}
|
|
SOURCE_ATTEMPT: ${{ github.run_attempt }}
|
|
FACTORY_REPORT_REF: ${{ github.event.repository.default_branch }}
|
|
run: bash dispatcher/.github/scripts/dispatch-task-reports.sh publish-agent-history.yml
|
|
# As in dispatch-reports: start the Issue's next queued instruction now.
|
|
- name: Request comment-build-queue.yml
|
|
if: ${{ !cancelled() }}
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
SOURCE_RUN_ID: ${{ github.run_id }}
|
|
SOURCE_ATTEMPT: ${{ github.run_attempt }}
|
|
FACTORY_REPORT_REF: ${{ github.event.repository.default_branch }}
|
|
ISSUE_NUMBER: ${{ needs.prepare.outputs.issue_number }}
|
|
run: bash dispatcher/.github/scripts/dispatch-task-reports.sh comment-build-queue.yml
|