Skip to content

Fix #1011: Finish parked builds when GitHub PR is merged - #1070

Merged
czpython merged 1 commit into
mainfrom
issues/1011
Nov 3, 2026
Merged

czpython merged 1 commit into
mainfrom
issues/1011

Conversation

@dserbano

@dserbano dserbano commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

When GitHub merges a pull request while its build waits for implementation review, answer the review gate with merged so the workflow returns successfully and the build finishes. Also answer the gate when a merge is already recorded as the build parks, and keep resolved tickets from moving back to In Progress on resume.

Closing a pull request without merging still cancels the build with pr closed without merge. Activity shows “Build finished” and “Merged on GitHub” for the successful path.

Closes #1011

@dserbano
dserbano requested a review from czpython as a code owner November 2, 2026 19:37
@dserbano
dserbano force-pushed the issues/1011 branch 2 times, most recently from 3d8c153 to 154610f Compare November 2, 2026 21:35

@czpython czpython left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the fix works. Answering ReviewWork with merged lets the build return, so DBOS records SUCCESS. I checked the parts that worried me:

  • resolve and the park-time subscriber can both answer the same round. Run.resume keys each reply on the parked round, so the second send collapses.
  • DBOS accepts send from inside a step, so the answer from run.parked:propagate reaches the gate.
  • An operator cannot post merged from the UI. ReviewWork asks on GitHub and offers no in-app controls.
  • The result.action == "merged" check in build_start_marks_ticket_in_progress is needed. The DBOS send commits before the webhook transaction does, so the subscriber can still read an empty resolution.

Before merge, please cut the PR down. The inline comments have the details:

  1. Remove _is_merged.
  2. Revert .gitignore.
  3. Remove the new test in test_durable_sdk.py.
  4. Patch ReviewWork.answer, not DBOS.send_async.
  5. Rewrite the PR description. It should say what the change does and end with Closes #1011. The test report, the local paths, and the MCP recovery tooling are not part of this diff.

One limit, not blocking: the park-time check makes a stranded build rare, but it does not exclude it. If the workflow commits its park after resolve reads the status but before the webhook commits, both sides miss it. That window is milliseconds.

"url": f"https://github.com/{(await self.subject).repo}/pull/{self.pr_number}",
}
)
if decision.action == "merged" or await self._is_merged():

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please drop _is_merged and keep if decision.action == "merged": return True. When a merge lands while the build runs, the PARKED subscriber already answers merged at the next park. The step only covers the moment between the reply and this line, and it adds a step to every review reply. Dropping it also removes the __wrapped__ patches in test_build_plan_phase.py and test_merged_work_exits_review_without_more_work.

Comment thread .gitignore Outdated
/dist/
# ADRs are private, kept out of the repo.
/docs/adr/
python_combine.py

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are local files. Please revert this hunk. It also drops the trailing newline.

Comment thread backend/tests/test_durable_sdk.py Outdated


@pytest.mark.parametrize("merge_timing", ["parked", "before_park", "after_review"])
async def test_merged_build_finishes_successfully(runtime, monkeypatch, merge_timing):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this test. test_durable_sdk.py tests the platform, and this test makes it import software_factory and the dashboard. It mocks eight Build internals, and most of what it checks is DBOS behavior. The tests in software_factory/ already cover the policy. If you want one real-DBOS check, keep only the parked case, put it in backend/tests/software_factory/, and drop the dashboard asserts.



@pytest.fixture(autouse=True)
def merged_reply(monkeypatch):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Patch ReviewWork.answer instead of DBOS.send_async, and only in the two tests that merge a parked run. test_lane_reactions.py already does it that way. Then assert on the answer call, not on the DBOS send arguments.

merged,
):
send = AsyncMock()
monkeypatch.setattr("dbos.DBOS.send_async", send)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here: patch ReviewWork.answer, not DBOS.

