Files

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