diff --git a/.agents/scripts/pulse-issue-reconcile-actions.sh b/.agents/scripts/pulse-issue-reconcile-actions.sh index 1da4d3db70..ab567ba65f 100644 --- a/.agents/scripts/pulse-issue-reconcile-actions.sh +++ b/.agents/scripts/pulse-issue-reconcile-actions.sh @@ -1155,6 +1155,26 @@ _action_reconcile_external_issue_gate() { return 0 } +####################################### +# Stage 0 routing predicate (GH#34281): after the external gate blocked an +# issue, decide whether it may still enter stage 4 (parent reconcile) — never +# stages 1-3. Only an unmutated parent-task whose approval verified STALE +# qualifies; stage 4 re-reads live state per mutation and admits only the +# terminal close (graph-backed) and its compensating reopen. +# Without this route the stale-approved close basis is unreachable in the +# single-pass loop because stage 0 consumes the issue first. +# Args: $1=labels CSV (cached) +# Returns: 0=route to stage 4, 1=stay blocked +####################################### +_pir_stale_approved_parent_may_reconcile() { + local labels_csv="$1" + #aidevops:trust-boundary -- STALE (signature valid) only; NO_APPROVAL/INVALID stay blocked. + [[ "${_PIR_EXTERNAL_GATE_MUTATED:-0}" -eq 0 ]] || return 1 + [[ "${_PIR_EXTERNAL_GATE_VERIFICATION:-}" == "$_PIR_APPROVAL_STALE" ]] || return 1 + _should_cpt "$labels_csv" || return 1 + return 0 +} + # Stage 1 predicate: issue has status:available (candidate for close-via-merged-PR). # Args: $1 = labels_csv (comma-separated label names from pre-fetched JSON) # Note: unquoted case patterns avoid adding to the string-literal ratchet count. diff --git a/.agents/scripts/pulse-issue-reconcile.sh b/.agents/scripts/pulse-issue-reconcile.sh index efe641e8e7..04cdcace1e 100644 --- a/.agents/scripts/pulse-issue-reconcile.sh +++ b/.agents/scripts/pulse-issue-reconcile.sh @@ -1528,11 +1528,16 @@ _pir_reconcile_issue_stages() { # Stage 0b: cached trust metadata heals missed creation-time NMR labels # and blocks every later action for unapproved external issue input. + # GH#34281: a stale-approved parent tracker skips stages 1-3 but still + # reaches stage 4, whose live gate admits only the terminal close. if _should_reconcile_external_issue_gate "$issue_author_association" \ "$issue_author_type" "$issue_author_is_bot"; then if _action_reconcile_external_issue_gate "$slug" "$issue_num" "$labels_csv" \ "$issue_author_association" "$issue_author_login"; then [[ "${_PIR_EXTERNAL_GATE_MUTATED:-0}" -eq 1 ]] && external_gated=$((external_gated + 1)) + if _pir_stale_approved_parent_may_reconcile "$labels_csv"; then + _pir_reconcile_parent_stage "$slug" "$issue_num" "$issue_title" "$issue_body" "$labels_csv" + fi return 0 fi fi diff --git a/.agents/scripts/tests/test-pulse-issue-reconcile.sh b/.agents/scripts/tests/test-pulse-issue-reconcile.sh index 4892f29174..6e4656a1fc 100644 --- a/.agents/scripts/tests/test-pulse-issue-reconcile.sh +++ b/.agents/scripts/tests/test-pulse-issue-reconcile.sh @@ -495,6 +495,68 @@ STUB return 0 } +# --------------------------------------------------------------------------- +# GH#34281 follow-up: the single-pass stage router must hand a stale-approved +# external parent to stage 4 (never stages 1-3); the stage-0b block previously +# consumed it first, so the stale-approved close basis was unreachable. +# --------------------------------------------------------------------------- +test_stage0b_routes_stale_approved_parent_to_stage4() { + local actions_sh="${SCRIPT_DIR}/../pulse-issue-reconcile-actions.sh" + local stages_fn="" result="" + stages_fn=$(sed -n '/^_pir_reconcile_issue_stages()/,/^}/p' "$RECONCILE_SH") + result=$(ACTIONS_SH="$actions_sh" STAGES_FN="$stages_fn" bash -c ' + _PIR_NMR_LABEL="needs-maintainer-review" + _PIR_PERSISTENT_LABEL="persistent" + _PIR_PT_LABEL="parent-task" + LOGFILE="/dev/null" + source "$ACTIONS_SH" + eval "$STAGES_FN" + _should_reconcile_persistent_issue() { return 1; } + _action_reconcile_external_issue_gate() { + _PIR_EXTERNAL_GATE_MUTATED="$GATE_MUTATED" + _PIR_EXTERNAL_GATE_VERIFICATION="$GATE_VERIFICATION" + return 0 + } + _should_ciw() { printf "stage1-reached\n"; return 0; } + _action_ciw_single() { return 1; } + _should_rsd() { printf "stage2-reached\n"; return 1; } + _should_oimp() { printf "stage3-reached\n"; return 1; } + _pir_reconcile_parent_stage() { printf "stage4-reached\n"; return 0; } + slug=owner/repo issue_num=42 issue_title=T issue_body=B + issue_author_association=CONTRIBUTOR issue_author_type=User + issue_author_is_bot=false issue_author_login=outsider + _ciw_rsd_enabled=1 ciw_per_repo=0 ciw_max_repo=5 rsd_per_repo=0 rsd_max_repo=5 + oimp_total_closed=0 oimp_max=5 external_gated=0 parent_task_nums="" + run_case() { + local name="$1" out="" + labels_csv="$2" GATE_VERIFICATION="$3" GATE_MUTATED="$4" + out=$(_pir_reconcile_issue_stages | tr "\n" " ") + printf "%s:[%s]\n" "$name" "${out% }" + } + run_case stale-parent "status:available,parent-task" STALE_APPROVAL 0 + run_case stale-plain "status:available" STALE_APPROVAL 0 + run_case stale-parent-nmr "parent-task,needs-maintainer-review" STALE_APPROVAL 0 + run_case unapproved-parent "status:available,parent-task" NO_APPROVAL 1 + run_case malformed-parent "status:available,parent-task" MALFORMED_APPROVAL 0 + ' 2>/dev/null) + + local expected="" all_ok=1 + for expected in \ + 'stale-parent:[stage4-reached]' \ + 'stale-plain:[]' \ + 'stale-parent-nmr:[]' \ + 'unapproved-parent:[]' \ + 'malformed-parent:[]'; do + printf '%s\n' "$result" | grep -qxF "$expected" || all_ok=0 + done + if [[ "$all_ok" -eq 1 ]]; then + _pass "stage 0b routes only stale-approved parent trackers to stage 4" + else + _fail "stage 0b stale-parent routing mismatch: ${result}" + fi + return 0 +} + # --------------------------------------------------------------------------- # Test 8b: mergedAt helper prefers gh_pr_view wrapper when available # --------------------------------------------------------------------------- @@ -1724,6 +1786,7 @@ test_body_in_prefetch_fetch test_should_predicates test_parent_live_hold_gate test_parent_stale_approval_terminal_close +test_stage0b_routes_stale_approved_parent_to_stage4 test_pr_merged_at_prefers_wrapper test_single_pass_wired_in_engine test_recent_parent_repair_reachable_from_single_pass