Repository navigation
dev-lead retry doesn't cover failed initial issue implementations (only rate-limited PRs) — issues silently stall #781
Description
Summary
A dev-lead initial issue implementation that fails at the Run issue engine step (for any non-rate-limit reason) has no retry path. dev-lead-retry only scans open PRs for a status=rate-limited marker — it never looks at issues, and it only re-dispatches the rate-limit case. So a failed issue implementation silently falls through the retry net and stays stuck until a human intervenes.
Reproduction (live case: petry-projects/.github#478)
- Issue petry-projects/.github#478 was labeled
dev-leadat ~02:37 UTC on 2026-06-18. - dev-lead run
27733022075(event=issues) ran the engine for ~10 min, posted a full implementation-plan comment, then theRun issuestep failed:##[error]Engine failed to implement issue #478 ##[error]Process completed with exit code 1. - Over the next ~12h,
dev-lead-retry(.github-private) ran on schedule 5+ times (04:58, 10:45, … UTC) — allcompleted/success— and never re-attempted feat: implement issue #404 — PR Review Agent — failures detected 2026-05-28 #478. - feat: implement issue #404 — PR Review Agent — failures detected 2026-05-28 #478 remains
OPEN, still labeleddev-lead, no linked PR, and carries no retry/failure marker label. The only post-failureissuesevent on it was unrelated label activity, correctly classifiedintent=skip reason=label-not-dev-lead.
Root cause
dev-lead-retry.yml is scoped — per its own header — to:
"Scans open PRs across the org for
status=rate-limitedmarkers and re-dispatches the appropriate dev-lead event once the rate limit clears."
So retry coverage is limited on two axes that both exclude this case:
| Axis | Retry covers | #478 |
|---|---|---|
| Object | open PRs | an issue (no PR was ever created) |
| Failure type | status=rate-limited marker |
generic Engine failed to implement (exit 1) |
The initial issue→implementation phase (the Run issue step in dev-lead-reusable) has no failure marker written and no scanner that requeues it. A crash after the plan comment but before a PR exists leaves nothing for retry to find.
Impact
- Any transient engine failure (timeout, overload, tool error) on a first issue implementation permanently strands the issue — no auto-recovery, no alert. It looks "picked up" (plan comment present,
dev-leadlabel on) but never completes. - Erodes trust in the "label it
dev-lead→ it gets fixed" contract.
Recommendation
Give failed initial issue implementations a retry path. Options (in rough order of preference):
- Mark + scan. On
Run issuefailure, label the issue with a recognizable marker (e.g.dev-lead:retrywith an attempt counter in a comment/marker), and extenddev-lead-retry.shto also scan open issues carrying that marker and re-dispatch theissuesintent. Bound attempts (e.g. 3) then fall back to adev-lead:needs-humanlabel + notification. - Self-requeue. On engine failure, emit a bounded
repository_dispatchto re-run the issue intent after a backoff, with an attempt ceiling. - At minimum — visibility. If retry stays PR/rate-limit-only by design, then on a failed initial implementation add a
dev-lead:failedlabel and a comment so the stall is visible and a human is cued, rather than silently looking "in progress."
Acceptance
- A first-attempt
Run issueengine failure results in either an automatic, bounded retry or an explicitneeds-human/failedsignal — never a silent stall. - Retry attempt count is bounded and observable.
- Covered by a test exercising the failed-issue-implementation path.
References
- Stuck issue:
petry-projects/.github#478 - Failed run:
petry-projects/.githubactions run27733022075(Run issue→ failure) - Retry workflow/script:
petry-projects/.github-private→.github/workflows/dev-lead-retry.yml,scripts/dev-lead-retry.sh - Reusable that runs the issue intent:
petry-projects/.github-private→.github/workflows/dev-lead-reusable.yml
Found while monitoring the #478 fix (itself a follow-up from the add-to-project rollout, .github#415).
Activity
github-actions commentedon Jun 18, 2026
Thanks for the thorough write-up — the reproduction trace, root-cause table, and the three concrete options make this very actionable. Confirmed as a bug: the
dev-lead-retrycontract silently excludes failed issue implementations, which breaks the "label it → it gets fixed" guarantee. Before we start implementation: of the three options you outlined, do you have a strong preference, or is there a constraint (e.g. avoiding changes to the reusable caller interface, or keepingdev-lead-retry.shas the single scan surface) that should guide the choice?don-petry commentedon Jun 18, 2026
Investigation findings (code-level confirmation)
I traced the full issue→implementation→retry path. The report is accurate; here is exactly where it breaks, with line references.
1. The silent stall is in
dev-lead-fix-issue.shOn a generic engine failure the script writes no comment, no label, no marker — just a workflow annotation and
exit 1. The issue keeps itsdev-leadlabel and looks "in progress" (the plan comment from an earlier step is already posted). This is the silent stall.The rate-limit branch (
engine_rc == 2, lines 77–87) does post a<!-- dev-lead-rate-limited -->comment — but it only asks a human to re-apply the label. There is no automatic retry even for the rate-limited issue case.2. The retry cron never looks at issues
scripts/dev-lead-retry.sh→scan_repo()(lines 241–268) enumerates only open PRs:prs_json=$(gh api --paginate "repos/${repo}/pulls?state=open&per_page=100" ...)There is no issue enumeration anywhere in the script. So even if a marker existed, nothing scans for it. Both axes in the report's table are confirmed in code: object = PRs only, failure type =
status=rate-limitedonly.3. The re-dispatch plumbing has no "issue" path
The retry mechanism fires
repository_dispatchevents, and the classifier requires a PR number:dev-lead-intent.sh:381—if [ -z "$pr_number" ]; then emit_skip "no-pr-number-in-payload". An issue-only payload is dropped immediately.dev-lead-ci-failureanddev-lead-reviews-retry(dev-lead-intent.sh:402-438), mirrored in the thin caller stubdev-lead.yml:34-35.dev-lead-reusable.yml:53-63) routesclient_payload.pr_number→ PR lane but has noclient_payload.issue_numberbranch.So delivering a failed issue back to the
issueintent requires a new dispatch type end-to-end (stub trigger → classifier → concurrency lane), not just a scanner tweak.4. There is no test or marker convention for the failed-issue path
tests/**/*retry*.batsexists fordev-lead-retry.shat all (only the e2e07-rate-limit-retry.shdry-run).<!-- dev-lead-fix-reviews pr=N sha=SHA intent=X status=rate-limited reset=ISO -->(dev-lead-fix-reviews.sh:733), and the attempt-cap conventionMAX_ATTEMPTS=3fromauto-rebase-retry.sh:40.Proposed plan
I recommend Option 1 (mark + scan) — it keeps retry logic centralized where it already lives (
dev-lead-retry.sh) and reuses the existing marker/dispatch idiom. To de-risk the consumer-facing change, I'd ship it in two phases so the "never silent" guarantee lands first.Phase 1 — Visibility & marker (satisfies "never a silent stall")
dev-lead-fix-issue.sh, on the generic-failure branch (and unify the rate-limit branch):attemptby counting prior<!-- dev-lead-issue N ... -->markers in the issue's comments,+1.attempt < MAX_ATTEMPTS(default 3): post a machine-readable marker comment<!-- dev-lead-issue ${ISSUE_NUMBER} status=failed attempt=${attempt} -->(rate-limit branch usesstatus=rate-limited reset=${ISO}) plus a short human-readable note.attempt >= MAX_ATTEMPTS: apply adev-lead:needs-humanlabel + a visible "automated retries exhausted" comment, and write no retryable marker.needs-humanlabel) even before any auto-retry exists.Phase 2 — Auto-retry scanner
dev-lead-retry.sh: add issue scanning that enumeratesrepos/${repo}/issues?state=open&labels=dev-lead, filters out PRs (.pull_request == null) and any carryingdev-lead:needs-human, then for each looks for a non-supersededdev-lead-issuemarker — honoringreset=for the rate-limited case andattempt < MAX_ATTEMPTS.dev-lead-issue-retrydispatch carryingclient_payload.issue_number(+attempt).dev-lead-intent.sh: restructure therepository_dispatchbranch so issue-retry readsissue_number(notpr_number), generalize the hands-off label lookup to use the issue number, and route to the existingissueintent.dev-lead.yml(stub) +dev-lead-reusable.yml: adddev-lead-issue-retrytorepository_dispatch.types, and add aclient_payload.issue_number→dev-lead-issue-<n>branch to the concurrency router so a retry shares the issue lane (no duplicate concurrent run). Propagate the stub change via standards-sync.check_existing_pr()indev-lead-fix-issue.shalready prevents duplicate work once a PR exists; the attempt counter is the hard loop bound.Tests (acceptance criterion 3)
tests/dev-lead/unit/test_dev_lead_retry.bats: issue scanning, attempt bounding, reset honoring,needs-human/terminal skip.test_fix_issue.bats: failure path writes the marker, increments attempt, appliesdev-lead:needs-humanat the ceiling.repository_dispatch_issue_retry.json+ classifier coverage in thetest_intent_*suite.07-rate-limit-retry.sh(or a sibling) to exercise the failed-issue path.Acceptance mapping
needs-human/failedsignal (Phase 1 guarantees the signal; Phase 2 adds the retry).MAX_ATTEMPTS=3) and observable (in marker + label).I've left the issue open and not started implementation — happy to proceed with Phase 1 first (lowest-risk, ships the guarantee) on
claude/issue-781-analysis-ecvnhconce you confirm the approach.Generated by Claude Code
don-petry commentedon Jun 18, 2026
Revised scope (supersedes the plan above)
Folding in failure-cause visibility. The key realization: the cause is already captured by the engine layer (
run_writerredacts engine output to/tmp/dev-lead-session-output.txtand appends a "Dev-Lead session output" block to$GITHUB_STEP_SUMMARY, engine.sh:1105-1142) and already classified insiderun_writer_with_fallback(engine.sh:1152-1214:any_rate_limited/any_missing/ engine-error) — both are then discarded beforedev-lead-fix-issue.shsees them. So this is mostly plumbing existing signals through to the issue and the retry marker, not new capture. No new workflow — everything rides the existingRun issuestep indev-lead-reusable.yml, the existing retry cron, and the existing step-summary/artifact machinery.A. Engine layer — classify & expose the cause (
scripts/engine.sh)run_writer_with_fallback: before each terminalreturn, write a one-line reason to/tmp/dev-lead-failure-reason(mirrors the existing/tmp/dev-lead-rate-limit-resetsidecar):rate-limited(all engines exit 2) ·missing-binary(any exit 127 — deterministic/infra) ·engine-error(other). No control-flow / exit-code changes.run_writer: move the redact + persist +$GITHUB_STEP_SUMMARYwrite so it also covers the rate-limit early-return path (currentlyreturn 2at 1091-1095 happens before the summary block). Result: session output is visible on the run page for all failure classes, not just generic ones.B. Issue handler — surface cause on the issue + marker (
scripts/dev-lead-fix-issue.sh)Replace the silent
exit 1(88-92) and unify the rate-limit branch (77-87). At failure:/tmp/dev-lead-failure-reason(defaultengine-error) and/tmp/dev-lead-rate-limit-reset.attemptfrom prior<!-- dev-lead-issue ${ISSUE_NUMBER} ... -->markers + 1./tmp/dev-lead-session-output.txtand a run deep-link${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}.rate-limitedstatus=rate-limited reset=…+ ack commentdev-lead:needs-human+ commentengine-errorstatus=failed+ cause snippet + run linkdev-lead:needs-human+ commentmissing-binarydev-lead:needs-human+ comment (deterministic infra; retry won't help)<!-- dev-lead-issue ${N} status=<failed|rate-limited> attempt=${K} reason=<r> run=<id> [reset=ISO] -->. Human-readable body always includes reason + run link (+ snippet forengine-error).exit 1/exit 2) so the workflow run still reflects failure.C. Retry scanner — scan issues (
scripts/dev-lead-retry.sh)scan_repo_issues: enumeraterepos/${repo}/issues?state=open&labels=dev-lead&per_page=100, filter.pull_request == null, skipdev-lead:needs-human.dev-lead-issuemarker → honorreset=for rate-limited, enforceattempt < MAX_ATTEMPTS(default 3, perauto-rebase-retry.sh:40), skip if an opendev-lead/issue-N*PR already exists (terminal/dedup).dev-lead-issue-retrywithclient_payload.issue_number(+ attempt). Cause data rides the marker — scanner never re-derives it. Called fromscan_repoin the same pass (same DRY_RUN/stagger semantics). Update script header.D. Dispatch routing (
scripts/dev-lead-intent.sh)repository_dispatchbranch so the earlypr_numberrequirement (line 381) no longer drops issue-only payloads. Readdispatch_typefirst; fordev-lead-issue-retry, readissue_number, run the hands-off label lookup against the issue number, and route to the existingissueintent with{issue_number}context. Existing types unchanged.E. Trigger + concurrency wiring
.github/workflows/dev-lead.yml(thin caller stub): adddev-lead-issue-retrytorepository_dispatch.types. Propagate to consumers via standards-sync — this is a caller-interface contract change, same path as whendev-lead-reviews-retrywas added..github/workflows/dev-lead-reusable.yml: add aclient_payload.issue_number→dev-lead-issue-<n>branch to the concurrency router (53-63) so a retry shares the issue lane (no duplicate concurrent run vs. a label event).F. Tests
tests/dev-lead/unit/test_dev_lead_retry.bats: issue scan, attempt bounding, reset honoring, needs-human/terminal skip, open-PR dedup.test_fix_issue.bats: failure path writes marker + cause snippet + run link;missing-binary→needs-humanwith no retryable marker; attempt ceiling →needs-human.test_engine_fallback.bats:/tmp/dev-lead-failure-reasonvalue per failure class; session output written on the rate-limit path.repository_dispatch_issue_retry.json+test_intent_*coverage.07-rate-limit-retry.sh(or sibling) for the failed-issue path.G. Docs/standards
ci-standards.mdand the consumer manifest for the new dispatch type and thedev-lead:needs-humanlabel semantics.Acceptance mapping
needs-human/failedsignal — never silent (A+B guarantee the signal even before C lands).MAX_ATTEMPTS=3) and observable (markerattempt=+ label).Phasing (unchanged intent, now cause-aware)
Ready to start Phase 1 on
claude/issue-781-analysis-ecvnhcon your go.Generated by Claude Code
don-petry commentedon Jun 19, 2026
Step 0 — live root cause of the #478 stall (run
27733022075)Pulled the failed
Run issuejob log (the prior session couldn't reachpetry-projects/.github). The genericEngine failed to implement issue #478/exit code 1was hiding a 600-second per-tier timeout kill:02:37:28Run issuestep starts;engine: claude (triage: haiku 4.5 → deep: opus 4.8 …)02:37:30.3Switched to a new branch 'dev-lead/issue-478-20260618-0237'+[headroom] claude usage 0% — ok(not rate-limited)02:47:30.5##[error]Engine failed to implement issue #478→exit code 1That's a clean 600.2s wall (
02:37:30.3 → 02:47:30.5). The writer runs throughrun_writer_with_fallback(dev-lead-fix-issue.sh:76), and the deep tier is bounded byDEEP_TIMEOUT_SEC(default 600,engine.sh:37; the run's env had it blank, so the default applied). The opus 4.8 writer invocation ran past 600s and was SIGTERM-killed by GNUtimeout(rc 124).Cause class:
engine-error(a transient/retryable timeout —engine.sh:43explicitly lists124=GNU timeoutas transient). Not rate-limit, not missing-binary.Why this is the textbook case for #781
timeout/rc=124) never escaped — only the genericexit 1reached the run; nothing reached the issue. That's the visibility blind spot Phase 1 (A: failure-reason sidecar + B: cause-on-issue marker/comment) closes, and the missing auto-retry is Phase 2 (C/D/E).Proceeding with Phase 1 → Phase 2 on
claude/issue-781-analysis-ecvnhc.feat(dev-lead): surface engine failure cause on failed issue impls (#781
feat(dev-lead): bounded auto-retry for failed initial issue impls (#781…
ci(dev-lead): add dev-lead-issue-retry + dev-lead-reviews-retry dispa…
ci(dev-lead): add dev-lead-issue-retry + dev-lead-reviews-retry dispa…
ci(dev-lead): add dev-lead-issue-retry + dev-lead-reviews-retry dispa…
ci(dev-lead): add dev-lead-issue-retry + dev-lead-reviews-retry dispa…
ci(dev-lead): add dev-lead-issue-retry + dev-lead-reviews-retry dispa…
ci(dev-lead): add dev-lead-issue-retry + dev-lead-reviews-retry dispa…
ci(dev-lead): add dev-lead-issue-retry + dev-lead-reviews-retry dispa…
50 remaining items
ci(dev-lead): enable dev-lead-issue-retry dispatch type (#781) (#398)
feat(dev-lead): retry failed initial issue implementations, never sil…
feat(dev-lead): retry failed initial issue implementations, never sil…
feat(dev-lead): retry failed initial issue implementations, never sil…
feat(dev-lead): retry failed initial issue implementations, never sil…
feat(dev-lead): retry failed initial issue implementations, never sil…
feat(dev-lead): retry failed initial issue implementations, never sil…
feat(dev-lead): retry failed initial issue implementations, never sil…
feat(dev-lead): retry failed initial issue implementations, never sil…
feat(dev-lead): retry failed initial issue implementations, never sil…
feat(dev-lead): retry failed initial issue implementations, never sil…
ci(dev-lead): enable dev-lead-issue-retry dispatch type (#781) (#301)
feat: implement issue #454 — Compliance: ruleset-drift-pr-quality-req…
ci(dev-lead): enable dev-lead-issue-retry dispatch type (#781) (#301)
ci(dev-lead): enable dev-lead-issue-retry dispatch type (#781) (#301)