Skip to content

Commit a3e6553

Browse files
authored
fix: make Stage 2 publish by command, and fail the job when it doesn't (#649)
Stage 2 (issue-analyze.yml) exited `success` while posting nothing on five of nine non-skipped runs over three days, billing $0.33-1.58 each. Every signal said the run was healthy: green job, is_error false, permission_denials_count 0. The `ready-for-analysis` label staying put is indistinguishable from "nobody has run analyze yet", so the backlog pass could not see it either. On #593 the maintainer eventually wrote the diagnosis by hand -- the stage was paid for and the work done twice. The prompt never told the agent to RUN anything to publish. Step 4 read "Post ONE comment on the issue with this structure:" followed by a markdown template -- a description of a document, not an instruction to execute a command. These workflows run in agent mode (use_sticky_comment false, track_progress false), so the action posts nothing on the agent's behalf and creates no tracking comment. An agent that renders the template as its final assistant message has, by its own lights, finished; it then never reaches the labelling step, which is why the label was untouched too. The contrast is what identifies it. On the same action version (459ad358 / CLI 2.1.234), Stage 4 -- whose prompt names `gh pr review` explicitly -- landed an APPROVED verdict, while Stage 2 went silent three times within three seconds. Stage 2 was the only bot workflow whose publish step was prose, and the only one that failed to publish. So publishing is now a literal `gh issue comment --body-file` command on both the conclusive and inconclusive paths, and PROCESS is renumbered (it had two steps numbered 3, so "am I done with step 3" was satisfiable by the wrong one). That fix is a behavioural argument about what a model will infer, so it is not trusted on its own: a post-agent step re-reads the issue and fails the job when no comment was posted or neither label applied. It asserts and deliberately does not repair -- posting on the agent's behalf would be a second publishing path routing around the first one failing, which masks the regression instead of surfacing it. quality-check.sh gains a matching static gate so neither half can be removed silently. Closes #646
1 parent 0620dd9 commit a3e6553

3 files changed

Lines changed: 208 additions & 16 deletions

File tree

.github/workflows/issue-analyze.yml

Lines changed: 113 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ jobs:
3535
app-id: ${{ secrets.CLAUDE_REVIEWER_APP_ID }}
3636
private-key: ${{ secrets.CLAUDE_REVIEWER_PRIVATE_KEY }}
3737

38+
# Marks the point the agent started, so the verification step below can
39+
# tell a comment IT posted from the `@claude-bot analyze` trigger comment
40+
# that started this run (which is always already present).
41+
- name: Mark analysis start
42+
id: started
43+
run: echo "at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
44+
3845
- uses: anthropics/claude-code-action@v1
3946
with:
4047
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -109,40 +116,65 @@ jobs:
109116
the reporter's), file:line references, and whether the
110117
debug bundle reveals a different root cause than claimed."
111118
112-
3. After the sub-agent reports back, **independently verify** by
119+
4. After the sub-agent reports back, **independently verify** by
113120
reading the file:line locations it cited. Do not just trust
114121
the summary — quote the actual code.
115122
116-
4. Post ONE comment on the issue with this structure:
123+
5. **Publish the analysis by RUNNING these two commands.** Writing
124+
the diagnosis as your reply does NOT publish it — you are running
125+
headless, nothing is watching your output, and a final message
126+
reaches nobody. The issue only changes if `gh` changes it.
127+
128+
Write the body to a file first, then post it. Use a file rather
129+
than `-b` because the body contains backticks, quotes and
130+
newlines that do not survive shell quoting:
131+
132+
cat > /tmp/analysis.md <<'ANALYSIS_EOF'
133+
## Root cause
134+
<one-paragraph plain-English explanation>
135+
136+
## Evidence
137+
- `path/to/file.py:LINE` — <quoted code excerpt>
138+
- <additional file:line refs as needed>
117139
118-
## Root cause
119-
<one-paragraph plain-English explanation>
140+
## Proposed fix
141+
<high-level approach in 2-4 sentences — NO code yet>
120142
121-
## Evidence
122-
- `path/to/file.py:LINE` — <quoted code excerpt>
123-
- <additional file:line refs as needed>
143+
## Risks / open questions
144+
<anything you couldn't determine, edge cases, or test gaps>
124145
125-
## Proposed fix
126-
<high-level approach in 2-4 sentences — NO code yet>
146+
---
147+
Reply `@claude-bot fix` to implement and open a draft PR.
148+
ANALYSIS_EOF
127149
128-
## Risks / open questions
129-
<anything you couldn't determine, edge cases, or test gaps>
150+
gh issue comment ${{ github.event.issue.number }} --body-file /tmp/analysis.md
130151
131-
---
132-
Reply `@claude-bot fix` to implement and open a draft PR.
152+
Check that the command succeeded. If it failed, fix the problem
153+
and run it again — do not proceed to step 6 with the comment
154+
unposted, and do not end the run having only described it.
133155
134-
5. Update labels:
156+
6. Update labels:
135157
gh issue edit ${{ github.event.issue.number }} --add-label analyzed --remove-label ready-for-analysis
136158
137159
──────────────────────────────────────────────────────────────────
138160
IF YOU CAN'T REACH A CONCLUSION
139161
──────────────────────────────────────────────────────────────────
140-
Post what you found, what you read, and what specific information
141-
is still missing. Then:
162+
Publish what you found, what you read, and what specific
163+
information is still missing — again by RUNNING the commands, not
164+
by describing the outcome:
165+
166+
cat > /tmp/analysis.md <<'ANALYSIS_EOF'
167+
## Inconclusive
168+
<what you read, what you ruled out, what is still missing>
169+
ANALYSIS_EOF
170+
171+
gh issue comment ${{ github.event.issue.number }} --body-file /tmp/analysis.md
142172
gh issue edit ${{ github.event.issue.number }} --add-label needs-human-review
143173
144174
Be honest — "I couldn't find the bug in the current code" is a
145175
valid outcome. Do not invent a root cause to fill the template.
176+
An inconclusive comment that is actually posted is worth far more
177+
than a confident one that is not.
146178
147179
──────────────────────────────────────────────────────────────────
148180
HARD CONSTRAINTS
@@ -151,4 +183,69 @@ jobs:
151183
- DO NOT open a PR.
152184
- DO NOT skip the bess-analyst delegation step — it's the whole
153185
point of this stage.
186+
- DO NOT end the run without having RUN `gh issue comment`. A
187+
diagnosis you only wrote out is a diagnosis nobody receives,
188+
and the job will fail the verification step below.
154189
- Quote real code. Don't paraphrase.
190+
191+
# The acceptance criterion of #646: a Stage 2 run must not be able to
192+
# exit `success` having written nothing to the issue.
193+
#
194+
# Five of nine non-skipped runs did exactly that over three days, at
195+
# $0.33-1.58 each, and every signal said they were healthy -- green run,
196+
# is_error false, permission_denials_count 0. The `ready-for-analysis`
197+
# label staying put is indistinguishable from "nobody has run analyze
198+
# yet", so the backlog pass could not see it either. On #593 the
199+
# maintainer eventually wrote the diagnosis by hand: the stage was paid
200+
# for and the work was done twice.
201+
#
202+
# `if: always()` is load-bearing -- the whole point is to catch the run
203+
# that the agent step already reported as successful.
204+
#
205+
# This step ASSERTS; it deliberately does not repair. Posting the comment
206+
# on the agent's behalf would be a second publishing path whose only job
207+
# is to route around the first one failing, which masks the regression
208+
# instead of surfacing it (docs/agents/rules.md, Debugging Protocol
209+
# step 8). A loud red run is the product here.
210+
- name: Verify the analysis reached the issue
211+
if: always()
212+
env:
213+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
214+
ISSUE: ${{ github.event.issue.number }}
215+
SINCE: ${{ steps.started.outputs.at }}
216+
run: |
217+
set -euo pipefail
218+
219+
posted=$(gh issue view "$ISSUE" --json comments \
220+
--jq "[.comments[] | select(.createdAt > \"$SINCE\")] | length")
221+
labels=$(gh issue view "$ISSUE" --json labels --jq '[.labels[].name] | join(",")')
222+
223+
echo "Comments posted since ${SINCE}: ${posted}"
224+
echo "Labels now: ${labels:-<none>}"
225+
226+
failed=0
227+
228+
if [ "$posted" -eq 0 ]; then
229+
echo "::error::Stage 2 posted NO comment on issue #${ISSUE}. The run"
230+
echo "::error::billed for an analysis that reached nobody. Do not re-fire"
231+
echo "::error::blindly -- check whether the agent described the comment"
232+
echo "::error::instead of running 'gh issue comment' (see #646)."
233+
failed=1
234+
fi
235+
236+
case ",$labels," in
237+
*,analyzed,*|*,needs-human-review,*) ;;
238+
*)
239+
echo "::error::Stage 2 applied neither 'analyzed' nor"
240+
echo "::error::'needs-human-review' to issue #${ISSUE}. Without one of"
241+
echo "::error::them the issue is indistinguishable from one that was"
242+
echo "::error::never analysed, and the backlog pass will skip it."
243+
failed=1
244+
;;
245+
esac
246+
247+
if [ "$failed" -eq 1 ]; then
248+
exit 1
249+
fi
250+
251+
echo "✅ Analysis published: ${posted} new comment(s), labels: ${labels}"