class ReviewWork(Gate):
"""The verdict on the built work: approve/request_changes (PR webhook) or
revise_contract (UI)."""
revise_contract (UI), or merged (GitHub closed the PR with a merge)."""

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: "approve/request_changes (PR review webhook), revise_contract (UI), or merged (pr.closed webhook)."

@dserbano

dserbano commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

Implemented the requested review cleanup and pushed:

  • Removed _is_merged; the review gate now returns successfully only for decision.action == "merged". Removed the associated __wrapped__ test patches.
  • Reverted the .gitignore changes.
  • Removed the software-factory integration test and its imports from test_durable_sdk.py.
  • Replaced DBOS mocks with ReviewWork.answer mocks in the two parked-merge webhook tests and the Activity test, with assertions on the answer call. The redispatch and ticket-status tests now use running builds so they do not depend on a global gate mock.
  • Updated the ReviewWork docstring as suggested.

Another full local UI test isn’t necessary for this cleanup. Most changes removed unnecessary code or adjusted tests. The 69 focused tests passed.

@czpython czpython left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turn. One more pass, and it is all removal.

After the implementer opens the PR, a Build can park only on review_work. _plan_phase has the only other park, and the build never goes back to it after _implement_phase starts. So three branches in this PR cover states that cannot occur:

  1. elif status.is_parked: await Build.cancel(self) in resolve.
  2. not subject.resolution in the RUNNING subscriber.
  3. elif not subject.resolution in the PARKED subscriber.

Please remove them and the tests that pin them. The inline comments have the details. After this, the resolve hunk is one line from main: the cancel becomes the answer.

status = await self.get_status(workflow=Build)
if status.is_parked and status.gate == ReviewWork.name:
await ReviewWork.answer(self, action="merged")
elif status.is_parked:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A Build with a PR can only be parked on review_work, so this branch never runs. Please remove it:

if (await self.get_status(workflow=Build)).is_parked:
    await ReviewWork.answer(self, action="merged")

If a park on another gate ever appears, ReviewWork.answer raises, and that is what we want.

*, subject: WorkItem, result: dict | None = None, **_: object
) -> None:
# The gate reply can arrive before the merge webhook commits its resolution.
if not subject.resolution and (not result or result.get("action") != "merged"):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The result check is the real fix: the DBOS send commits before the webhook commits resolution. The not subject.resolution half never matters. A closed or cancelled build is cancelled and never resumes. A merged build resumes only through the merged reply. Every Build reply has action, so read it directly:

# A merged reply ends the build, and the merge already moved the ticket to Done.
if not result or result["action"] != "merged":
    await subject.set_ticket_status(TicketStatus.IN_PROGRESS)

# A merge can arrive before the gate parks, when there is no ask to answer yet.
if subject.resolution == Resolution.MERGED:
await ReviewWork.answer(subject, action="merged")
elif not subject.resolution:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A review_work park never happens with a closed or cancelled resolution, so this can be a plain else.

assert reparked == [True]


async def test_merged_work_exits_review_without_more_work(monkeypatch):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this test. It only shows that if decision.action == "merged": return True returns True.



@pytest.mark.parametrize("resolution", ["merged", "closed", "cancelled"])
async def test_resolved_build_resume_keeps_ticket_status(druks_db, monkeypatch, resolution):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this test. It pins the not subject.resolution guard, which goes away. Keep the two tests below it.

assert not (await _fresh_run(run_id)).is_active
answer.assert_awaited_once_with(item, action="merged")
run = await _fresh_run(run_id)
assert run.state == "parked"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With answer mocked, these three checks only show that cancel did not run. The answer.assert_awaited_once_with(...) above is enough. Please remove them and the _fresh_run call.

assert await _milestone_count(work_item_id, "merged") == 0
assert not (await _fresh_run(run_id)).is_active
run = await _fresh_run(run_id)
assert run.state == "cancelled"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR does not change the closed-unmerged path. Please revert this hunk.

assert [event.type for event in events] == [outcome]
assert events[0].payload == {"repo": "acme/widget", "pr_number": 42}
assert item.resolution == outcome
if merged and state == "parked":

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep the answer patch, but please remove these checks. This test is about the activity record, and the webhook test already pins the answer call.

Comment thread backend/tests/test_run_cancel.py Outdated
)

await run.cancel(failure="pr merged while parked")
await run.cancel(failure="operator cancelled")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the cancel branch gone, this PR has no reason to change this string. Please revert this file.

@czpython
czpython merged commit a429bd0 into main Nov 3, 2026
6 checks passed
@czpython
czpython deleted the issues/1011 branch November 3, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A merge while parked records the build as a failure

2 participants