Closing the first loop
froot closes its first loop
Make the loop actually close β the SPEC's own precondition for replicating it.
froot is a Temporal worker that proposes dependency patches as pull requests and lets the repo's own CI verify each one. Its charter says: close one loop end-to-end before adding the next. This PR closes the gaps that kept the first loop from fully closing β a red PR left rotting, a superseded PR never cleaned up, a fault that retried forever, a model outage that could stall the spine. Expand any block to read the surrounding code.
What "hardening" actually meant
froot's loop is built on a pure, effect-driven state machine: each transition emits one effect as data, a durable activity runs it, and the resulting event feeds the next transition. The four gaps this PR closes map cleanly onto that shape rather than fighting it.
- Close-on-red. A failed CI now closes the PR (and deletes its branch) before recording β modeled as a new effect on the state machine, not buried in an activity. - Reconcile. A pure policy closes PRs a newer patch superseded or the base already satisfied, re-derived from the repo each scan tick. - Failure discipline. Every activity runs under a bounded retry so a persistent fault surfaces instead of looping forever; the model degrades to "unknown" so it can never stall the spine. - Proof. An end-to-end test drives the real activities through both terminal shapes, and a coverage floor plus a transitive-determinism gate lock the bar in.
The tour follows the change the way the loop runs it: the new effect as data, the state machine that emits it, the forge that performs it, the reconcile policy beside it, the activities that bind them to the world, the retry discipline around them, and the proof.
The close, modeled as data
src/froot/domain/effects.pysrc/froot/domain/events.pysrc/froot/domain/state.pyfroot's defining choice is that every externally-visible action is an effect β a frozen value the pure state machine emits and a durable activity interprets. Closing a PR (and deleting its branch) is exactly that kind of action, so it becomes a first-class effect rather than a side effect hidden inside the record step. That is one new member in each of three discriminated unions.
The new ClosePullRequest effect carries the PR and the failing check names, and joins the Effect union.
src/froot/domain/effects.py Β· 75 lines
β― 45 lines hidden (lines 1β45)
The matching event is the spine's report that the close happened. It carries no data β the state it returns to already holds the outcome to record.
src/froot/domain/events.py Β· 63 lines
β― 42 lines hidden (lines 1β42)
And one new state sits between "CI came back red" and "outcome recorded": the PR is being closed. It carries the already-built LoopOutcome, so once the close lands the loop records exactly what it would have recorded anyway.
src/froot/domain/state.py Β· 71 lines
β― 46 lines hidden (lines 1β46)
One branch, the invariant intact
src/froot/policy/state_machine.pyThe driver enforces a hard rule: every transition emits exactly one effect. Closing then recording is two actions, so it cannot be one transition β it is a sequence through the new Closing state. The branch is the only new logic.
Red CI with close-on-red on routes through Closing; every other terminal reading records straight away. Closing then records.
src/froot/policy/state_machine.py Β· 206 lines
β― 153 lines hidden (lines 1β153)
β― 12 lines hidden (lines 195β206)
The toggle is configuration, and the state machine is pure β it must never read the environment. So close_on_red arrives as an explicit parameter to advance, defaulted to the SPEC's behavior, and only the one red branch reads it. The workflow passes it in from the bump's params (next section).
src/froot/policy/state_machine.py Β· 206 lines
β― 96 lines hidden (lines 1β96)
β― 83 lines hidden (lines 124β206)
The close itself: idempotent by construction
src/froot/ports/protocols.pysrc/froot/adapters/github.pyThe Forge port gains one method. The signature states the contract: close the PR and, by default, delete its head branch.
src/froot/ports/protocols.py Β· 160 lines
β― 126 lines hidden (lines 1β126)
β― 12 lines hidden (lines 149β160)
The implementation is two GitHub calls, both chosen so a retried close never fails on a half-done prior attempt β which matters because every activity now runs under a bounded retry (Β§6).
src/froot/adapters/github.py Β· 382 lines
β― 315 lines hidden (lines 1β315)
β― 37 lines hidden (lines 346β382)
Closing the stale PRs, fail-safe
src/froot/policy/reconcile.pysrc/froot/policy/naming.pyBranch names are per version, so a package that gets a newer patch (1.2.3 then 1.2.4) ends up with a second open PR the propose path never closes. The fix is a pure policy: given the repo's open PRs and the upgrade facts the scan already gathered, return the froot PRs to close β superseded by a newer target, or already satisfied by the base.
superseded fires when a newer candidate exists; satisfied when the base already sits at or past the PR's target.
src/froot/policy/reconcile.py Β· 137 lines
β― 51 lines hidden (lines 1β51)
β― 46 lines hidden (lines 92β137)
Everything hinges on matching an open PR back to its package and version. The branch is froot/dependency-patch/<slug>-<version>, and both the slug and the version contain hyphens β so a blind split is ambiguous. The matcher instead derives each package's prefix and parses the remainder as a version: a branch belongs to a package only when the tail is a real version.
src/froot/policy/reconcile.py Β· 137 lines
β― 92 lines hidden (lines 1β92)
β― 22 lines hidden (lines 116β137)
src/froot/policy/naming.py Β· 92 lines
β― 37 lines hidden (lines 1β37)
β― 43 lines hidden (lines 50β92)
Where the effects meet GitHub
src/froot/workflow/activities.pysrc/froot/workflow/scan_workflow.pyThe activities are the only impure layer. Two are new. The close activity renders the "why", posts it through the idempotent comment path, then closes β mirroring the order the adapter is built for.
src/froot/workflow/activities.py Β· 378 lines
β― 199 lines hidden (lines 1β199)
β― 145 lines hidden (lines 234β378)
The reconcile activity is self-contained: list the open PRs, re-derive the live upgrade facts (a fresh checkout β derive, never store), ask the pure policy which to close, and close each. It no-ops when reconcile is switched off.
src/froot/workflow/activities.py Β· 378 lines
β― 234 lines hidden (lines 1β234)
β― 96 lines hidden (lines 283β378)
The scan loop calls it once per tick, after dispatching the bumps β so the stale-PR cleanup is re-derived from the repo on the same cadence as the scan.
src/froot/workflow/scan_workflow.py Β· 74 lines
β― 45 lines hidden (lines 1β45)
β― 12 lines hidden (lines 63β74)
src/froot/workflow/activities.py Β· 378 lines
β― 179 lines hidden (lines 1β179)
β― 182 lines hidden (lines 197β378)
Surface persistent faults; never stall on the model
src/froot/workflow/constants.pysrc/froot/workflow/activities.pyBefore this PR, activities ran under Temporal's default retry β unbounded. A genuinely stuck fault (a deleted commit, a sustained outage) would loop forever, invisible. A bounded policy makes it eventually fail, where the dashboard's Failures panel can show it.
src/froot/workflow/constants.py Β· 35 lines
β― 25 lines hidden (lines 1β25)
TOOL_RETRY is attached to every execute_activity call across the four workflows. Permanent faults are unaffected β the adapter already raises a missing or invalid token as non-retryable, so it still stops on the first attempt.
The new ClosePullRequest arm, under the same bounded retry as every other activity call.
src/froot/workflow/bump_workflow.py Β· 158 lines
β― 119 lines hidden (lines 1β119)
β― 20 lines hidden (lines 139β158)
The model goes the other way. It is froot's one thin judgment, and it never gates a PR β CI is the oracle. So a model that is down must not fail (and then retry) the activity and stall the bump. It degrades to an "unknown" verdict instead, logged for visibility.
src/froot/workflow/activities.py Β· 378 lines
β― 76 lines hidden (lines 1β76)
β― 274 lines hidden (lines 105β378)
The real loop, and a gate that holds the line
tests/test_e2e_workflow.pyscripts/check_determinism_transitive.py.github/workflows/determinism.ymlThe existing workflow tests mock the activities to exercise the spine. This PR adds an end-to-end test that does the opposite: it runs the real activities and only fakes the adapters underneath them, so the actual effect interpreters drive the loop. Both terminal shapes are covered, plus the durable CI wait.
One shared fake forge wired under the real activities; the red case asserts the PR is closed, its branch deleted, and recorded.
tests/test_e2e_workflow.py Β· 154 lines
β― 54 lines hidden (lines 1β54)
β― 54 lines hidden (lines 78β131)
β― 6 lines hidden (lines 149β154)
The second proof is a gate. froot already ships a transitive determinism analyzer β the one its reviewer loop points at other repos. This PR points it at froot's own source, so a hazard hidden behind a first-party helper (which the lexical kernel cannot see) fails froot's own CI too.
scripts/check_determinism_transitive.py Β· 71 lines
β― 26 lines hidden (lines 1β26)
.github/workflows/determinism.yml Β· 45 lines
β― 30 lines hidden (lines 1β30)
Does the loop close now?
The claim this PR makes is that the first loop now closes end-to-end: no rotting red PR, no stale superseded PR, no invisible infinite retry, no model outage that stalls the spine β and all of it expressed on the existing pure spine rather than bolted onto its side. Below, each claim is scored against the change.
| Claim | Upheld by | Where |
|---|---|---|
| A red PR is closed, not left to rot | terminal CIFailed routes through Closing β ClosePullRequest, then records | domain/{effects,events,state}.py, policy/state_machine.py |
| The close is a visible effect, not a hidden mutation | modeled as data on the pure machine; assert_never proves exhaustiveness | policy/state_machine.py |
| Closing never drops the outcome | both paths build and record the same LoopOutcome | policy/state_machine.py _from_closing |
| Stale superseded/satisfied PRs are cleaned up | pure reconciliations, re-derived each scan tick | policy/reconcile.py, workflow/scan_workflow.py |
| Reconcile never closes a live PR by mistake | version-parse matcher; closes only on a positive match | policy/reconcile.py, policy/naming.py |
| The close survives a retry | idempotent PATCH + 404/422-tolerant branch delete + marker comment | adapters/github.py |
| Persistent faults surface; the model can't stall the spine | TOOL_RETRY on every activity; judge degrades to UnknownVerdict | workflow/constants.py, workflow/activities.py |
| The whole loop is proven to close | real-activity e2e (green / red-close / pendingβpass) + transitive determinism gate | tests/test_e2e_workflow.py, scripts/check_determinism_transitive.py |
Three findings from the adversarial review are folded in: the UnknownVerdict docstring now names the model-failure path, and the end-to-end suite gained a pendingβpass case so the durable CI wait is exercised with real activities. A third β a test that would specifically pin the retry bound β was considered and declined: Temporal's own default also retries, so such a test could not distinguish this change from the framework's behavior. From the code: the loop now closes on every terminal path, the cleanup is fail-safe, the failure modes are deliberate, and the durable spine is exactly the shape it was.