diff --git a/.agents/scripts/commands/pulse.md b/.agents/scripts/commands/pulse.md index bd4d5698c..40caaa6a6 100644 --- a/.agents/scripts/commands/pulse.md +++ b/.agents/scripts/commands/pulse.md @@ -50,10 +50,17 @@ AVAILABLE=$((MAX_WORKERS - WORKER_COUNT)) # shows PRODUCT_MIN and TOOLING_MAX. Read these values: PRODUCT_MIN=$(grep '^PRODUCT_MIN=' ~/.aidevops/logs/pulse-priority-allocations 2>/dev/null | cut -d= -f2 || echo 0) TOOLING_MAX=$(grep '^TOOLING_MAX=' ~/.aidevops/logs/pulse-priority-allocations 2>/dev/null | cut -d= -f2 || echo "$MAX_WORKERS") + +# Adaptive queue governor (t1455) from pre-fetched state +PULSE_QUEUE_MODE=$(grep '^PULSE_QUEUE_MODE=' ~/.aidevops/logs/pulse-state.txt 2>/dev/null | cut -d= -f2 || echo "balanced") +PR_REMEDIATION_FOCUS_PCT=$(grep '^PR_REMEDIATION_FOCUS_PCT=' ~/.aidevops/logs/pulse-state.txt 2>/dev/null | cut -d= -f2 || echo 50) +NEW_ISSUE_DISPATCH_PCT=$(grep '^NEW_ISSUE_DISPATCH_PCT=' ~/.aidevops/logs/pulse-state.txt 2>/dev/null | cut -d= -f2 || echo 50) ``` If `AVAILABLE <= 0`: you can still merge ready PRs, but don't dispatch new workers. +If `PULSE_QUEUE_MODE` is `pr-heavy` or `merge-heavy`, spend most dispatch capacity on existing PR advancement (merge-ready first, then failing checks/changes requested) and limit new issue starts to the adaptive budget (`NEW_ISSUE_DISPATCH_PCT`). + ### Priority-class enforcement (t1423) Worker slots are partitioned between **product** repos (`"priority": "product"` in repos.json) and **tooling** repos (`"priority": "tooling"`). Product repos get a guaranteed minimum share (default 60%) to prevent tooling hygiene from starving user-facing work. @@ -83,6 +90,14 @@ Scan the pre-fetched state above. Act immediately on each item — don't build a ### PRs — merge, fix, or flag +**Adaptive mode rule (t1455):** + +- `merge-heavy`: complete merge-ready PRs first, then PR fix workers, then only minimal new issue dispatch. +- `pr-heavy`: prioritize PR repair/merge throughput over opening fresh issue work. +- `balanced`: normal ordering. + +Treat this as a live queue-pressure signal, not a static threshold. + **External contributor gate (MANDATORY):** Before merging ANY PR, check if the author is a repo collaborator. The permission check must **fail closed** — if the API call itself fails, do NOT auto-merge and do NOT assume the author is external. ```bash @@ -533,6 +548,14 @@ If decomposition succeeds (`SUBTASK_COUNT >= 2`): For each dispatchable issue (intelligence-first): +When `PULSE_QUEUE_MODE` is `pr-heavy` or `merge-heavy`, limit issue dispatches to the current cycle budget: + +```bash +ISSUE_DISPATCH_BUDGET=$(((AVAILABLE * NEW_ISSUE_DISPATCH_PCT) / 100)) +``` + +If budget is exhausted, stop opening new issue workers and continue PR advancement work. + 1. Skip if a worker is already running for it locally (check `ps` output for the issue number) 2. Skip if an open PR already exists for it (check PR list) 3. Treat labels as hints, not gates. `status:queued`, `status:in-progress`, and `status:in-review` suggest active work, but verify with evidence (active worker, recent PR updates, recent commits) before skipping. diff --git a/.agents/scripts/pulse-wrapper.sh b/.agents/scripts/pulse-wrapper.sh index 53fab6f84..5394bfa06 100755 --- a/.agents/scripts/pulse-wrapper.sh +++ b/.agents/scripts/pulse-wrapper.sh @@ -129,6 +129,7 @@ PULSE_MODEL="${PULSE_MODEL:-}" HEADLESS_RUNTIME_HELPER="${HEADLESS_RUNTIME_HELPER:-${SCRIPT_DIR}/headless-runtime-helper.sh}" REPOS_JSON="${REPOS_JSON:-${HOME}/.config/aidevops/repos.json}" STATE_FILE="${HOME}/.aidevops/logs/pulse-state.txt" +QUEUE_METRICS_FILE="${HOME}/.aidevops/logs/pulse-queue-metrics" if [[ ! -x "$HEADLESS_RUNTIME_HELPER" ]]; then printf '[pulse-wrapper] ERROR: headless runtime helper is missing or not executable: %s (SCRIPT_DIR=%s)\n' "$HEADLESS_RUNTIME_HELPER" "$SCRIPT_DIR" >&2 @@ -347,6 +348,9 @@ prefetch_state() { # Append priority-class worker allocations (t1423) _append_priority_allocations >>"$STATE_FILE" + # Append adaptive queue-governor guidance (t1455) + append_adaptive_queue_governor + # Export PULSE_SCOPE_REPOS — comma-separated list of repo slugs that # workers are allowed to create PRs/branches on (t1405, GH#2928). # Workers CAN file issues on any repo (cross-repo self-improvement), @@ -1678,11 +1682,111 @@ prefetch_contribution_watch() { ####################################### count_active_workers() { local count - count=$(ps axo command | grep '[/]full-loop' | grep -c '[.]opencode') || count=0 + count=$(ps axo command | grep '\.opencode run' | grep '/full-loop Implement issue #' | grep -v '/pulse' | grep -c -v grep) || count=0 echo "$count" return 0 } +####################################### +# Append adaptive queue-governor guidance to pre-fetched state +# +# Uses observed queue totals and trend vs previous cycle to derive an +# adaptive PR-vs-issue dispatch focus. This avoids static per-repo +# thresholds and shifts effort toward PR burn-down when PR backlog grows. +####################################### +append_adaptive_queue_governor() { + if [[ ! -f "$STATE_FILE" ]]; then + return 0 + fi + + local total_prs total_issues ready_prs failing_prs + total_prs=$(awk '/^### Open PRs \([0-9]+\)/ { line=$0; gsub(/[^0-9]/, "", line); sum+=line } END { print sum+0 }' "$STATE_FILE") + total_issues=$(awk '/^### Open Issues \([0-9]+\)/ { line=$0; gsub(/[^0-9]/, "", line); sum+=line } END { print sum+0 }' "$STATE_FILE") + ready_prs=$(rg -c '\[checks: PASS\].*\[review: APPROVED\]' "$STATE_FILE" 2>/dev/null || echo "0") + failing_prs=$(rg -c '\[checks: FAIL\]|\[review: CHANGES_REQUESTED\]' "$STATE_FILE" 2>/dev/null || echo "0") + + [[ "$total_prs" =~ ^[0-9]+$ ]] || total_prs=0 + [[ "$total_issues" =~ ^[0-9]+$ ]] || total_issues=0 + [[ "$ready_prs" =~ ^[0-9]+$ ]] || ready_prs=0 + [[ "$failing_prs" =~ ^[0-9]+$ ]] || failing_prs=0 + + local prev_total_prs=0 prev_total_issues=0 prev_ready_prs=0 prev_failing_prs=0 + if [[ -f "$QUEUE_METRICS_FILE" ]]; then + while IFS='=' read -r key value; do + case "$key" in + prev_total_prs) prev_total_prs="$value" ;; + prev_total_issues) prev_total_issues="$value" ;; + prev_ready_prs) prev_ready_prs="$value" ;; + prev_failing_prs) prev_failing_prs="$value" ;; + esac + done <"$QUEUE_METRICS_FILE" + fi + + [[ "$prev_total_prs" =~ ^-?[0-9]+$ ]] || prev_total_prs=0 + [[ "$prev_total_issues" =~ ^-?[0-9]+$ ]] || prev_total_issues=0 + [[ "$prev_ready_prs" =~ ^-?[0-9]+$ ]] || prev_ready_prs=0 + [[ "$prev_failing_prs" =~ ^-?[0-9]+$ ]] || prev_failing_prs=0 + + local pr_delta issue_delta ready_delta failing_delta + pr_delta=$((total_prs - prev_total_prs)) + issue_delta=$((total_issues - prev_total_issues)) + ready_delta=$((ready_prs - prev_ready_prs)) + failing_delta=$((failing_prs - prev_failing_prs)) + + local denominator pr_share_pct growth_bias pr_focus_pct new_issue_pct + denominator=$((total_prs + total_issues)) + if [[ "$denominator" -lt 1 ]]; then + denominator=1 + fi + pr_share_pct=$(((total_prs * 100) / denominator)) + growth_bias=0 + if [[ "$pr_delta" -gt 0 ]]; then + growth_bias=10 + elif [[ "$pr_delta" -lt 0 ]]; then + growth_bias=-5 + fi + pr_focus_pct=$((35 + (pr_share_pct / 2) + growth_bias)) + if [[ "$pr_focus_pct" -lt 35 ]]; then + pr_focus_pct=35 + elif [[ "$pr_focus_pct" -gt 85 ]]; then + pr_focus_pct=85 + fi + new_issue_pct=$((100 - pr_focus_pct)) + + local queue_mode + queue_mode="balanced" + if [[ "$ready_prs" -gt 0 && "$pr_delta" -ge 0 ]]; then + queue_mode="merge-heavy" + elif [[ "$pr_focus_pct" -ge 60 ]]; then + queue_mode="pr-heavy" + fi + + cat >"$QUEUE_METRICS_FILE" <>"$STATE_FILE" + + echo "[pulse-wrapper] Adaptive queue governor: mode=${queue_mode} prs=${total_prs} issues=${total_issues} pr_focus=${pr_focus_pct}%" >>"$LOGFILE" + return 0 +} + ####################################### # Get current max workers from pulse-max-workers file # Returns: numeric value via stdout (defaults to 1)