diff --git a/hooks/cleanup-worktree.sh b/hooks/cleanup-worktree.sh index 2518d69..21007d8 100755 --- a/hooks/cleanup-worktree.sh +++ b/hooks/cleanup-worktree.sh @@ -124,7 +124,8 @@ fi # SAFETY: Verify PR merge in git history before cleanup (prevent race condition) # If this is a merged PR cleanup, ensure the merge commit exists in origin before nuking worktree -if [[ "$ISSUE_STATE" == "merged" ]]; then +ISSUE_STATE_FROM_FILE=$(jq -r --arg key "$ISSUE_KEY" '.issues[$key].state // "unknown"' ".autoship/state.json" 2>/dev/null) || ISSUE_STATE_FROM_FILE="unknown" +if [[ "$ISSUE_STATE_FROM_FILE" == "merged" ]]; then git fetch origin main 2>/dev/null || true # Try to find the branch in git log (crude check, but prevents accidental orphan commits) if ! git log --oneline origin/main 2>/dev/null | grep -qi "$ISSUE_KEY\|#$ISSUE_NUM"; then @@ -164,14 +165,27 @@ if [[ -n "$REPO_SLUG" ]] && command -v gh >/dev/null 2>&1; then gh issue edit "$ISSUE_NUM" --remove-label "$label" --repo "$REPO_SLUG" 2>/dev/null || true done - # Check if issue is still open and close it if needed + # Check if issue is still open and close it if needed. + # SAFETY: Only close if a linked PR exists AND is actually MERGED on GitHub. + # Prevents closing in-flight issues on session restart when state.json says "merged" + # but no PR was ever opened / merged (see issue #2224). ISSUE_STATE=$(gh issue view "$ISSUE_NUM" --repo "$REPO_SLUG" --json state --jq '.state' 2>/dev/null) || ISSUE_STATE="" - + PR_NUM=$(jq -r --arg key "$ISSUE_KEY" '.issues[$key].pr_number // empty' ".autoship/state.json" 2>/dev/null) || PR_NUM="" + PR_STATE="" + if [[ -n "$PR_NUM" ]]; then + PR_STATE=$(gh pr view "$PR_NUM" --repo "$REPO_SLUG" --json state --jq '.state' 2>/dev/null) || PR_STATE="" + fi + if [[ "$ISSUE_STATE" == "OPEN" ]]; then - echo "Closing issue $ISSUE_NUM on GitHub" - gh issue close "$ISSUE_NUM" --repo "$REPO_SLUG" --comment "Closed by AutoShip worktree cleanup after merge." 2>/dev/null || { - echo "Warning: failed to close issue $ISSUE_NUM" - } + if [[ -n "$PR_NUM" && "$PR_STATE" == "MERGED" ]]; then + echo "Closing issue $ISSUE_NUM on GitHub (PR #$PR_NUM merged)" + gh issue close "$ISSUE_NUM" --repo "$REPO_SLUG" --comment "Closed by AutoShip worktree cleanup after PR #$PR_NUM merged." 2>/dev/null || { + echo "Warning: failed to close issue $ISSUE_NUM" + } + else + echo "Skipping close of issue $ISSUE_NUM — PR merge not verified (pr_number='${PR_NUM:-none}', pr_state='${PR_STATE:-none}')" + echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] cleanup-worktree: refused to close $ISSUE_KEY — pr_number=${PR_NUM:-none} pr_state=${PR_STATE:-none}" >> .autoship/poll.log 2>/dev/null || true + fi fi else echo "Warning: could not determine repo slug or gh CLI not available; skipping GitHub cleanup" diff --git a/hooks/monitor-issues.sh b/hooks/monitor-issues.sh index adb370a..b0c036e 100755 --- a/hooks/monitor-issues.sh +++ b/hooks/monitor-issues.sh @@ -76,8 +76,8 @@ while true; do # Skip pull requests (GitHub issues API returns PRs too) is_pr=$(gh api "repos/$REPO_SLUG/issues/$num" --jq '.pull_request != null' 2>/dev/null) || is_pr="false" if [[ "$is_pr" == "false" ]]; then - # Only emit if not already tracked in state - tracked=$(jq -r --arg n "$num" '.issues[$n] // empty' "$STATE_FILE" 2>/dev/null) + # Only emit if not already tracked in state (state uses prefixed key "issue-") + tracked=$(jq -r --arg n "issue-$num" '.issues[$n] // empty' "$STATE_FILE" 2>/dev/null) if [[ -z "$tracked" ]]; then echo "[ISSUE_NEW] number=$num" fi @@ -92,8 +92,8 @@ while true; do for num in $closed_issues; do is_pr=$(gh api "repos/$REPO_SLUG/issues/$num" --jq '.pull_request != null' 2>/dev/null) || is_pr="false" if [[ "$is_pr" == "false" ]]; then - # Only emit if we were tracking this issue - tracked=$(jq -r --arg n "$num" '.issues[$n] // empty' "$STATE_FILE" 2>/dev/null) + # Only emit if we were tracking this issue (state uses prefixed key "issue-") + tracked=$(jq -r --arg n "issue-$num" '.issues[$n] // empty' "$STATE_FILE" 2>/dev/null) if [[ -n "$tracked" ]]; then echo "[ISSUE_CLOSED] number=$num" fi diff --git a/hooks/monitor-prs.sh b/hooks/monitor-prs.sh index f85e4b2..f657173 100755 --- a/hooks/monitor-prs.sh +++ b/hooks/monitor-prs.sh @@ -98,25 +98,33 @@ while true; do --repo "$REPO_SLUG" 2>/dev/null | \ jq -r '.[] | "\(.number) \(.mergedAt)"' | \ while read -r num merged_at; do - # Only emit for merges in the last 60 seconds - merged_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$merged_at" "+%s" 2>/dev/null || \ - date -d "$merged_at" "+%s" 2>/dev/null || echo 0) - now_epoch=$(date +%s) + # Only emit for merges in the last 60 seconds. + # macOS `date -j -f` treats input as local time unless -u is set; force UTC. + merged_epoch=$(date -j -u -f "%Y-%m-%dT%H:%M:%SZ" "$merged_at" "+%s" 2>/dev/null || \ + date -u -d "$merged_at" "+%s" 2>/dev/null || echo 0) + now_epoch=$(date -u +%s) age=$((now_epoch - merged_epoch)) if [[ $age -lt 60 ]]; then emit_if_changed "$num" "[PR_MERGED]" # Transition state to merged and write completed_at for the linked issue. # Look up which issue this PR belongs to by matching pr_number in state.json. + # BEACON is gated by the same seen-set as [PR_MERGED] so it fires once per PR. if [[ -f "$STATE_FILE" ]] && command -v jq >/dev/null 2>&1; then ISSUE_ID=$(jq -r --argjson pr "$num" \ '.issues | to_entries[] | select(.value.pr_number == $pr) | .key' \ "$STATE_FILE" 2>/dev/null | head -1) if [[ -n "$ISSUE_ID" ]]; then - COMPLETED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - bash "$REPO_ROOT/hooks/update-state.sh" set-merged "$ISSUE_ID" \ - completed_at="$COMPLETED_AT" 2>/dev/null || true - echo "[BEACON] Transitioned issue $ISSUE_ID to merged (PR #$num, completed_at=$COMPLETED_AT)" + beacon_key="${num}:BEACON_MERGED" + beacon_seen=$(jq -r --arg k "$beacon_key" '.[$k] // empty' "$SEEN_FILE") + if [[ -z "$beacon_seen" ]]; then + COMPLETED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + bash "$REPO_ROOT/hooks/update-state.sh" set-merged "$ISSUE_ID" \ + completed_at="$COMPLETED_AT" 2>/dev/null || true + echo "[BEACON] Transitioned issue $ISSUE_ID to merged (PR #$num, completed_at=$COMPLETED_AT)" + jq --arg k "$beacon_key" --arg now "$(date -u +%s)" '.[$k] = $now' \ + "$SEEN_FILE" > "$_SEEN_TMP" && mv "$_SEEN_TMP" "$SEEN_FILE" + fi fi fi fi diff --git a/skills/dispatch/SKILL.md b/skills/dispatch/SKILL.md index ad0277d..bc0a761 100644 --- a/skills/dispatch/SKILL.md +++ b/skills/dispatch/SKILL.md @@ -102,6 +102,24 @@ fi **Codex app-server failure protocol:** If `dispatch-codex-appserver.sh` returns STUCK on attempt 1, treat as tool exhaustion and immediately try the next agent in the priority list (gemini > claude-haiku > claude-sonnet) — do not retry codex. Log `CODEX_APPSERVER_STUCK` to poll.log. +**⚠️ CWD HAZARD — Agent-tool (Claude Haiku/Sonnet) dispatch (see bug #2226):** + +Each `Bash` tool call in an Agent-tool worker spawns a fresh shell. `cd ` in one call **does not persist** to the next. If the worker runs `git commit` without re-cd'ing, the commit lands on the **parent session's branch**, not `autoship/issue-`. + +**Required mitigation — prefix every bash call:** + +```bash +cd .autoship/workspaces/issue- && +``` + +Every command the worker runs must include the `cd &&` prefix. Do not rely on a setup step that cd's once. + +**Preferred alternatives (when available):** +- **tmux pane dispatch** (Gemini) — pane has persistent cwd, safe by default. +- **Codex app-server** — passes explicit `cwd` to each invocation, immune to drift. + +Agent-tool dispatch is the least safe channel; prefer tmux/Codex for worktree-isolated work when quota allows. + --- ## Step 1: Create Worktree