Skip to content

An epic-child PR's merge ref goes stale when the epic branch moves, and nothing retriggers it #8880

Closed

Description

@usirin

An epic child's PR is opened against the epic assembly branch. When the driver pushes that
assembly branch mid-flight (merging origin/main in to clear a base-side red, per the epic-child
claim step in claude-plugins/fabrika/skills/operate/SKILL.md), the child PR's checks do not
re-run. The red the child inherited from the old base stays on the PR, and no verb in the pipeline
retriggers it.

On lane 8716, child #8766's PR #8859 sat red on roadmap-guard after epic/8716 was moved. The
operator cleared it by closing and reopening the PR twice, because the first reopen raced the
merge-ref recomputation and re-ran the guard against the stale base anyway. Close/reopen is also
the wrong lever for a second reason: it tears the PR's preview stage down mid-deploy (#8881).

Triage note. The stated cause needs checking before the fix is chosen. The issue says GitHub does
not rebuild pull/N/merge on a base push. The likelier mechanism is that GitHub does recompute the
merge ref (asynchronously, which is what the first reopen raced) but emits no pull_request event
for a base push, so nothing schedules a run. The two readings point at different fixes: if the ref
does rebuild, a check-suite re-request at the unchanged head is enough; if it does not, only a head
push (empty commit) will do. Settle it against the real platform first, per this repo's
ground-falsifiable-claims rule.

Triage note. heal-ci cannot cover this today. Its one rerun arm is gated to a recognised
transient signature (claude-plugins/fabrika/skills/heal-ci/SKILL.md step 4), and a stale-base
red is not transient; there is no diagnosis token for "the base moved under this PR".

Related, not duplicates: #8881 (the close/reopen fallout), #6918 (no verb refreshes a lane branch
from origin/main), #8839 (build pr has no --base), #8863 (the roadmap-guard red this surfaced
through, closed, wrong stated cause).

Acceptance criteria

  • The platform behaviour is verified against a real PR whose base moved. Which of the two
    readings above holds is recorded on this issue or in the PR body, with what was read.
  • After an epic assembly branch is pushed, the operator has a documented, non-destructive way to
    retrigger every open child PR's checks. Close/reopen is explicitly named as forbidden, with
    Closing and reopening a PR tears down its preview stage mid-deploy and fails the deploy on a deleted D1 #8881 as the reason.
  • The step lands where the driver will hit it: the epic-child section of
    claude-plugins/fabrika/skills/operate/SKILL.md, adjacent to the base push it follows.
  • If the retrigger is mechanical enough to be a verb, it is one under
    packages/fabrika-cli/src/lane/ rather than hand-rolled gh api in skill prose (ADR 0228).
  • The retrigger is idempotent: running it against a child whose checks are already current is a
    no-op, not a second run.
  • BaseStanding.behindBy is read by a consumer or dropped from the type. compareStanding refuses the whole sweep when GitHub omits behind_by, and retriggerOne reads only status — so a field nothing consumes can fail a sweep.
  • lane retrigger's exit 11 either means what the CLI description and the verb-reference row say ("before anything was written") or the wording matches the behaviour. Today a comparison that fails on the second child, after the first child's head was already moved, returns 11 with a write behind it.

Original report (verbatim)

Summary

GitHub does not rebuild a PR's pull/N/merge ref when the PR's base branch is pushed. After the driver moved epic/8716 to clear a roadmap-guard red, PR #8859's checks kept running against the old merge commit and stayed red. The only way through was closing and reopening the PR, twice.

What I was doing

Driving epic lane 8716. Child #8766's PR #8859 (base epic/8716) went red on roadmap-guard because the epic branch had drifted from main. I merged origin/main into epic/8716 and pushed, expecting the child's checks to go green.

What I observed

The PR's check suite did not re-run against the moved base. The roadmap-guard job kept evaluating the stale pull/8859/merge commit. The operator closed and reopened the PR to force a merge-ref rebuild; the first reopen raced the rebuild and re-ran the guard against the stale base again, so it took a second close/reopen. The reviewer filed the same red as #8863 with a wrong stated cause (it named the guard, the real cause is the stale merge ref).

Why it matters

