Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions hooks/cleanup-worktree.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +127 to +128
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleanup-worktree.sh runs with set -u, but the script references $STATE_FILE earlier (for result freshness) without ever defining it. In this hunk you also hard-code .autoship/state.json; please define STATE_FILE once (e.g., STATE_FILE=.autoship/state.json) and use it consistently so the script doesn’t abort on an unset variable and reads a single canonical state path.

Copilot uses AI. Check for mistakes.
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
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 4 additions & 4 deletions hooks/monitor-issues.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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-<N>")
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
Expand All @@ -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-<N>")
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
Expand Down
24 changes: 16 additions & 8 deletions hooks/monitor-prs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions skills/dispatch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <worktree>` 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-<N>`.

**Required mitigation — prefix every bash call:**

```bash
cd .autoship/workspaces/issue-<N> && <actual command>
```

Every command the worker runs must include the `cd <worktree> &&` 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
Expand Down