fix: fix: architect creates duplicate sprint pitch for vision issues that already have sub-issues (#486)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful

This commit is contained in:
Agent 2026-04-09 08:43:48 +00:00
parent 1e1bb12d66
commit 1426b1710f
2 changed files with 203 additions and 2 deletions

View file

@ -176,6 +176,83 @@ detect_questions_phase() {
return 0
}
# ── Sub-issue existence check ────────────────────────────────────────────
# Check if a vision issue already has sub-issues filed from it.
# Returns 0 if sub-issues exist and are open, 1 otherwise.
# Args: vision_issue_number
has_open_subissues() {
local vision_issue="$1"
local subissue_count=0
# Search for issues whose body contains 'Decomposed from #N' pattern
# Fetch all open issues with bodies in one API call (avoids N+1 calls)
local issues_json
issues_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/issues?state=open&limit=100" 2>/dev/null) || return 1
# Check each issue for the decomposition pattern using jq to extract bodies
subissue_count=$(printf '%s' "$issues_json" | jq -r --arg vid "$vision_issue" '
[.[] | select(.number != $vid) | select(.body // "" | contains("Decomposed from #" + $vid))] | length
' 2>/dev/null) || subissue_count=0
if [ "$subissue_count" -gt 0 ]; then
log "Vision issue #${vision_issue} has ${subissue_count} open sub-issue(s) — skipping"
return 0 # Has open sub-issues
fi
log "Vision issue #${vision_issue} has no open sub-issues"
return 1 # No open sub-issues
}
# ── Merged sprint PR check ───────────────────────────────────────────────
# Check if a vision issue already has a merged sprint PR on the ops repo.
# Returns 0 if a merged sprint PR exists, 1 otherwise.
# Args: vision_issue_number
has_merged_sprint_pr() {
local vision_issue="$1"
# Get closed PRs from ops repo
local prs_json
prs_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls?state=closed&limit=100" 2>/dev/null) || return 1
# Check each closed PR for architect markers and vision issue reference
local pr_numbers
pr_numbers=$(printf '%s' "$prs_json" | jq -r '.[] | select(.title | contains("architect:")) | .number' 2>/dev/null) || return 1
local pr_num
while IFS= read -r pr_num; do
[ -z "$pr_num" ] && continue
# Get PR details including merged status
local pr_details
pr_details=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls/${pr_num}" 2>/dev/null) || continue
# Check if PR is actually merged (not just closed)
local is_merged
is_merged=$(printf '%s' "$pr_details" | jq -r '.merged // false') || continue
if [ "$is_merged" != "true" ]; then
continue
fi
# Get PR body and check for vision issue reference
local pr_body
pr_body=$(printf '%s' "$pr_details" | jq -r '.body // ""') || continue
# Check if PR body references the vision issue number
# Look for patterns like "#N" where N is the vision issue number
if printf '%s' "$pr_body" | grep -qE "(#|refs|references)[[:space:]]*#${vision_issue}|#${vision_issue}[^0-9]|#${vision_issue}$"; then
log "Found merged sprint PR #${pr_num} referencing vision issue #${vision_issue} — skipping"
return 0 # Has merged sprint PR
fi
done <<< "$pr_numbers"
log "Vision issue #${vision_issue} has no merged sprint PR"
return 1 # No merged sprint PR
}
# ── Precondition checks in bash before invoking the model ─────────────────
# Check 1: Skip if no vision issues exist and no open architect PRs to handle
@ -212,8 +289,102 @@ if [ "${open_arch_prs:-0}" -ge 3 ]; then
exit 0
fi
log "3 open architect PRs found but responses detected — processing"
# Track that we have responses to process (even if pitch_budget=0)
has_responses_to_process=true
fi
# ── Preflight: Select vision issues for pitching ──────────────────────────
# This logic is also documented in formulas/run-architect.toml preflight step
# Get all open vision issues
vision_issues_json=$(curl -sf -H "Authorization: token $FORGE_TOKEN" \
"${FORGE_API}/issues?labels=vision&state=open&limit=100" 2>/dev/null) || vision_issues_json='[]'
# Get open architect PRs to skip issues they reference
open_arch_prs_json=$(curl -sf -H "Authorization: token $FORGE_TOKEN" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls?state=open&limit=10" 2>/dev/null) || open_arch_prs_json='[]'
# Build list of vision issues that already have open architect PRs
declare -A _arch_vision_issues_with_open_prs
while IFS= read -r pr_num; do
[ -z "$pr_num" ] && continue
pr_body=$(curl -sf -H "Authorization: token $FORGE_TOKEN" \
"${FORGE_API}/repos/${FORGE_OPS_REPO}/pulls/${pr_num}" 2>/dev/null | jq -r '.body // ""') || continue
# Extract vision issue numbers referenced in PR body (e.g., "refs #419" or "#419")
while IFS= read -r ref_issue; do
[ -z "$ref_issue" ] && continue
_arch_vision_issues_with_open_prs["$ref_issue"]=1
done <<< "$(printf '%s' "$pr_body" | grep -oE '#[0-9]+' | tr -d '#' | sort -u)"
done <<< "$(printf '%s' "$open_arch_prs_json" | jq -r '.[] | select(.title | startswith("architect:")) | .number')"
# Get issues with in-progress label
in_progress_issues=$(curl -sf -H "Authorization: token $FORGE_TOKEN" \
"${FORGE_API}/issues?labels=in-progress&state=open&limit=100" 2>/dev/null | jq -r '.[].number' 2>/dev/null) || in_progress_issues=""
# Select vision issues for pitching
ARCHITECT_TARGET_ISSUES=()
vision_issue_count=0
pitch_budget=$((3 - open_arch_prs))
# Get all vision issue numbers
vision_issue_nums=$(printf '%s' "$vision_issues_json" | jq -r '.[].number' 2>/dev/null) || vision_issue_nums=""
while IFS= read -r vision_issue; do
[ -z "$vision_issue" ] && continue
vision_issue_count=$((vision_issue_count + 1))
# Skip if pitch budget exhausted
if [ ${#ARCHITECT_TARGET_ISSUES[@]} -ge $pitch_budget ]; then
log "Pitch budget exhausted (${#ARCHITECT_TARGET_ISSUES[@]}/${pitch_budget})"
break
fi
# Skip if vision issue already has open architect PR
if [ "${_arch_vision_issues_with_open_prs[$vision_issue]:-}" = "1" ]; then
log "Vision issue #${vision_issue} already has open architect PR — skipping"
continue
fi
# Skip if vision issue has in-progress label
if printf '%s\n' "$in_progress_issues" | grep -q "^${vision_issue}$"; then
log "Vision issue #${vision_issue} has in-progress label — skipping"
continue
fi
# Skip if vision issue has open sub-issues (already being worked on)
if has_open_subissues "$vision_issue"; then
log "Vision issue #${vision_issue} has open sub-issues — skipping"
continue
fi
# Skip if vision issue has merged sprint PR (decomposition already done)
if has_merged_sprint_pr "$vision_issue"; then
log "Vision issue #${vision_issue} has merged sprint PR — skipping"
continue
fi
# Add to target issues
ARCHITECT_TARGET_ISSUES+=("$vision_issue")
log "Selected vision issue #${vision_issue} for pitching"
done <<< "$vision_issue_nums"
# If no issues selected, signal done
# BUT: if we have responses to process (from Check 2), still run agent to handle them
if [ ${#ARCHITECT_TARGET_ISSUES[@]} -eq 0 ]; then
if [ "${has_responses_to_process:-false}" = "true" ]; then
log "No new pitches, but responses detected — processing existing PRs"
else
log "No vision issues available for pitching (all have open PRs, sub-issues, or merged sprint PRs) — signaling PHASE:done"
# Signal PHASE:done by writing to phase file if it exists
if [ -f "/tmp/architect-${PROJECT_NAME}.phase" ]; then
echo "PHASE:done" > "/tmp/architect-${PROJECT_NAME}.phase"
fi
exit 0
fi
fi
log "Selected ${#ARCHITECT_TARGET_ISSUES[@]} vision issue(s) for pitching: ${ARCHITECT_TARGET_ISSUES[*]}"
# ── Run agent ─────────────────────────────────────────────────────────────
export CLAUDE_MODEL="sonnet"

View file

@ -53,14 +53,44 @@ For each available pitch slot:
1. From the vision issues list, skip any issue that already has an open architect PR
(match by checking if any open architect PR body references the vision issue number)
2. Skip any issue that already has the `in-progress` label
3. From remaining candidates, pick the most unblocking issue first (fewest open
3. Check for existing sub-issues filed from this vision issue (see Sub-issue existence check below)
4. Check for merged sprint PRs referencing this vision issue (see Merged sprint PR check below)
5. From remaining candidates, pick the most unblocking issue first (fewest open
dependencies, or earliest created if tied)
4. Add to ARCHITECT_TARGET_ISSUES array
6. Add to ARCHITECT_TARGET_ISSUES array
### Sub-issue existence check
Before selecting a vision issue for pitching, check if it already has sub-issues:
1. Search for issues whose body contains 'Decomposed from #N' where N is the vision issue number
2. Also check for issues filed by architect-bot that reference the vision issue number
3. If any sub-issues are open (state != 'closed'), skip this vision issue it's already being worked on
4. If all sub-issues are closed, the vision issue may be ready for a new sprint (next phase)
API calls:
- GET /repos/{owner}/{repo}/issues?labels=vision&state=open fetch vision issues
- GET /repos/{owner}/{repo}/issues search all issues for 'Decomposed from #N' pattern
- Check each issue's state field to determine if open or closed
### Merged sprint PR check
Before selecting a vision issue for pitching, check for merged architect PRs:
1. Search for merged PRs on the ops repo where the body references the vision issue number
2. Use: GET /repos/{owner}/{repo}/pulls?state=closed (then filter for merged ones)
3. If a merged PR references the vision issue, decomposition already happened skip this vision issue
API calls:
- GET /repos/{owner}/{repo}/pulls?state=closed fetch closed PRs from ops repo
- Check PR body for references to the vision issue number (e.g., "refs #N" or "#N")
Skip conditions:
- If no vision issues are found, signal PHASE:done
- If pitch_budget <= 0 (already 3 open architect PRs), skip pitching only handle existing PRs
- If all vision issues already have open architect PRs, signal PHASE:done
- If all vision issues have open sub-issues, skip pitching decomposition already in progress
- If all vision issues have merged sprint PRs, skip pitching decomposition already completed
Output:
- Sets ARCHITECT_TARGET_ISSUES as a JSON array of issue numbers to pitch (up to 3)