Every epic run that moves its assembly branch mid-flight will leave its open child PRs red with no clean retrigger. Close/reopen is the wrong tool: it also tears down the preview stage mid-deploy (filed separately). Each occurrence is a hand fix the founder has ruled should not exist (#8807 R4.1).

Pointers

Suggested next step (non-binding)

After an epic-branch push, have the operator retrigger the child PRs' checks deliberately, via gh api re-requesting the check suite or an empty commit on the child branch, instead of close/reopen. Document the merge-ref behavior in the operate skill.


Filed by an agent · session 94113692-2733-4534-9963-c7ae534247d0 · branch main · 2026-09-10T05:09:49Z

Activity

  1. usirin commented on Sep 10, 2026

    @usirin
    MemberAuthor
    No description provided.
  2. added
    axis:pipeline-hardeningStanding cross-cutting axis: pipeline hardening (was milestone #1; go-forward label)
    p1Medium priority
    ready-for:agentAn execution engine may pick this up.
    status:triagedTriage signed off; ready for write-code to pick
    type:bugBehavior diverges from intent
    and removed on Sep 10, 2026
  3. usirin commented on Sep 17, 2026

    @usirin
    MemberAuthor

    Built fabrika lane retrigger <epic> plus the operate step that runs it after lane push.

    Read first: the platform question the issue asked to settle. It is answered in the PR body off a live
    read of this repository, not off docs alone — GitHub rebuilds refs/pull/<n>/merge on a base push
    and emits no event for it, so a re-run (which replays the original event's pinned SHA) cannot clear a
    stale-base red and the head has to move.

    Then packages/fabrika-cli/src/lane/retrigger-verb.ts: the no-op arm is the one to grade against
    criterion 5 — staleness is the platform's own comparison against the base as it stands, so a child
    already carrying the base tip is read and never written to. The 422 split (conflict versus a head a
    sibling moved) is the other judgement call: GitHub documents one status for both, so the verb
    re-reads the head to tell them apart.

    Tests run: packages/fabrika-cli/src/lane/retrigger-verb.unit.test.ts (10, new),
    src/io/pulls.unit.test.ts, src/lane/command-help.unit.test.ts, src/lane/verbs.unit.test.ts,
    src/short-description.unit.test.ts. build check --surface code and --surface prose both green,
    and the package typecheck reports 0 errors.

  4. usirin commented on Sep 17, 2026

    @usirin
    MemberAuthor

    Lane transcript for 8880. Terminal state: complete (LANE-TERMINAL). PR #9379 merged at b6b98ffcf95411e4c5b07356c170a4e91778fbb1.

    One build round, one review FAIL, one repair round, review PASS, ship landed through the queue.

    lane print + lane history
    {
      "phases": [
        {
          "name": "pipeline",
          "tasks": [
            "issue"
          ]
        }
      ],
      "terminals": {
        "complete": "complete",
        "tripped": "tripped"
      },
      "tasks": {
        "issue": {
          "initial": "queued",
          "maxRetries": 3,
          "maxWaits": 3,
          "maxLaps": 16,
          "states": {
            "queued": [
              "WIP",
              "BLOCKED",
              "CLEARED",
              "CANCELLED",
              "LANDED"
            ],
            "build": [
              "DONE",
              "BLOCKED",
              "LAP",
              "CLEARED",
              "CANCELLED",
              "LANDED"
            ],
            "build:ui": [
              "DONE",
              "BLOCKED",
              "LAP",
              "CLEARED",
              "CANCELLED",
              "LANDED"
            ],
            "review": [
              "PASS",
              "BLOCKED",
              "LAP",
              "FAIL",
              "CLEARED",
              "CANCELLED",
              "LANDED"
            ],
            "review:ui": [
              "PASS",
              "BLOCKED",
              "LAP",
              "FAIL",
              "CLEARED",
              "CANCELLED",
              "LANDED"
            ],
            "ship": [
              "LAP",
              "DONE",
              "WIP",
              "BLOCKED",
              "FAIL",
              "CLEARED",
              "CANCELLED",
              "LANDED"
            ],
            "ship:queued": [
              "LAP",
              "DONE",
              "BLOCKED",
              "WIP",
              "FAIL",
              "CLEARED",
              "CANCELLED",
              "LANDED"
            ],
            "blocked": [
              "UNBLOCKED",
              "CLEARED",
              "CANCELLED",
              "LANDED"
            ],
            "human:cp-approval": [
              "UNBLOCKED",
              "CLEARED",
              "CANCELLED",
              "LANDED"
            ],
            "human:queue-stall": [
              "UNBLOCKED",
              "CLEARED",
              "CANCELLED",
              "LANDED"
            ],
            "human:machinery-stall": [
              "UNBLOCKED",
              "CLEARED",
              "CANCELLED",
              "LANDED"
            ],
            "human:budget-spent": [
              "UNBLOCKED",
              "CLEARED",
              "CANCELLED",
              "LANDED"
            ],
            "shipped": [
              "CLEARED",
              "CANCELLED",
              "LANDED"
            ],
            "diagnosed": [
              "CLEARED",
              "CANCELLED",
              "LANDED"
            ],
            "board:cancelled": [
              "CLEARED"
            ],
            "board:landed": [
              "CLEARED"
            ]
          }
        }
      }
    }
    [
      {
        "task": "issue",
        "event": "ISSUE.WIP",
        "at": "2026-09-17T00:51:03.740Z"
      },
      {
        "task": "issue",
        "event": "ISSUE.DONE",
        "at": "2026-09-17T01:08:09.920Z",
        "pr": "https://github.com/kamp-us/phoenix/pull/9379"
      },
      {
        "task": "issue",
        "event": "ISSUE.FAIL",
        "at": "2026-09-17T01:24:41.616Z",
        "pr": "https://github.com/kamp-us/phoenix/pull/9379"
      },
      {
        "task": "issue",
        "event": "ISSUE.DONE",
        "at": "2026-09-17T01:34:12.739Z",
        "pr": "https://github.com/kamp-us/phoenix/pull/9379"
      },
      {
        "task": "issue",
        "event": "ISSUE.PASS",
        "at": "2026-09-17T01:50:00.089Z",
        "pr": "https://github.com/kamp-us/phoenix/pull/9379"
      },
      {
        "task": "issue",
        "event": "ISSUE.DONE",
        "at": "2026-09-17T01:59:00.238Z",
        "pr": "https://github.com/kamp-us/phoenix/pull/9379",
        "partial": false,
        "landed": [
          9379
        ]
      }
    ]
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    axis:pipeline-hardeningStanding cross-cutting axis: pipeline hardening (was milestone #1; go-forward label)p1Medium priorityready-for:agentAn execution engine may pick this up.status:triagedTriage signed off; ready for write-code to picktype:bugBehavior diverges from intent

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions