From 39f487516223326ccdef217631a4aabd177a9dd6 Mon Sep 17 00:00:00 2001 From: marcusquinn <6428977+marcusquinn@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:19:17 +0000 Subject: [PATCH 1/2] fix: prevent duplicate dispatch across runners via assignee check + jitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: multiple pulse runners evaluating the same issue simultaneously create duplicate PRs. Process-based dedup (has_worker_for_repo_issue, is-duplicate) only sees local processes — invisible across machines. Fix 1: Add is-assigned command to dispatch-dedup-helper.sh that queries GitHub assignees before dispatch. If another runner already self-assigned, skip the issue. This is the primary cross-machine dedup guard. Fix 2: Add 0-30s random startup jitter to pulse-wrapper.sh so concurrent launchd-triggered pulses don't evaluate issues at the same instant. Configurable via PULSE_JITTER_MAX (set to 0 to disable). Fix 3: Update pulse.md dispatch instructions to enforce the assignee check as a mandatory step alongside existing local process dedup. Observed: PR #4940 duplicated PR #4938 for issue #4937 because alex-solovyev's pulse dispatched 2 min after marcusquinn self-assigned, interpreting the in-progress worker as 'failed'. --- .agents/scripts/commands/pulse.md | 11 +++- .agents/scripts/dispatch-dedup-helper.sh | 81 +++++++++++++++++++++++- .agents/scripts/pulse-wrapper.sh | 20 ++++++ 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/.agents/scripts/commands/pulse.md b/.agents/scripts/commands/pulse.md index abad0107e..7ef705610 100644 --- a/.agents/scripts/commands/pulse.md +++ b/.agents/scripts/commands/pulse.md @@ -60,13 +60,20 @@ Check external contributor gate before ANY merge (see Pre-merge checks below). For each unassigned, non-blocked issue with no open PR and no active worker: ```bash -# Dedup guard (MANDATORY) +# Dedup guard (MANDATORY — all three checks required) source ~/.aidevops/agents/scripts/pulse-wrapper.sh +RUNNER_USER=$(gh api user --jq '.login' 2>/dev/null || whoami) + +# 1. Local process dedup (same machine only) if has_worker_for_repo_issue NUMBER SLUG; then continue; fi if ~/.aidevops/agents/scripts/dispatch-dedup-helper.sh is-duplicate "Issue #NUMBER: TITLE"; then continue; fi +# 2. Cross-machine assignee dedup (checks GitHub — visible to ALL runners) +# This is the primary guard against duplicate dispatch across machines. +# If another runner already assigned themselves, skip this issue. +if ~/.aidevops/agents/scripts/dispatch-dedup-helper.sh is-assigned NUMBER SLUG "$RUNNER_USER"; then continue; fi + # Assign and dispatch -RUNNER_USER=$(gh api user --jq '.login' 2>/dev/null || whoami) gh issue edit NUMBER --repo SLUG --add-assignee "$RUNNER_USER" --add-label "status:queued" 2>/dev/null || true ~/.aidevops/agents/scripts/headless-runtime-helper.sh run \ diff --git a/.agents/scripts/dispatch-dedup-helper.sh b/.agents/scripts/dispatch-dedup-helper.sh index 210f7bb33..874e1c7a8 100755 --- a/.agents/scripts/dispatch-dedup-helper.sh +++ b/.agents/scripts/dispatch-dedup-helper.sh @@ -278,6 +278,69 @@ is_duplicate() { return 1 } +####################################### +# Check if a GitHub issue is already assigned to someone else. +# +# This is the primary cross-machine dedup guard. Process-based checks +# (is_duplicate, has_worker_for_repo_issue) only see local processes — +# they miss workers running on other machines. The GitHub assignee is +# the single source of truth visible to all runners. +# +# Args: +# $1 = issue number +# $2 = repo slug (owner/repo) +# $3 = (optional) current runner login — if assigned to self, not a dup +# Returns: +# exit 0 if assigned to someone else (do NOT dispatch) +# exit 1 if unassigned or assigned to self (safe to dispatch) +# Outputs: assignee info on stdout if assigned +####################################### +is_assigned() { + local issue_number="$1" + local repo_slug="$2" + local self_login="${3:-}" + + if [[ -z "$issue_number" || -z "$repo_slug" ]]; then + # Missing args — cannot check, allow dispatch + return 1 + fi + + # Validate issue number is numeric + if [[ ! "$issue_number" =~ ^[0-9]+$ ]]; then + return 1 + fi + + # Query GitHub for current assignees + local assignees + assignees=$(gh issue view "$issue_number" --repo "$repo_slug" \ + --json assignees --jq '[.assignees[].login] | join(",")' 2>/dev/null) || assignees="" + + if [[ -z "$assignees" ]]; then + # No assignees — safe to dispatch + return 1 + fi + + # If assigned to self, not a duplicate + if [[ -n "$self_login" ]]; then + # Check if ALL assignees are self (could be multiple) + local dominated_by_self=true + local IFS=',' + for assignee in $assignees; do + if [[ "$assignee" != "$self_login" ]]; then + dominated_by_self=false + break + fi + done + unset IFS + if [[ "$dominated_by_self" == "true" ]]; then + return 1 + fi + fi + + printf 'ASSIGNED: issue #%s in %s is assigned to %s\n' "$issue_number" "$repo_slug" "$assignees" + return 0 +} + ####################################### # Show help ####################################### @@ -288,6 +351,8 @@ dispatch-dedup-helper.sh - Normalize and deduplicate worker dispatch titles (t23 Usage: dispatch-dedup-helper.sh extract-keys Extract dedup keys from a title dispatch-dedup-helper.sh is-duplicate <title> Check if already running (exit 0=dup, 1=safe) + dispatch-dedup-helper.sh is-assigned <issue> <slug> [self-login] + Check if issue is assigned (exit 0=assigned, 1=free) dispatch-dedup-helper.sh list-running-keys List keys for all running workers dispatch-dedup-helper.sh normalize <title> Normalize a title for comparison dispatch-dedup-helper.sh help Show this help @@ -298,12 +363,19 @@ Examples: # Output: issue-2300 # task-t1337 - # Check before dispatching + # Check before dispatching (local process dedup) if dispatch-dedup-helper.sh is-duplicate "Issue #2300: Fix auth"; then echo "Already running — skip dispatch" else echo "Safe to dispatch" fi + + # Check before dispatching (cross-machine assignee dedup) + if dispatch-dedup-helper.sh is-assigned 2300 owner/repo mylogin; then + echo "Assigned to someone else — skip dispatch" + else + echo "Unassigned or assigned to self — safe to dispatch" + fi HELP return 0 } @@ -330,6 +402,13 @@ main() { } is_duplicate "$1" ;; + is-assigned) + [[ $# -lt 2 ]] && { + echo "Error: is-assigned requires <issue-number> <repo-slug> [self-login]" >&2 + return 1 + } + is_assigned "$1" "$2" "${3:-}" + ;; list-running-keys) list_running_keys ;; diff --git a/.agents/scripts/pulse-wrapper.sh b/.agents/scripts/pulse-wrapper.sh index 3fe7641d6..8f6c688ed 100755 --- a/.agents/scripts/pulse-wrapper.sh +++ b/.agents/scripts/pulse-wrapper.sh @@ -63,6 +63,26 @@ set -euo pipefail ####################################### export PATH="/bin:/usr/bin:/usr/local/bin:/opt/homebrew/bin:${PATH}" +####################################### +# Startup jitter — desynchronise concurrent pulse instances +# +# When multiple runners share the same launchd interval (120s), their +# pulses fire simultaneously, creating a race window where both evaluate +# the same issue before either can self-assign. A random 0-30s delay at +# startup staggers the pulses so the first runner to wake assigns the +# issue before the second runner evaluates it. +# +# PULSE_JITTER_MAX: max jitter in seconds (default 30, set to 0 to disable) +####################################### +PULSE_JITTER_MAX="${PULSE_JITTER_MAX:-30}" +if [[ "$PULSE_JITTER_MAX" -gt 0 ]]; then + # $RANDOM is 0-32767; modulo gives 0 to PULSE_JITTER_MAX + jitter_seconds=$((RANDOM % (PULSE_JITTER_MAX + 1))) + if [[ "$jitter_seconds" -gt 0 ]]; then + sleep "$jitter_seconds" + fi +fi + # Use ${BASH_SOURCE[0]:-$0} for shell portability — BASH_SOURCE is undefined # in zsh, which is the MCP shell environment. This fallback ensures SCRIPT_DIR # resolves correctly whether the script is executed directly (bash) or sourced From 540ac93231d007abf066f9d1747ba9b21521f445 Mon Sep 17 00:00:00 2001 From: marcusquinn <6428977+marcusquinn@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:23:21 +0000 Subject: [PATCH 2/2] fix: validate PULSE_JITTER_MAX as numeric, use read -ra for assignee parsing Address Gemini review feedback: - Validate PULSE_JITTER_MAX is numeric before arithmetic (prevents set -e failures from non-integer env var values) - Use read -ra for comma-separated assignee parsing instead of IFS word splitting (more robust against whitespace edge cases) --- .agents/scripts/dispatch-dedup-helper.sh | 9 ++++++--- .agents/scripts/pulse-wrapper.sh | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.agents/scripts/dispatch-dedup-helper.sh b/.agents/scripts/dispatch-dedup-helper.sh index 874e1c7a8..f65ec0a73 100755 --- a/.agents/scripts/dispatch-dedup-helper.sh +++ b/.agents/scripts/dispatch-dedup-helper.sh @@ -324,14 +324,17 @@ is_assigned() { if [[ -n "$self_login" ]]; then # Check if ALL assignees are self (could be multiple) local dominated_by_self=true - local IFS=',' - for assignee in $assignees; do + local -a assignee_array=() + local saved_ifs="${IFS:-}" + IFS=',' read -ra assignee_array <<<"$assignees" + IFS="$saved_ifs" + local assignee + for assignee in "${assignee_array[@]}"; do if [[ "$assignee" != "$self_login" ]]; then dominated_by_self=false break fi done - unset IFS if [[ "$dominated_by_self" == "true" ]]; then return 1 fi diff --git a/.agents/scripts/pulse-wrapper.sh b/.agents/scripts/pulse-wrapper.sh index 8f6c688ed..d40a70343 100755 --- a/.agents/scripts/pulse-wrapper.sh +++ b/.agents/scripts/pulse-wrapper.sh @@ -75,7 +75,7 @@ export PATH="/bin:/usr/bin:/usr/local/bin:/opt/homebrew/bin:${PATH}" # PULSE_JITTER_MAX: max jitter in seconds (default 30, set to 0 to disable) ####################################### PULSE_JITTER_MAX="${PULSE_JITTER_MAX:-30}" -if [[ "$PULSE_JITTER_MAX" -gt 0 ]]; then +if [[ "$PULSE_JITTER_MAX" =~ ^[0-9]+$ && "$PULSE_JITTER_MAX" -gt 0 ]]; then # $RANDOM is 0-32767; modulo gives 0 to PULSE_JITTER_MAX jitter_seconds=$((RANDOM % (PULSE_JITTER_MAX + 1))) if [[ "$jitter_seconds" -gt 0 ]]; then