US-45.5: the merge executor squashes a thread's allowed checkpoint as a GitHub App - #911
Merged
Merged
Conversation
… a GitHub App A recorded thread-mode allow now enqueues ThreadMergeWorker (unique per story, executing and retryable included), which runs MergeExecutor. It re-reads the allow, the checkpoint the gate judges and the stage row, then squashes the recorded checkpoint tree onto the base head as loopctl's GitHub App: tree checked against the forge (tree_mismatch), base freshness against the allow's base_sha, empty_change refused, merge_commit_sha recorded by compare-and-set before the ref moves with force false. A retry whose recorded commit the base already contains is already_merged. A stale base or a refused fast-forward merges the base into the thread branch as the App; Threads.record_base_update/4 (control-plane, no route) records the base_update checkpoint and takes the new ci to ci base_updated edge in one transaction, keeping review and custody and clearing the allow. A conflict goes back over base_moved, or escalates claim_not_live. claim_checkpoints judges a base_update that is, or reaches, the last allowed checkpoint. New env vars GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY; unset, every allowed thread story escalates app_unconfigured and nothing merges.
…ate, CAS the base update 1. record_merge_commit is fenced under the story and stage-row locks (at ci, same claim, allow names the checkpoint) and fails closed; a merge whose stage write fails, or a recorded squash found on the base outside ci, escalates naming the sha (new merged_outside_ci edge from queued and the in-flight stages). 2. The last attempt escalates every unresolved outcome, crashes included, in one place (retries_exhausted). 3. Unresolvable states at ci escalate; only inapplicable ones skip. 4. A thread-mode already_merged verdict enqueues the executor, which adopts the recorded squash or the checkpoint commit itself. 5. Consecutive base updates of one change are bounded at 3 (base_churn). 6. The installation token asks for workflows: write as well; docs corrected. 7. The worker's uniqueness no longer includes executing jobs. 8. The base update merges into a temporary loop/ branch created at the checkpoint and fast-forwards the thread branch to it with force false. 9. A 422 body never leaves the adapter; only its classification does. 10. The reason bound is StageMachine.max_reason_length/0.
Every write that moves stories.claim_epoch rebinds the stage row in the same transaction (follow_claim/4 on a claim, follow_release/5 on every release), and the story is held FOR SHARE while the fence reads, so the story half of the epoch check was redundant and could not be made to fail.
…sweep lost merges Root cause of findings 1, 2 and 7: round 1 removed executing from the merge worker's unique states, which made overlapping runs possible. Restored; an allow recorded during a run is caught instead by the run re-reading the allow at its end and snoozing itself when it changed (an enqueue from inside its own perform would be deduplicated into it). - A losing merge_commit compare-and-set converges (skip) instead of escalating. - A transient failure reading the recorded squash retries; only a 404 or a different commit mints a new one. - A transient failure writing merged retries and the retry adopts the squash; the last attempt or a non-transient failure escalates naming the sha. - The gate answers an unrecorded head whose first parent is the allowed checkpoint base_update_in_flight (a retry); the executor recognises its own unrecorded base update and adopts it only when GitHub re-merging the same base commit into the checkpoint yields the identical tree. - Exits are caught like crashes on the last attempt; ThreadMergeSweepWorker (every five minutes) re-drives thread stories left at ci with an allow. - A merge recorded on a row past merged raises no alarm. - ancestor? reads the compare with per_page=1; classify_failure moved above the failure comment; the placed base branch has one derivation.
The gate read any unrecorded head whose first parent was the allowed checkpoint as the executor's base update in flight, so a claimant's ordinary commit on top of the checkpoint waited as a transient fault instead of going back as a moved head. One shared predicate, CheckpointSource.base_update_of/3, now requires exactly two parents, the checkpoint first and a second on the base branch; the gate and the executor's recovery both use it.
…calate from the current stage 1. The last attempt asks whether the recorded squash is on the base before escalating retries_exhausted, and adopts it (ci -> merged) or escalates naming the sha when that write fails. 2. One escalation helper chooses the edge from the story's current stage: merge_gate at ci, merged_outside_ci from queued or in flight for a merge, nothing (logged) anywhere else. 3. The sweep selects only stories whose current claim's accepted implement row was placed in thread mode, from the same base query as the gate's route. 4. Recovering an unrecorded head escalates or retries a forge failure; only a head of another shape or tree is a moved head. 5. A claimant checkpoint's parent and a review's checkpoint are the latest of kind checkpoint; the runner contract says a session must fetch the thread branch after a base update. 6. A non-transient failure of the gate's extra commit read is a moved head. 7. Freshness is base head == base_sha alone. 8. One bounded-reason function in StageMachine for the gate, post-deploy verification and the executor. 9. Moduledoc arity.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
US-45.5: the merge executor squashes a thread's allowed checkpoint as a GitHub App
Story:
docs/user_stories/epic_45_change_threads/us_45.5.json(AC-45.5.1..9, TC-45.5.1..10). PRD §4 items 3-5.The review gate has not run yet; this PR is opened on the first green commit so CI and review run concurrently.
What it does
Loopctl.Workers.ThreadMergeWorker(Obandefault, unique per story overavailable/scheduled/executing/retryable,period: :infinity, 8 attempts) runsLoopctl.Delivery.MergeExecutor.MergePreconditionenqueues it on every recorded THREAD-mode allow (replays included, so re-asking the gate re-enqueues after a lost insert).ci, amerge_gate_allowed_sha, the allow's own event naming a checkpoint andbase_sha, the claim placed inthreadmode, andThreads.claim_checkpoints/2's judged head being exactly the allowed checkpoint (AC-45.5.1). Anything else is{:skipped, reason}with no forge call.tree_sha(tree_mismatch, escalated), freshness (AC-45.5.8: base head must equal the allow'sbase_shaand be contained by the checkpoint),empty_change, one commit with the checkpoint's tree and the base head as its only parent,merge_commit_sharecorded by compare-and-set (Threads.record_merge_commit/5) BEFOREupdate_ref(force: false).merge_commit_shais an ancestor of the base head isalready_mergedand moves the story tomerged. A retry on an unmoved base reuses the recorded commit instead of minting another.POST /repos/:repo/merges); a merge whose first parent is the checkpoint is recorded byThreads.record_base_update/4(control-plane, no HTTP route, bypasses the claimant fence) which takes the new{:ci, :ci, :base_updated}edge in the SAME transaction viaStages.follow_base_update/4: head replaced, allow and head-keyed fields cleared, review/custody untouched, counted inattempts.advance/4refuses the edge from every caller. A conflict goes back overbase_movedwhile the claim is live, and escalatesclaim_not_liveotherwise (the gate's own rule for a moved thread head, so nothing loops).claim_checkpoints/2judges abase_updatethat IS the last-allowed checkpoint or whose parent chain reaches it (Stages.last_allow_query/2); any other base update is invisible. The gate then needs green CI on that exact sha (existing US-45.6 read).Loopctl.Delivery.MergeMessagebuildsStory <number>: <title>reduced to one printable line (\p{C},\p{Z}, whitespace collapsed, 72-codepoint bound), the thread URL, and aLoopctl-Story: <id>trailer; no entry text.Loopctl.Delivery.MergeForgebehaviour, Req adapterGitHubAppMergeForge(App JWT RS256 via:public_key, installation lookup per repo, token scoped to that repo andcontents: write, 2s/5s timeouts,retry: false,redirect: false, failures classified byGitHubPullRequestSource.classify_failure/1). New env varsGITHUB_APP_ID/GITHUB_APP_PRIVATE_KEYdocumented indeploy/FLY_SECRETS.md; unset, the executor escalatesapp_unconfiguredand makes no request. Mox mockLoopctl.MockMergeForge, config-based DI.MergeExecutor's moduledoc: transient faults retry and escalateforge_unavailableon the last attempt; two runs cannot overlap (unique incl. executing) and if they could, the ref CAS and themerge_commit_shaCAS keep it to one squash; a lost base-merge ack leaves an unrecorded branch head that the retry sends back overbase_movedrather than adopting.Also: the egress chokepoint allowlist and
docs/egress-guard.mdgain the adapter (it carries the story number and title to the tenant's own repository), CHANGELOG,docs/agent-delivery-loop.md, and theMergePreconditionmoduledoc.No new endpoint, so no MCP tool.
Size
One PR, not split: the brief's PR-1 set includes AC-45.5.8 (freshness), which cannot be met without the base-update path, and a PR-1 that escalated instead was ruled out. Production code is about 1.1k lines of which a large share is doc comments; the rest is tests.
Tests
test/loopctl/delivery/merge_precondition_integration_test.exs— newmerge executor (US-45.5)block (committed-tenant, like the gate's own thread tests): TC-45.5.1, .2, .4, .5, .6, .8, .9, conflicts, first-parent mismatch, empty change, transient/final attempt throughThreadMergeWorker.perform/1, and the enqueue wiring throughenforce/3with inline Oban. The existing thread block stubs the App as transiently unreachable, since every thread allow now runs the executor inline.test/loopctl/threads_test.exs—record_base_update/4(TC-45.5.8 at the context level, idempotency, refusals), TC-45.5.10,record_merge_commit/5CAS, and tenant isolation for both writes.test/loopctl/delivery/stage_machine_test.exsTC-45.5.7;stages_test.exsedge refused toadvance/4;merge_message_test.exsTC-45.5.3;github_app_merge_forge_test.exs(JWT verified with the public key, non-ff/ruleset 422, merge 201/204/409, compare 404);thread_merge_worker_test.exs(unique config).Mutation table
Every assertion added, proved with
~/workspace/claude-config/bin/mutate.sh(exit 0 = the check failed under the mutation). Run sequentially. Checks are the whole test file named.Survivors and what was done: M4, M24, M29, M30 exposed weak tests, which were strengthened (the b rows). M18 and M20 showed explicit guards in
follow_base_update/4that the compare-and-set already enforces, so they were deleted rather than kept as dead checks; M19 was refused (its old text is not unique instages.ex) and its guard was deleted for the same reason.Check files: integration =
test/loopctl/delivery/merge_precondition_integration_test.exs, threads =test/loopctl/threads_test.exs, stages / stage_machine / merge_message / adapter (github_app_merge_forge_test.exs) / worker (test/loopctl/workers/thread_merge_worker_test.exs).Gate
The pre-commit gate ran green on the commit (format, compile, credo --strict, dialyzer, full suite).
Review round 1 (10 findings, fixed in 3401bea and d6c74e8)
ci:Threads.record_merge_commit/6now runsStages.mergeable_in/4under the story and stage-row locks (stageci, same claim epoch, allowed sha is the checkpoint) and fails closed. After a successful ref update any failure to recordmergedescalates naming the sha; a later run finding the squash on the base with the story out ofciescalates over a new control-only edgemerged_outside_ci. Residual window: the lock cannot span the HTTP ref update; the orphan escalation covers it.finalize/4; every non-success on the last attempt escalatesretries_exhausted(renamed fromforge_unavailable), crashes included.ciwith an allow escalate with a named reason instead of skipping.already_mergedverdict enqueues the executor, which adopts the merge (the recorded squash, or the checkpoint commit itself).base_churn.contents: writeandworkflows: write; docs say the App needs both.loop/loopctl-base-update-*branch cut at the checkpoint, fast-forward the thread branch to it (force: false), record the base update only after the ref moved, delete the temp branch best effort. New forge callbackscreate_ref/3,delete_ref/2.StageMachine.max_reason_length().Session check: the story-epoch half of the fence could not fail (mutation survived); the stage row moves with the story in the same transaction on every claim and release, so the redundant half was removed (d6c74e8).
Mutations: every cited one re-run at the new text, plus N1-N23 for the fixes; all exit 0.
Review round 2 (10 findings, fixed in 2555cd3 and 3fb1bce)
Three findings traced to round 1's fix 7 (overlapping runs); it was reversed at the cause rather than guarded.
:executingback in the unique states). An allow recorded during a run is picked up at the end of every run, which snoozes the same job; the residual window after the final re-read, and any job killed on its last attempt, is re-driven by the newThreadMergeSweepWorker(cron, every 5 minutes, thread stories atciwith an allow; pull-request stories are never swept).mergedafter the squash landed retries and the retry adopts the merge.CheckpointSource.base_update_of/3, one predicate shared with the executor) as the transientbase_update_in_flight; a claimant's single-parent commit is still a moved head. The executor's retry adopts its own unrecorded base update only when GitHub re-merging the same commit into the checkpoint produces an identical tree.mergedraise no alarm; the ancestry compare usesper_page=1;DispatchPayload.placed_base_branch/2is the one base-branch derivation; a misplaced comment moved back.Mutations: every cited one re-run at the new text, plus R1-R20 and the in-flight shape pair; all exit 0.
Review round 3 (the ceiling; 9 findings, fixed in place in 2bade08, no round 4)
None of the findings changed the design, so they were fixed in place with mutation proof, following #902 and #910.
retries_exhausted.merge_gateatci,merged_outside_cifrom queued or in-flight stages; finalize and the orphan path share it.DispatchLedger.route_rows_query/0, shared with the gate's route query), so pull-request stories never fill its batch.checkpoint; a base update is only ever the gate's judged head. The runner contract prose says a session must fetch the thread branch after abase_updatedtransition (runner handoff filed).base_sha; the unreachable ancestry call was removed.StageMachine.bounded_reason/1is the one truncation for stored reasons (gate, post-deploy verification, executor).Mutations: 90 run sequentially, all exit 0 (cited re-runs plus T1-T13). Not provable: the executor's own wiring to
bounded_reason/1, since none of its reasons can reach the bound.