feat(agent-queue): PR mode — open a PR per fleet job (AQ_FLEET_PR)

When AQ_FLEET_PR=1 and a claimed fleet job carries a `repo`, run the agent in an
isolated checkout on branch aq/job/<fleetJobId> (off baseBranch), then on a passing
verify commit/push and `gh pr create`. The PR URL + branch are recorded in the meta
and reported on lease release (-> the coordinator stores them on the run).

- fleet-client: parse repo/baseBranch from the claim, carry them in frontmatter;
  fleet_report_insights now sends prUrl/branch.
- _fleet_pr_prepare (clone/fetch + branch, local-path aware, identity fallback) and
  _fleet_pr_open (commit/push/gh pr create). WIP checkpointing is skipped for PR jobs
  (the pushed branch is the durable artifact).
- New flags: AQ_FLEET_PR, AQ_FLEET_REPOS_DIR, GH_BIN. README documented.
- selftest: +1 case (bare-repo origin + gh stub) — branch pushed, PR opened, prUrl
  reported on release. Full self-test PASS.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
saravanakumardb1 2026-05-31 05:27:39 -07:00
parent 94d55a3d4a
commit cfbcc2da9d
4 changed files with 143 additions and 5 deletions

View File

@ -428,6 +428,9 @@ AQ_FLEET=1 AQ_FLEET_TOKEN=… AQ_PRODUCT_ID=… agent-queue.sh run #
| `AQ_FLEET` | `0` | master switch — `1` enables coordinator integration; `0`/unset = offline git-queue (zero coordinator calls) |
| `AQ_FLEET_ROUTE` | `1` | `route_via_service`: `1` = coordinator is authoritative for claim (P2-S3 behavior); `0` = local inbox authoritative (coordinator not used to source work) |
| `AQ_FLEET_AUTOSHIP` | `0` | `1` = when the local verify gate passes, advance the coordinator job `testing → shipped` (the factory's verify **is** the test phase); `0` = report `testing` and rest for the human review gate |
| `AQ_FLEET_PR` | `0` | `1` = for a job carrying a `repo`, run the agent in an isolated checkout on branch `aq/job/<id>`, then commit/push and `gh pr create`; the PR URL is reported back and recorded on the run |
| `AQ_FLEET_REPOS_DIR` | `.state/repos` | cache dir for PR-mode repo checkouts (one per repo) |
| `GH_BIN` | `gh` | GitHub CLI used to open PRs in PR mode |
| `AQ_FLEET_SHADOW` | `0` | shadow/dual-run: `1` (requires `AQ_FLEET=1` + `AQ_FLEET_ROUTE=0`) queries the coordinator in parallel and records divergence, **never acting on it** |
| `AQ_FLEET_SHADOW_FACTORY_ID` | `<factory>-shadow` | isolated id used for the read-only shadow claim (never the real factory id) |
| `AQ_FLEET_SHADOW_LOG` | `.state/fleet-shadow.log` | structured shadow-divergence log (`ts⇥localJob⇥coordJob⇥verdict`) |

View File

@ -65,6 +65,7 @@ DEVIN_BIN="${DEVIN_BIN:-$(command -v devin || echo "$HOME/.local/bin/devin")}"
CLAUDE_BIN="${CLAUDE_BIN:-$(command -v claude || echo claude)}"
CODEX_BIN="${CODEX_BIN:-$(command -v codex || echo codex)}"
COPILOT_BIN="${COPILOT_BIN:-$(command -v copilot || echo copilot)}"
GH_BIN="${GH_BIN:-$(command -v gh || echo gh)}" # GitHub CLI (fleet PR mode)
# ── Tracker integration (§10) — task <-> job round-trip via the items API ──
# Base URL of the platform-service items API; the items routes live under /api.
@ -633,6 +634,61 @@ build_agent_cmd() {
esac
}
# ── Fleet PR mode (§PR) ──────────────────────────────────────────────
# Prepare an isolated checkout of <repo> on branch aq/job/<jid> off <base> and
# echo its path (used as the agent cwd). Clones into a cache (default
# $STATE/repos/<safe>), then fetch + hard-reset to origin/<base>. Returns 1 on
# failure (caller falls back to the static cwd). <repo> may be owner/name or a
# clone URL (ssh/https).
_fleet_pr_prepare() {
local repo=$1 base=${2:-main} jid=$3 logf=$4
local reposdir="${AQ_FLEET_REPOS_DIR:-$STATE/repos}"; mkdir -p "$reposdir"
local safe dir url br
safe=$(printf '%s' "$repo" | tr -c 'A-Za-z0-9._-' '_'); dir="$reposdir/$safe"
# Resolve a clone URL: full URLs (ssh/https) pass through; absolute/existing
# local paths (bare repos, mirrors) are used as-is; otherwise `owner/name` is
# expanded to a GitHub HTTPS URL.
case "$repo" in
*://*|git@*) url="$repo";;
/*) url="$repo";;
*) [[ -e "$repo" ]] && url="$repo" || url="https://github.com/$repo.git";;
esac
if [[ -d "$dir/.git" ]]; then
git -C "$dir" fetch --quiet origin "$base" >>"$logf" 2>&1 || { echo "PR: fetch failed for $url ($base)" >>"$logf"; return 1; }
else
git clone --quiet "$url" "$dir" >>"$logf" 2>&1 || { echo "PR: clone failed for $url" >>"$logf"; return 1; }
git -C "$dir" fetch --quiet origin "$base" >>"$logf" 2>&1 || true
fi
br="aq/job/$jid"
git -C "$dir" checkout --quiet -B "$br" "origin/$base" >>"$logf" 2>&1 \
|| git -C "$dir" checkout --quiet -B "$br" "$base" >>"$logf" 2>&1 \
|| { echo "PR: cannot branch $br off $base" >>"$logf"; return 1; }
# Ensure a commit identity exists (fall back to a bot identity if the host has
# no global git user configured) so the PR commit never fails.
git -C "$dir" config user.email >/dev/null 2>&1 || git -C "$dir" config user.email "agent-queue@fleet.local"
git -C "$dir" config user.name >/dev/null 2>&1 || git -C "$dir" config user.name "agent-queue"
echo "$dir"
}
# Commit the agent's work in <dir>, push branch aq/job/<jid>, open a PR against
# <base>, and echo the PR URL. Returns 1 with no output if there is nothing to
# commit or any git/gh step fails (caller leaves pr_url empty).
_fleet_pr_open() {
local dir=$1 base=${2:-main} jid=$3 title=$4 logf=$5
local br="aq/job/$jid"
git -C "$dir" add -A >>"$logf" 2>&1
if git -C "$dir" diff --cached --quiet 2>/dev/null; then
echo "PR: no changes to commit — skipping PR" >>"$logf"; return 1
fi
git -C "$dir" commit --quiet -m "$title" >>"$logf" 2>&1 || { echo "PR: commit failed" >>"$logf"; return 1; }
git -C "$dir" push --quiet -u origin "$br" >>"$logf" 2>&1 || { echo "PR: push failed" >>"$logf"; return 1; }
local url
url=$( cd "$dir" && "$GH_BIN" pr create --base "$base" --head "$br" \
--title "$title" --body "Automated by agent-queue fleet (job $jid)." 2>>"$logf" ) \
|| { echo "PR: gh pr create failed" >>"$logf"; return 1; }
printf '%s' "$url" | tr -d '\n'
}
# ── Worker: runs one job to completion (invoked in background) ───────
run_worker() {
local doing_file=$1
@ -716,9 +772,31 @@ run_worker() {
local devin_export="${logf%.log}.devin-export.json"
build_agent_cmd "$engine" "$bodyf" "$yolo" "$devin_export"
# ── Fleet PR mode (§PR): if enabled and this fleet job targets a repo, run the
# agent in an isolated checkout on branch aq/job/<id> instead of the static
# cwd. The PR is opened after a passing verify. WIP checkpointing is skipped
# for PR jobs — the pushed PR branch is the durable artifact. ──
local pr_dir="" pr_base="" pr_repo="" pr_jid=""
if [[ "${AQ_FLEET_PR:-0}" == 1 ]] && fleet_enabled && _fleet_is_job "$job"; then
pr_repo=$(fm_get "$doing_file" fleet-repo "")
pr_base=$(fm_get "$doing_file" fleet-base-branch "main")
# Branch off the stable fleet job id (not the transient local job name).
pr_jid=$(fm_get "$doing_file" fleet-job-id "$job")
if [[ -n "$pr_repo" ]]; then
local _prep; _prep=$(_fleet_pr_prepare "$pr_repo" "$pr_base" "$pr_jid" "$logf")
if [[ -n "$_prep" && -d "$_prep" ]]; then
cwd="$_prep"; pr_dir="$_prep"
echo "PR mode: agent cwd=$cwd on branch aq/job/$pr_jid (base $pr_base, repo $pr_repo)" >> "$logf"
else
echo "PR mode: prepare failed for $pr_repo — running in $cwd, no PR" >> "$logf"
fi
fi
fi
# ── WIP checkpoint setup (§25.2): on a git cwd, create/checkout aq/wip/<job>
# so partial work survives a crash; a trap guarantees a checkpoint on EVERY
# exit path (success, failure, timeout, SIGTERM/SIGINT). Non-git cwd: no-op. ──
# exit path (success, failure, timeout, SIGTERM/SIGINT). Non-git cwd: no-op.
# Skipped in PR mode (the aq/job/<id> PR branch is the durable artifact). ──
WIP_ACTIVE=0; WIP_BASE=""; WIP_DONE=0
_worker_trap() {
[[ "$WIP_DONE" == 1 ]] && return
@ -727,7 +805,7 @@ run_worker() {
}
trap '_worker_trap' EXIT
trap '_worker_trap; exit 143' INT TERM
_wip_start "$job" "$cwd" "$metaf" "$logf" || true
if [[ -z "$pr_dir" ]]; then _wip_start "$job" "$cwd" "$metaf" "$logf" || true; fi
# ── Fleet (§7/§18): report `building` (with WIP checkpoint) to the coordinator.
# If the lease is stale (we were reclaimed) the report is FENCED -> self-abort and
@ -847,6 +925,19 @@ run_worker() {
mv "$review_file" "$TESTING/" 2>/dev/null
_meta_end "$metaf" "testing" "$started"
echo "VERIFY PASSED — promoted to testing (QA): $(date)" >> "$logf"
# PR mode (§PR): work passed verify — commit/push the job branch, open a PR,
# record the URL in the meta, and push it onto the coordinator run.
if [[ -n "$pr_dir" ]]; then
local _prtitle _prurl
_prtitle=$(head -1 "$bodyf" 2>/dev/null | sed 's/^#* *//' | cut -c1-72)
[[ -n "$_prtitle" ]] || _prtitle="agent-queue job $job"
_prurl=$(_fleet_pr_open "$pr_dir" "$pr_base" "$pr_jid" "$_prtitle" "$logf") || _prurl=""
if [[ -n "$_prurl" ]]; then
{ echo "pr_url=$_prurl"; echo "pr_branch=aq/job/$pr_jid"; } >> "$metaf"
echo "PR opened: $_prurl" >> "$logf"
if fleet_enabled && _fleet_is_job "$job"; then fleet_report_insights "$job" testing; fi
fi
fi
# Fleet (§14): mirror local QA to the coordinator. Always report `testing`
# so the coordinator stage reflects that local verify passed. When AUTOSHIP
# is enabled, the factory's verify gate IS the test phase — advance

View File

@ -53,6 +53,11 @@ AQ_FLEET_ROUTE="${AQ_FLEET_ROUTE:-1}"
# coordinator job testing -> shipped (the factory's verify IS the test phase).
# Default 0 keeps the human review gate authoritative (job rests at testing).
AQ_FLEET_AUTOSHIP="${AQ_FLEET_AUTOSHIP:-0}"
# AQ_FLEET_PR=1 ⇒ for jobs that carry a `repo`, run the agent in an isolated
# checkout on branch aq/job/<id>, then commit/push and open a PR; the PR URL is
# reported back and recorded on the run. Checkouts are cached under AQ_FLEET_REPOS_DIR.
AQ_FLEET_PR="${AQ_FLEET_PR:-0}"
AQ_FLEET_REPOS_DIR="${AQ_FLEET_REPOS_DIR:-}" # default resolved to $STATE/repos at call time
AQ_FLEET_SHADOW="${AQ_FLEET_SHADOW:-0}"
# Isolated factory id for the read-only shadow claim (never the real factory id).
AQ_FLEET_SHADOW_FACTORY_ID="${AQ_FLEET_SHADOW_FACTORY_ID:-${AQ_FACTORY_ID}-shadow}"
@ -180,10 +185,12 @@ fleet_claim() {
case "$FLEET_CODE" in 2*) :;; *) err "fleet: claim failed (HTTP ${FLEET_CODE:-error})"; return 1;; esac
printf '%s' "$FLEET_BODY" | grep -q '"claimed"[[:space:]]*:[[:space:]]*true' || return 2
local jid body_md epoch
local jid body_md epoch repo base_branch
jid=$(printf '%s' "$FLEET_BODY" | _json_str id)
body_md=$(printf '%s' "$FLEET_BODY" | _json_str bodyMd)
epoch=$(printf '%s' "$FLEET_BODY" | _fleet_json_num leaseEpoch)
repo=$(printf '%s' "$FLEET_BODY" | _json_str repo)
base_branch=$(printf '%s' "$FLEET_BODY" | _json_str baseBranch)
[[ -n "$jid" ]] || { err "fleet: claim returned no job id"; return 1; }
# Materialize a transient local job .md (same approach as from-tracker) so the
@ -199,6 +206,8 @@ fleet_claim() {
echo "yolo: true"
echo "fleet-job-id: $jid"
echo "fleet-lease-epoch: ${epoch:-0}"
[[ -n "$repo" ]] && echo "fleet-repo: $repo"
[[ -n "$base_branch" ]] && echo "fleet-base-branch: $base_branch"
echo "idempotency-key: fleet-$jid"
echo "---"
echo
@ -293,9 +302,13 @@ fleet_report_insights() {
[[ "$turns" =~ ^[0-9]+$ ]] && ins+=",\"turns\":$turns"
[[ "$tools" =~ ^[0-9]+$ ]] && ins+=",\"toolCalls\":$tools"
[[ "$est" == "true" || "$est" == "1" ]] && ins+=",\"estimated\":true"
local pr_url pr_branch
pr_url=$(_meta_val "$metaf" pr_url); pr_branch=$(_meta_val "$metaf" pr_branch)
local body="{\"leaseEpoch\":${epoch:-0}"
[[ -n "$ins" ]] && body+=",\"insights\":{${ins#,}}"
[[ -n "$result" ]] && body+=",\"result\":\"$(_json_escape "$result")\""
[[ -n "$pr_url" ]] && body+=",\"prUrl\":\"$(_json_escape "$pr_url")\""
[[ -n "$pr_branch" ]] && body+=",\"branch\":\"$(_json_escape "$pr_branch")\""
body+="}"
_fleet_call POST "/fleet/jobs/$jid/lease/release" "$body"
return 0

View File

@ -773,8 +773,10 @@ case "$1 $2" in
printf '%s\n200\n' '{"claimed":false}'
else
[ -n "${AQ_FSTUB_CLAIM_FLAG:-}" ] && : > "$AQ_FSTUB_CLAIM_FLAG"
printf '{"claimed":true,"job":{"id":"%s","bodyMd":"%s","leaseEpoch":1},"lease":{"leaseEpoch":1}}\n200\n' \
"${AQ_FSTUB_JOB_ID:-fjob_1}" "${AQ_FSTUB_BODY:-do the work}"
repo_field=""
[ -n "${AQ_FSTUB_REPO:-}" ] && repo_field=",\"repo\":\"${AQ_FSTUB_REPO}\",\"baseBranch\":\"${AQ_FSTUB_BASE:-main}\""
printf '{"claimed":true,"job":{"id":"%s","bodyMd":"%s","leaseEpoch":1%s},"lease":{"leaseEpoch":1}}\n200\n' \
"${AQ_FSTUB_JOB_ID:-fjob_1}" "${AQ_FSTUB_BODY:-do the work}" "$repo_field"
fi ;;
PATCH\ /fleet/jobs/*) printf '%s\n%s\n' '{}' "${AQ_FSTUB_PATCH_CODE:-200}" ;;
*) printf '%s\n200\n' '{}' ;;
@ -861,6 +863,35 @@ else
cat "$AQ_FSTUB_CALLS" >&2; fail "autoship-off should report testing and withhold shipped"
fi
# 35d. PR mode: a fleet job carrying a `repo` -> agent works on aq/job/<id> in an
# isolated checkout, PR is opened (gh stub), and the prUrl is reported on release.
prbare="$tmp/pr-origin.git"; git init --bare -q "$prbare"
prseed="$tmp/pr-seed"; git clone -q "$prbare" "$prseed" 2>/dev/null
( cd "$prseed" && git config user.email t@t && git config user.name t \
&& echo seed > README.md && git add -A && git commit -qm seed \
&& git branch -M main && git push -q origin main ) >/dev/null 2>&1
prengine="$tmp/pr-engine"
printf '#!/usr/bin/env bash\necho "pr change" > PR_CHANGE.md\necho done\nexit 0\n' > "$prengine"; chmod +x "$prengine"
ghstub="$tmp/gh-stub"
printf '#!/usr/bin/env bash\necho "$@" >> "%s"\necho "https://github.com/test/repo/pull/7"\nexit 0\n' "$tmp/gh-calls.log" > "$ghstub"; chmod +x "$ghstub"
export AGENT_QUEUE_ROOT="$tmp/queue-fl-pr"; export AQ_FLEET_CWD="$work"
"$AQ" init >/dev/null
export AQ_FSTUB_CALLS="$tmp/fl-pr-calls.log" AQ_FSTUB_CLAIM_FLAG="$tmp/fl-pr-claimed" \
AQ_FSTUB_JOB_ID="fjob_pr" AQ_FSTUB_BODY="add a file" AQ_FSTUB_REPO="$prbare" AQ_FSTUB_BASE="main"
: > "$AQ_FSTUB_CALLS"; rm -f "$AQ_FSTUB_CLAIM_FLAG"
AQ_FLEET=1 AQ_FLEET_PR=1 AQ_FLEET_AUTOSHIP=1 AGENT_QUEUE_VERIFY=true AGENT_QUEUE_POLL=1 \
AQ_FLEET_REPOS_DIR="$tmp/pr-repos" GH_BIN="$ghstub" DEVIN_BIN="$prengine" "$AQ" run --once >/dev/null 2>&1
if git -C "$prbare" rev-parse --verify aq/job/fjob_pr >/dev/null 2>&1 \
&& grep -q 'pr create' "$tmp/gh-calls.log" 2>/dev/null \
&& grep -q '/fleet/jobs/fjob_pr/lease/release :: .*"prUrl":"https://github.com/test/repo/pull/7"' "$AQ_FSTUB_CALLS"; then
pass "fleet PR mode: agent branch pushed + PR opened + prUrl reported on release"
else
echo "gh-calls: $(cat "$tmp/gh-calls.log" 2>/dev/null)" >&2
grep release "$AQ_FSTUB_CALLS" >&2 2>/dev/null
fail "PR mode did not push branch / open PR / report prUrl"
fi
unset AQ_FSTUB_REPO AQ_FSTUB_BASE
# 36. FENCING: PATCH returns conflict (stale epoch) → worker self-aborts, job is
# quarantined to failed/ (NOT review/testing/shipped), fenced is recorded.
export AGENT_QUEUE_ROOT="$tmp/queue-fl3"