docs/agents/workflow.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ Issue opened/edited → issue-triage.yml [auto, ~$0.05]
4343
→ delegates to `bess-analyst` sub-agent
4444
→ posts Root cause / Evidence / Proposed fix
4545
→ label `analyzed` (or `needs-human-review`)
46+
→ job FAILS if it posted no comment or applied
47+
neither label — a run that analysed nothing
48+
must not report success (#646)
4649
4750
@claude-bot fix → issue-fix.yml [manual, ~$1–4]
4851
→ reads Stage 2 diagnosis comment

scripts/quality-check.sh

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,98 @@ then
420420
ERRORS=$((ERRORS + 1))
421421
fi
422422

423+
echo ""
424+
echo "📋 Checking bot workflow publish contract..."
425+
echo "-------------------------------------------"
426+
427+
# Stage 2 (issue-analyze.yml) spent three days exiting `success` while posting
428+
# nothing -- five of nine non-skipped runs produced no comment and no label,
429+
# burning $0.33-1.58 each (#646). Every layer reported healthy: the run was
430+
# green, is_error false, permission_denials_count 0.
431+
#
432+
# Two independent things have to hold, and this gate checks BOTH because either
433+
# one alone leaves the failure silent:
434+
#
435+
# 1. The prompt must tell the agent to RUN a command to publish. Stage 2 said
436+
# "Post ONE comment on the issue with this structure:" followed by a
437+
# markdown template -- a description of a document, not an instruction to
438+
# execute anything. These workflows run in AGENT mode (use_sticky_comment
439+
# false, track_progress false), so the action posts nothing on the agent's
440+
# behalf: an agent that renders the template as its final assistant message
441+
# has, by its own lights, finished. It then never reaches the labelling
442+
# step, which is why the label was untouched too.
443+
#
444+
# The contrast is what makes this more than a story. On the SAME action
445+
# version (459ad358 / CLI 2.1.234), Stage 4's review -- whose prompt names
446+
# `gh pr review` explicitly -- landed an APPROVED verdict, while Stage 2
447+
# went silent three times in three seconds. Stage 2 was the only bot
448+
# workflow whose publish step was prose, and the only one that failed to
449+
# publish.
450+
#
451+
# 2. The workflow must VERIFY it afterwards. The prompt fix is a behavioural
452+
# argument about what a model will infer, so it cannot be trusted on its
453+
# own -- the next upstream version may infer differently, exactly as this
454+
# one did. Only a post-condition on the job makes that loud instead of
455+
# green.
456+
#
457+
# Deliberately NOT checked for, and deliberately not built: a step that posts
458+
# the comment on the agent's behalf when it didn't. That is a second publishing
459+
# path whose only job is to route around the first one failing, and it would
460+
# mask the regression rather than surface it (docs/agents/rules.md, Debugging
461+
# Protocol step 8). The guard asserts; it does not repair.
462+
if ! python3 - <<'PY'
463+
import pathlib, re, sys
464+
465+
WORKFLOWS = pathlib.Path(".github/workflows")
466+
467+
# Workflows whose whole product is a comment on the issue. Stage 3 is excluded
468+
# on purpose: its product is a PR, which is observable without this check.
469+
PUBLISHERS = {
470+
"issue-triage.yml": "gh issue comment",
471+
"issue-analyze.yml": "gh issue comment",
472+
}
473+
474+
bad = []
475+
476+
for name, command in PUBLISHERS.items():
477+
path = WORKFLOWS / name
478+
if not path.is_file():
479+
print(f"❌ {name} is missing -- the publish contract cannot be checked")
480+
bad.append(name)
481+
continue
482+
if command not in path.read_text():
483+
print(
484+
f"❌ {name} never names `{command}`. Its publish step is prose, so "
485+
"the agent can render it as a final message and exit success "
486+
"having written nothing to GitHub (#646)."
487+
)
488+
bad.append(name)
489+
490+
# The post-agent guard: a run that published nothing must fail the job. Checked
491+
# by the shape that actually does the work -- a step keyed on `if: always()`
492+
# that re-reads the issue -- rather than by step name, which renames freely.
493+
analyze = WORKFLOWS / "issue-analyze.yml"
494+
if analyze.is_file():
495+
text = analyze.read_text()
496+
has_always = re.search(r"^\s*if:\s*always\(\)\s*$", text, re.MULTILINE)
497+
has_readback = "--json comments" in text and "--json labels" in text
498+
if not (has_always and has_readback):
499+
print(
500+
"❌ issue-analyze.yml has no post-agent verification step. A run "
501+
"that posts no comment and applies no label must FAIL the job, "
502+
"not exit success (#646 acceptance criterion)."
503+
)
504+
bad.append("issue-analyze.yml:guard")
505+
506+
if bad:
507+
sys.exit(1)
508+
print(f"✅ Bot workflow publish contract intact ({len(PUBLISHERS)} publishers "
509+
"name their command, Stage 2 verifies it posted)")
510+
PY
511+
then
512+
ERRORS=$((ERRORS + 1))
513+
fi
514+
423515
echo ""
424516
echo "📋 Checking scenario discovery coverage..."
425517
echo "-------------------------------------------"

0 commit comments

Comments
 (0)