publish.sh: no PR opened when the agent pushes a slugged branch — work silently stranded on a green run #124

Open
opened 2026-08-31 18:29:39 +02:00 by ffaerber · 3 comments
Owner

Symptom

A dev agent implements the change, commits, pushes — and no PR is opened. The run reports completed/success, the issue gets a normal reply, and the work sits on the branch unnoticed.

Observed on ffaerber/homelab#273 (run 1940). @junior's own reply said:

Pushed branch ai/issue-273-pin-mainnet-geth; the automation will open the PR.

No automation did. The branch was correct and complete — one file, the exact requested change — and it stayed invisible until someone opened the PR by hand.

Cause

publish.sh considers exactly one branch:

br="$BRANCH"
ahead=$(git rev-list --count "origin/main..origin/$br" 2>/dev/null || echo 0)
if [ "${ahead:-0}" -eq 0 ]; then
  # No changes on this branch — a plan / questions / analysis only.
  post "$(printf '%s%s' "$reply" "$activity")"
  exit 0
fi

$BRANCH is the canonical ai/issue-N. The agent pushed to a slugged branch, ai/issue-273-pin-mainnet-geth. origin/ai/issue-273 was never created, so ahead=0, and the script takes the "analysis only" path — replying and exiting 0 without opening a PR.

The comment directly above that block states the intent:

# One PR per run: publish ONLY this run's own branch ($BRANCH), never sibling
# ai/issue-N-* branches.

That rule is reasonable for avoiding multi-PR ambiguity, but it has no fallback for the case where the agent pushed only a sibling and the canonical branch does not exist at all.

Evidence — branches on ffaerber/homelab:

ai/issue-240   +  ai/issue-240-t4t-provider-healthcheck    ← both exist, PR opened
ai/issue-262   +  ai/issue-262-prA-infra, -prB-apps, …     ← both exist, PRs opened
ai/issue-273-pin-mainnet-geth                              ← slug ONLY, no PR

Why rescue-pr.sh does not catch it

rescue-pr.sh is explicitly failure-only:

when a run FAILED after a dev agent already pushed commits, the normal Publish step never ran…

Here the run succeeded, so the rescue never runs. The stranded-work safety net has a hole exactly where the run looks healthy.

Suggested fix

In publish.sh, when $BRANCH is 0 commits ahead, look for sibling ai/issue-N-* branches that are ahead before concluding "analysis only":

if [ "${ahead:-0}" -eq 0 ]; then
  # The agent may have pushed a slugged branch instead of the canonical one.
  alt=$(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/${BRANCH}-*" \
        | while read -r r; do
            [ "$(git rev-list --count "origin/main..$r" 2>/dev/null || echo 0)" -gt 0 ] && echo "${r#origin/}"
          done | head -1)
  if [ -n "$alt" ]; then
    echo "publish: \$BRANCH empty but $alt has commits — publishing that instead"
    br="$alt"
    ahead=$(git rev-list --count "origin/main..origin/$br")
  fi
fi

Pick the single ahead sibling when there is exactly one; if there are several, post a warning naming them rather than guessing — that preserves the "no multi-PR ambiguity" intent while closing the silent-loss case.

A cheaper alternative: keep the strict rule, but make the "analysis only" reply state which branch was checked, so Pushed branch X; the automation will open the PR and No changes on ai/issue-N cannot both appear in the same thread without anyone noticing.

Impact

Silent loss of completed work on any run where the agent chooses a slugged branch name. The run is green, the reply looks normal, and only a human comparing the branch list against the PR list will spot it.

## Symptom A dev agent implements the change, commits, pushes — and **no PR is opened**. The run reports `completed/success`, the issue gets a normal reply, and the work sits on the branch unnoticed. Observed on `ffaerber/homelab#273` (run 1940). `@junior`'s own reply said: > Pushed branch `ai/issue-273-pin-mainnet-geth`; **the automation will open the PR.** No automation did. The branch was correct and complete — one file, the exact requested change — and it stayed invisible until someone opened the PR by hand. ## Cause `publish.sh` considers exactly one branch: ```bash br="$BRANCH" ahead=$(git rev-list --count "origin/main..origin/$br" 2>/dev/null || echo 0) if [ "${ahead:-0}" -eq 0 ]; then # No changes on this branch — a plan / questions / analysis only. post "$(printf '%s%s' "$reply" "$activity")" exit 0 fi ``` `$BRANCH` is the canonical `ai/issue-N`. The agent pushed to a **slugged** branch, `ai/issue-273-pin-mainnet-geth`. `origin/ai/issue-273` was never created, so `ahead=0`, and the script takes the "analysis only" path — replying and exiting 0 without opening a PR. The comment directly above that block states the intent: ``` # One PR per run: publish ONLY this run's own branch ($BRANCH), never sibling # ai/issue-N-* branches. ``` That rule is reasonable for avoiding multi-PR ambiguity, but it has no fallback for the case where the agent pushed *only* a sibling and the canonical branch does not exist at all. Evidence — branches on `ffaerber/homelab`: ``` ai/issue-240 + ai/issue-240-t4t-provider-healthcheck ← both exist, PR opened ai/issue-262 + ai/issue-262-prA-infra, -prB-apps, … ← both exist, PRs opened ai/issue-273-pin-mainnet-geth ← slug ONLY, no PR ``` ## Why `rescue-pr.sh` does not catch it `rescue-pr.sh` is explicitly failure-only: > when a run **FAILED** after a dev agent already pushed commits, the normal Publish step never ran… Here the run **succeeded**, so the rescue never runs. The stranded-work safety net has a hole exactly where the run looks healthy. ## Suggested fix In `publish.sh`, when `$BRANCH` is 0 commits ahead, look for sibling `ai/issue-N-*` branches that are ahead before concluding "analysis only": ```bash if [ "${ahead:-0}" -eq 0 ]; then # The agent may have pushed a slugged branch instead of the canonical one. alt=$(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/${BRANCH}-*" \ | while read -r r; do [ "$(git rev-list --count "origin/main..$r" 2>/dev/null || echo 0)" -gt 0 ] && echo "${r#origin/}" done | head -1) if [ -n "$alt" ]; then echo "publish: \$BRANCH empty but $alt has commits — publishing that instead" br="$alt" ahead=$(git rev-list --count "origin/main..origin/$br") fi fi ``` Pick the single ahead sibling when there is exactly one; if there are several, post a warning naming them rather than guessing — that preserves the "no multi-PR ambiguity" intent while closing the silent-loss case. A cheaper alternative: keep the strict rule, but make the "analysis only" reply state which branch was checked, so `Pushed branch X; the automation will open the PR` and `No changes on ai/issue-N` cannot both appear in the same thread without anyone noticing. ## Impact Silent loss of completed work on any run where the agent chooses a slugged branch name. The run is green, the reply looks normal, and only a human comparing the branch list against the PR list will spot it.
Member

Issue #124 confirms the bug: when a dev agent pushes a slugged branch ai/issue-N-<slug> instead of the canonical ai/issue-N, publish.sh (line 355-361) only checks the canonical branch, gets ahead=0, and exits with an analysis-only reply — no PR opened and no warning.

I took the issue’s suggested fix as the starting point and checked the current code. The script already handles slugged branches in resolve_prs() (line 51-52) and in the autopilot merge path (line 228), so the omission in the dev-branch publish path is isolated and consistent to fix.

Assessment

The minimal fix is in .gitea/workflows/scripts/publish.sh, in the block starting at line 355. After computing ahead for $BRANCH, if it is zero, scan refs/remotes/origin/${BRANCH}-* for siblings with commits beyond main. Behavior:

  • Exactly one sibling with commits → redirect br and recompute ahead.
  • Multiple siblings → do not guess; instead, append a clear warning to the reply before posting the analysis-only comment, naming the sibling branches and asking the agent or maintainer to pick one.
  • No siblings → unchanged analysis-only comment.

This preserves the “one PR per run” rule while closing the silent-loss hole.

Suggested implementation shape

br="$BRANCH"
ahead=$(git rev-list --count "origin/main..origin/$br" 2>/dev/null || echo 0)
if [ "${ahead:-0}" -eq 0 ]; then
  # The agent may have pushed a slugged branch instead of the canonical one.
  alt=$(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/${BRANCH}-*" \
        | while read -r r; do
            a=$(git rev-list --count "origin/main..$r" 2>/dev/null || echo 0)
            [ "$a" -gt 0 ] && echo "${r#origin/}"
          done)
  alt_count=$(printf '%s\n' "$alt" | grep -c . || echo 0)
  if [ "$alt_count" -eq 1 ]; then
    br="$alt"
    ahead=$(git rev-list --count "origin/main..origin/$br" 2>/dev/null || echo 0)
    echo "publish: canonical $BRANCH empty but $br has commits — publishing that instead"
  elif [ "$alt_count" -gt 1 ]; then
    reply=$(printf '⚠️ Multiple ahead branches found for this issue (%s). I will not guess which one to publish — please choose one.\n\n%s' "$alt" "$reply")
  fi
fi
if [ "${ahead:-0}" -eq 0 ]; then
  post "$(printf '%s%s' "$reply" "$activity")"
  exit 0
fi

I would also consider whether to reuse the existing resolve_prs() pattern (head.ref==$br or startswith($br+"-")) for symmetry, but the issue’s git for-each-ref approach is more direct and runs locally without API pagination.

Risks / follow-up

  • No rescue-pr.sh change needed. The issue explains the gap is not in the rescue path, and rescue-pr.sh correctly only runs on failures.
  • Team learning about publishing PRs: this is exactly the kind of silent fail the @qa diff-stat and end-to-end checks target. A regression test could be: create a mock repo, push a single commit to ai/issue-999-test-slug, run publish.sh with BRANCH=ai/issue-999, and assert a PR is opened for the slugged branch.
  • Edge case: if both the canonical branch and a slug exist with commits, the current strict rule still publishes only $BRANCH, which is the intended behavior and should stay.

Effort estimate

Small: one file, ~25 lines. I’d pair it with a quick shell test in /tmp to verify the branch-counting logic. If we want a full workflow integration test that’s more work and better handed to @senior.

I can implement this on a branch if @pm asks, or @senior can take it if we want broader test coverage.

details

🔧 8 tool calls · in 55760 · out 1183 tokens · subscription · model ollama-cloud/kimi-k2.7-code:cloud

🔧 read: {"filePath":"/workspace/gitea/agents/publish.sh"}
🔧 read: {"filePath":"/workspace/gitea/agents/rescue-pr.sh"}
🔧 read: 🔧 **glob**:
🔧 glob: 🔧 **glob**:
🔧 grep: publish\.sh|rescue-pr\.sh
🔧 read: .gitea/workflows/scripts/publish.sh

tokens — input 55760 (fresh 55760 · cache 0w / 0r) · output 1183 · reasoning 0 · subscription

Issue #124 confirms the bug: when a dev agent pushes a slugged branch `ai/issue-N-<slug>` instead of the canonical `ai/issue-N`, `publish.sh` (line 355-361) only checks the canonical branch, gets `ahead=0`, and exits with an analysis-only reply — no PR opened and no warning. I took the issue’s suggested fix as the starting point and checked the current code. The script already handles slugged branches in `resolve_prs()` (line 51-52) and in the autopilot merge path (line 228), so the omission in the dev-branch publish path is isolated and consistent to fix. ## Assessment The minimal fix is in `.gitea/workflows/scripts/publish.sh`, in the block starting at line 355. After computing `ahead` for `$BRANCH`, if it is zero, scan `refs/remotes/origin/${BRANCH}-*` for siblings with commits beyond `main`. Behavior: - Exactly one sibling with commits → redirect `br` and recompute `ahead`. - Multiple siblings → do **not** guess; instead, append a clear warning to the reply before posting the analysis-only comment, naming the sibling branches and asking the agent or maintainer to pick one. - No siblings → unchanged analysis-only comment. This preserves the “one PR per run” rule while closing the silent-loss hole. ## Suggested implementation shape ```bash br="$BRANCH" ahead=$(git rev-list --count "origin/main..origin/$br" 2>/dev/null || echo 0) if [ "${ahead:-0}" -eq 0 ]; then # The agent may have pushed a slugged branch instead of the canonical one. alt=$(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/${BRANCH}-*" \ | while read -r r; do a=$(git rev-list --count "origin/main..$r" 2>/dev/null || echo 0) [ "$a" -gt 0 ] && echo "${r#origin/}" done) alt_count=$(printf '%s\n' "$alt" | grep -c . || echo 0) if [ "$alt_count" -eq 1 ]; then br="$alt" ahead=$(git rev-list --count "origin/main..origin/$br" 2>/dev/null || echo 0) echo "publish: canonical $BRANCH empty but $br has commits — publishing that instead" elif [ "$alt_count" -gt 1 ]; then reply=$(printf '⚠️ Multiple ahead branches found for this issue (%s). I will not guess which one to publish — please choose one.\n\n%s' "$alt" "$reply") fi fi if [ "${ahead:-0}" -eq 0 ]; then post "$(printf '%s%s' "$reply" "$activity")" exit 0 fi ``` I would also consider whether to reuse the existing `resolve_prs()` pattern (`head.ref==$br or startswith($br+"-")`) for symmetry, but the issue’s `git for-each-ref` approach is more direct and runs locally without API pagination. ## Risks / follow-up - **No `rescue-pr.sh` change needed.** The issue explains the gap is not in the rescue path, and `rescue-pr.sh` correctly only runs on failures. - **Team learning about publishing PRs:** this is exactly the kind of silent fail the `@qa` diff-stat and end-to-end checks target. A regression test could be: create a mock repo, push a single commit to `ai/issue-999-test-slug`, run `publish.sh` with `BRANCH=ai/issue-999`, and assert a PR is opened for the slugged branch. - **Edge case:** if both the canonical branch and a slug exist with commits, the current strict rule still publishes only `$BRANCH`, which is the intended behavior and should stay. ## Effort estimate Small: one file, ~25 lines. I’d pair it with a quick shell test in `/tmp` to verify the branch-counting logic. If we want a full workflow integration test that’s more work and better handed to `@senior`. I can implement this on a branch if @pm asks, or @senior can take it if we want broader test coverage. <details> <summary>details</summary> 🔧 8 tool calls · in 55760 · out 1183 tokens · subscription · model ollama-cloud/kimi-k2.7-code:cloud 🔧 **read**: `{"filePath":"/workspace/gitea/agents/publish.sh"}` 🔧 **read**: `{"filePath":"/workspace/gitea/agents/rescue-pr.sh"}` 🔧 **read**: `` 🔧 **glob**: `` 🔧 **glob**: `` 🔧 **glob**: `` 🔧 **grep**: `publish\.sh|rescue-pr\.sh` 🔧 **read**: `.gitea/workflows/scripts/publish.sh` <sub>tokens — input 55760 (fresh 55760 · cache 0w / 0r) · output 1183 · reasoning 0 · **subscription**</sub> </details> <!-- 🤖 agent reply — do not trigger -->
Author
Owner

Controlled confirmation — same agent, same task shape, opposite outcomes

Ran a near-identical task (ffaerber/homelab#275, pin one image line in docker-compose.yml) immediately after #273. Same agent, same issue template, same one-line YAML change. The only difference was the branch name:

#273 #275
branch pushed ai/issue-273-pin-mainnet-geth ai/issue-275
matches $BRANCH no (slugged) yes (canonical)
run result completed/success completed/success
PR opened NO — work stranded yes, automatically (#276)

That isolates the cause to the branch-name comparison in publish.sh and rules out the model, the task, the tokens and the workflow wiring.

Worth noting: on #275 @pm's plan explicitly said it would use ai/issue-275-grafana-pin — a slugged name — but @junior pushed ai/issue-275 instead. So which branch name an agent picks is not deterministic, which means this failure is intermittent rather than tied to any particular issue or agent. That is the worst shape for a silent-loss bug: it works most of the time, and when it doesn't, the run is green.

Second, independent trigger found

ffaerber/homelab#275 also stalled before any of this, for a different reason: a plain yes reply to @pm's "reply yes to proceed" did not fire a run. Nothing happened at all — no branch, no PR, no run — until the comment was reposted as @pm yes.

So the hand-back prompt asks for yes, but routing needs an @mention. Either route.sh should treat a short affirmative on a thread awaiting approval as directed at the agent that asked, or the prompt should say reply "@pm yes". As written, following the instruction literally is a dead end, and the issue looks abandoned.

## Controlled confirmation — same agent, same task shape, opposite outcomes Ran a near-identical task (`ffaerber/homelab#275`, pin one image line in `docker-compose.yml`) immediately after #273. Same agent, same issue template, same one-line YAML change. The **only** difference was the branch name: | | #273 | #275 | |---|---|---| | branch pushed | `ai/issue-273-pin-mainnet-geth` | `ai/issue-275` | | matches `$BRANCH` | **no** (slugged) | **yes** (canonical) | | run result | `completed/success` | `completed/success` | | **PR opened** | **NO — work stranded** | **yes, automatically (#276)** | That isolates the cause to the branch-name comparison in `publish.sh` and rules out the model, the task, the tokens and the workflow wiring. Worth noting: on #275 `@pm`'s plan explicitly said it would use `ai/issue-275-grafana-pin` — a slugged name — but `@junior` pushed `ai/issue-275` instead. So **which branch name an agent picks is not deterministic**, which means this failure is intermittent rather than tied to any particular issue or agent. That is the worst shape for a silent-loss bug: it works most of the time, and when it doesn't, the run is green. ## Second, independent trigger found `ffaerber/homelab#275` also stalled before any of this, for a different reason: a plain `yes` reply to `@pm`'s *"reply yes to proceed"* did **not** fire a run. Nothing happened at all — no branch, no PR, no run — until the comment was reposted as `@pm yes`. So the hand-back prompt asks for `yes`, but routing needs an `@mention`. Either `route.sh` should treat a short affirmative on a thread awaiting approval as directed at the agent that asked, or the prompt should say `reply "@pm yes"`. As written, following the instruction literally is a dead end, and the issue looks abandoned.
Member

ffaerber — the controlled confirmation table isolates the slug-branch cause cleanly, and the yes-reply routing issue is a real, separate finding worth its own issue.

Plan for #124

  • Scope: .gitea/workflows/scripts/publish.sh, in the ahead=0 block (~line 355).
  • Behavior: scan refs/remotes/origin/${BRANCH}-* for siblings ahead of main. Exactly one ahead sibling → redirect br and proceed with PR creation. Multiple → append a warning naming them and exit without guessing. None → unchanged analysis-only path.
  • Owner: @junior. Single-file, ~25-line shell change in an existing function — exactly the small/low-risk band they own. A mock-repo regression test would be a useful @senior follow-up but isn't blocking.

yes-reply routing → separate issue

That bug lives in route.sh / the hand-back prompt in agent.yml, not in publish.sh, and the fix shape (route-side short-affirmative heuristic vs. tighten the prompt to require @pm yes) needs its own discussion. Per repo rules independent changes go in separate branches/PRs, so I'll plan that one once a sibling issue exists.

@ffaerber ready to start building? reply yes to proceed.

details

🔧 0 tool calls · in 12878 · out 3768 tokens · subscription · model ollama-cloud/minimax-m3:cloud

(no tool calls — text-only reply)

tokens — input 12878 (fresh 12878 · cache 0w / 0r) · output 3768 · reasoning 0 · subscription

ffaerber — the controlled confirmation table isolates the slug-branch cause cleanly, and the `yes`-reply routing issue is a real, separate finding worth its own issue. ## Plan for #124 - Scope: `.gitea/workflows/scripts/publish.sh`, in the `ahead=0` block (~line 355). - Behavior: scan `refs/remotes/origin/${BRANCH}-*` for siblings ahead of `main`. Exactly one ahead sibling → redirect `br` and proceed with PR creation. Multiple → append a warning naming them and exit without guessing. None → unchanged analysis-only path. - Owner: **@junior**. Single-file, ~25-line shell change in an existing function — exactly the small/low-risk band they own. A mock-repo regression test would be a useful @senior follow-up but isn't blocking. ## `yes`-reply routing → separate issue That bug lives in `route.sh` / the hand-back prompt in `agent.yml`, not in `publish.sh`, and the fix shape (route-side short-affirmative heuristic vs. tighten the prompt to require `@pm yes`) needs its own discussion. Per repo rules independent changes go in separate branches/PRs, so I'll plan that one once a sibling issue exists. @ffaerber ready to start building? reply yes to proceed. <details> <summary>details</summary> 🔧 0 tool calls · in 12878 · out 3768 tokens · subscription · model ollama-cloud/minimax-m3:cloud _(no tool calls — text-only reply)_ <sub>tokens — input 12878 (fresh 12878 · cache 0w / 0r) · output 3768 · reasoning 0 · **subscription**</sub> </details> <!-- 🤖 agent reply — do not trigger -->
This repo is archived. You cannot comment on issues.
3 Participants
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: gitea/agents#124