Skip to content

fix(roadmap): remove the orphan draft that a failed marks write left #4051

Open

Description

@dan-petty

Problem

Publish in _stage_one (stage.py) has these steps in order:

  1. Push the branch.
  2. Open the draft PR.
  3. Write the marks in one set_marks (BRANCH, PR, SHA, GROUPS, TEXT_HASH and others).
  4. Add the labels.
  5. Post the comment.

_open_pull removes the branch when the draft cannot be made. A label failure is caught (_label_pull and _label_needs_fix catch GitHubOperationError). A failed comment comes after the marks write, so the item is recorded.

Only a failed set_marks (step 3) leaves a pushed branch and a draft PR with no stage.pr. A kill of the worker between step 1 and step 3 leaves the same state.

The item then stays out of every later round as held by a pushed branch (_Judge._held, issue_holder). _reconcile reads closed items only. A person must delete the branch and close the draft by hand.

Why a search by issue number is not safe. The held check finds a branch by the issue number. A branch of a person or of an agent for the same issue number looks the same. OpenPull has no author field. The only author guard is _foreign_commits. It compares commit authors with the git user.name of the clone. If the clone has the identity of that author, the guard passes. A rule that deletes every pushed branch of an Item with no stage.pr would then delete the work of a person. It would also defeat _Judge._held (#2654).

Expected

Mark the intent before the push. Then the job can tell its own orphan from any other branch.

  1. Add the key stage.pending to StageRecordKey (stores/store.py). Its value is JSON: the branch name and the time. _stage_one writes it with one set_marks before the push. The marks write after the draft sets it to None, together with the other keys.
  2. Add a pass _orphans next to _reconcile. It reads the open items that have stage.pending and no stage.pr.
    • The pass uses the branch name from the mark. A retitle between the failure and the next round changes the slug, but not the mark.
    • A mark younger than a constant age (1 hour) is left alone. A run in another clone can be in the middle of its publish.
    • A branch that is not on origin gives Removal(..., gone=True): only the record is cleared.
    • A pushed branch with _foreign_commits equal to 0 gives a Removal. _remove_staged closes the draft, deletes the branch and clears the record. The draft is the open pull request with that head. The pass reads store.open_pulls() once per round, and only when a pushed branch needs it.
    • A pushed branch with a commit of another author is kept and reported as a KeptBranch, as _reconcile does for closed items. It costs no extra request.
  3. A pushed branch with no stage.pending mark is never touched. It belongs to a person or an agent.

The item is then planned again in the next round. The plan facts (groups with measured tokens, the hash of the text) are in the marks write that failed. They cannot be rebuilt without the planner.

Request cost: each staged item adds 2 requests (one read and one PATCH of the issue for stage.pending). Staged items are rare. A round with an orphan to remove adds 1 read of the open pull requests. Other rounds add none.

Acceptance criteria

  • uv run pytest tests/roadmap/stage/test_roadmap_stage_orphan.py -k marks_write_fails - a test fails the marks write after the draft and runs the next round. It finds the draft closed, the branch deleted and the item planned again.
  • uv run pytest tests/roadmap/stage/test_roadmap_stage_orphan.py -k killed_after_push - a kill between the push and the draft leaves a branch with no draft. The next round deletes the branch and clears the record.
  • uv run pytest tests/roadmap/stage/test_roadmap_stage_orphan.py -k retitle_between - a retitle between the failure and the next round changes the slug. The orphan is still found by the mark.
  • uv run pytest tests/roadmap/stage/test_roadmap_stage_orphan.py -k foreign_commit - a branch with a commit of a person is kept and reported, not deleted.
  • uv run pytest tests/roadmap/stage/test_roadmap_stage_orphan.py -k other_branch_kept - in one round, the orphan of the job is removed. A pushed branch of an agent for another Ready item, with no mark, is not touched.
  • uv run pytest tests/roadmap/stage/test_roadmap_stage_orphan.py -k young_mark - a mark younger than the age limit is left alone.
  • uv run pytest tests/roadmap/stage/test_roadmap_stage_orphan.py -k request_count - a staged item adds 2 requests for the mark. A round with an orphan to remove adds 1 read of the open pull requests. A round with a kept branch adds none.

Every command above fails on the unchanged code. The pull request cites the fail-to-pass run (devops ci fail-to-pass --base <ref>).

Feasibility

I read these files at origin/main:

  • stage.py: _stage_one, _open_pull, _label_pull, _label_needs_fix, _reconcile, _rejections, _remove_staged, _foreign_commits, _Judge._held.
  • git/worktree_add.py: issue_holder.
  • stores/store.py: OpenPull (it has no author field) and StageRecordKey.

I checked that _stage_one already writes BRANCH only after the push, so a change of that order gives nothing. I checked that the marks write is the single write that sets PR, BRANCH and SHA.

I did not run the code. I did not check GitHub behavior for the deleted head branch of an open pull request. The pass closes the draft itself with _remove_staged, so it does not depend on that behavior.

The first idea, a search by issue number and branch pattern, is dropped. The reason is in the Problem section.

Existing tool considered

_remove_staged, Removal, KeptBranch, _foreign_commits and the JSON mark pattern of stage.rejected (all in the repository). No library is needed.

Cost

Build: about 50k tokens and 2.5 h (one key, one write, one pass, 8 tests). Keep: about 2k tokens per quarter. Fix: about 10k tokens and 0.5 h.

Related

Part of #3027. #3387 (items whose draft plan fails the check; a different fault), #3648 (display of stopped items; this change adds no explain line). Order: after the issues that change stage.py before it.

Activity

  1. added
    type/bugDefect, regression, or incorrect runtime behavior
    scope/roadmapThe roadmap jobs: intake, refine, stage, implement and the Service that runs them
    status/newStatus New: the item is not ready yet
    on Nov 7, 2026
  2. dan-petty commented on Nov 7, 2026

    @dan-petty
    OwnerAuthor

    Intake placed this item to the backlog: intake puts every new item in the backlog. A person plans the milestones.

    • Type: type/bug. The fix prefix of the title gives the type.
    • Scope: scope/roadmap. The (roadmap) scope of the title gives the scope.
    • Priority: P2-Medium. It is a rare failure path that needs manual cleanup, but it blocks an item from all later rounds until a person intervenes.
    • Value: Medium. It removes a manual recovery step for a real but infrequent operational failure.
    • Effort: Medium. It adds a new mark key, modifies the publish order, and adds a multi-branch _orphans pass with edge cases for age, foreign commits, and gone branches.
    • Related: feat(roadmap): Stage items whose draft plan fails the check #3387, which asks for a different change. Both deal with the stage job and pushed branches, but #3387 re-stages items whose plans fail checks while the candidate cleans up orphans left by a failed marks write.
  3. added
    priority/p2-mediumStandard priority feature work and architectural improvements
    on Nov 7, 2026
  4. added
    owner/agentOwner Agent: an agentic coder works on the item
    on Nov 8, 2026
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

    effort/mediumEffort Mediumowner/agentOwner Agent: an agentic coder works on the itempriority/p2-mediumStandard priority feature work and architectural improvementsscope/roadmapThe roadmap jobs: intake, refine, stage, implement and the Service that runs themsource/agentFiled by an agentstatus/newStatus New: the item is not ready yettype/bugDefect, regression, or incorrect runtime behaviorvalue/mediumValue Medium

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions