Overview

Phase 4 is froot's thesis: stop proposing and start acting — but only where a class of work has earned it, on a record that triangulates so no single gamed metric can buy autonomy. The loop now auto-merges a clean (judge) + green (CI) bump when its (repo, loop) class has earned the grant on an allowlisted repo and an independent deep review approves.

The allowlist is empty by default, so every un-opted repo behaves exactly as before — the same verdict is the advisory shadow gate a steward watches. Authority rests on four bearings that fail differently and can't all lie at once (§3.7–3.8): the approval rate, the post-merge defect rate, an adversarial gate self-test, and a deep review at the gate. This guide walks the pure-core extension first, then each bearing, then the honesty sweep.

The pure core, extended

The acting gate is added the way Phase 1's close-on-red was: as data in the pure state machine, not I/O sprinkled through the spine. Three new states, three new effects, two new events. A bump that auto-merges flows AwaitingCi → GateReviewing → Merging → Recorded, mirroring the AwaitingCi → Closing → Recorded red path.

src/froot/domain/state.py · 105 lines
src/froot/domain/state.py105 lines · Python
⋯ 59 lines hidden (lines 1–59)
1"""The bump lifecycle states — one frozen model per stage of a single bump.
2 
3A bump moves Discovered -> Judged -> AwaitingCi -> Recorded, and each state
4carries exactly the data valid at that stage and no more: ``Discovered`` has
5only a candidate; ``AwaitingCi`` necessarily has a verdict *and* an open PR.
6A state that holds a PR but no verdict, or an outcome before CI resolved, is
7unrepresentable. ``Recorded`` is terminal.
8"""
9 
10from __future__ import annotations
11 
12from typing import Annotated, Literal
13 
14from pydantic import Field
15 
16from froot.domain.base import Frozen
17from froot.domain.candidate import Candidate
18from froot.domain.changelog import ChangelogVerdict
19from froot.domain.outcome import LoopOutcome
20from froot.domain.pull_request import PullRequestRef
21 
22 
23class Discovered(Frozen):
24 """A fresh candidate, not yet judged. The loop's entry state."""
25 
26 kind: Literal["discovered"] = "discovered"
27 candidate: Candidate
28 
29 
30class Judged(Frozen):
31 """The changelog has been assessed; ready to open the PR."""
32 
33 kind: Literal["judged"] = "judged"
34 candidate: Candidate
35 verdict: ChangelogVerdict
36 
37 
38class AwaitingCi(Frozen):
39 """The PR is open; the loop is durably waiting on the repo's CI."""
40 
41 kind: Literal["awaiting_ci"] = "awaiting_ci"
42 candidate: Candidate
43 verdict: ChangelogVerdict
44 pr: PullRequestRef
45 
46 
47class Closing(Frozen):
48 """CI failed and close-on-red is on: the PR is being closed before record.
49 
50 A transient stage between a red CI reading and the terminal record: the
51 spine closes the PR (and deletes its branch), then the loop records the
52 same outcome it would have anyway. Carries the already-built outcome so the
53 record step needs nothing more.
54 """
55 
56 kind: Literal["closing"] = "closing"
57 outcome: LoopOutcome
58 
59 
60class GateReviewing(Frozen):
61 """A green, clean, earned bump is being deep-reviewed before it merges.
62 
63 The transient stage between the green CI reading and the merge, where the
64 spine runs the independent adversarial gate review (the fourth trust leg,
65 §3.7). On approval the loop merges; on a hold it records and leaves the PR
66 for the human. Carries the already-built outcome so the next step needs
67 nothing more.
68 """
69 
70 kind: Literal["gate_reviewing"] = "gate_reviewing"
71 outcome: LoopOutcome
72 
73 
74class Merging(Frozen):
75 """The PR is being auto-merged before record (green, clean, earned class).
76 
77 The mirror of :class:`Closing` on the success side — a transient stage
78 between a passed gate review and the terminal record, where the spine merges
79 the PR (§3.4 Stage 5) and then records the same outcome. Reached only when a
80 steward has allowlisted the repo; otherwise the loop records and leaves the
81 PR for the human.
82 """
83 
84 kind: Literal["merging"] = "merging"
85 outcome: LoopOutcome
86 
⋯ 19 lines hidden (lines 87–105)
87 
88class Recorded(Frozen):
89 """Terminal: CI resolved and the outcome was recorded."""
90 
91 kind: Literal["recorded"] = "recorded"
92 outcome: LoopOutcome
93 
94 
95# The state of a single bump's loop.
96BumpState = Annotated[
97 Discovered
98 | Judged
99 | AwaitingCi
100 | Closing
101 | GateReviewing
102 | Merging
103 | Recorded,
104 Field(discriminator="kind"),
src/froot/domain/effects.py · 109 lines
src/froot/domain/effects.py109 lines · Python
⋯ 59 lines hidden (lines 1–59)
1"""Effects: data describing what the spine should do next.
2 
3The loop's state machine is pure and performs no I/O. On each transition it
4emits an *effect* — judge the changelog, open the PR, wait on CI, record the
5outcome. The Temporal spine interprets each effect into an activity (or, for
6:class:`AwaitCi`, a durable poll-and-sleep loop) and feeds the resulting event
7back in. Effects are values, so a transition is fully testable without touching
8npm, GitHub, or a model.
9"""
10 
11from __future__ import annotations
12 
13from typing import Annotated, Literal
14 
15from pydantic import Field
16 
17from froot.domain.base import Frozen
18from froot.domain.candidate import Candidate
19from froot.domain.changelog import ChangelogVerdict
20from froot.domain.outcome import LoopOutcome
21from froot.domain.pull_request import PullRequestRef
22 
23 
24class JudgeChangelog(Frozen):
25 """Fetch the candidate's changelog and get the model's typed verdict."""
26 
27 kind: Literal["judge_changelog"] = "judge_changelog"
28 candidate: Candidate
29 
30 
31class OpenPullRequest(Frozen):
32 """Regenerate manifest+lockfile and open (idempotently) the bump's PR."""
33 
34 kind: Literal["open_pull_request"] = "open_pull_request"
35 candidate: Candidate
36 verdict: ChangelogVerdict
37 
38 
39class AwaitCi(Frozen):
40 """Durably wait on the PR's CI until it resolves or the deadline passes."""
41 
42 kind: Literal["await_ci"] = "await_ci"
43 pr: PullRequestRef
44 
45 
46class ClosePullRequest(Frozen):
47 """Close a red bump's PR (comment why, close it, delete its branch).
48 
49 Emitted in place of going straight to the record when CI came back failed
50 and close-on-red is enabled: the loop leaves no rotting red PR behind. The
51 failing check names ride along so the spine can comment what failed; the
52 record still follows (this state's outcome), so the red outcome is logged.
53 """
54 
55 kind: Literal["close_pull_request"] = "close_pull_request"
56 pr: PullRequestRef
57 failing: tuple[str, ...] = ()
58 
59 
60class ReviewBump(Frozen):
61 """Independently deep-review a bump at the gate, before it auto-merges.
62 
63 The fourth trust leg (§3.7's *sampled deep review*, here run on every
64 auto-merge candidate). Emitted when a green, clean, earned bump is about to
65 merge: the spine asks a second, independent, adversarial model pass to read
66 the changelog with a "find the reason to hold" stance. Only its approval
67 lets the merge proceed; anything else holds the PR for the human. The
68 expensive leg, spent only at the high-stakes moment.
69 """
70 
71 kind: Literal["review_bump"] = "review_bump"
72 candidate: Candidate
73 pr: PullRequestRef
74 
75 
76class MergePullRequest(Frozen):
77 """Auto-merge a clean+green bump whose class has earned the grant.
78 
79 Emitted in place of going straight to the record when CI came back green,
80 the changelog read clean, the (repo, loop) class has earned auto-merge on an
81 allowlisted repo, *and* the independent gate review approved (the acting
82 gate — §3.4 Stage 5). The record still follows (this state's outcome), so
83 the merged outcome is logged either way. Nothing reaches here unless a
84 steward has opted the repo into the allowlist; the default is empty, so the
85 loop stays propose-only.
86 """
87 
88 kind: Literal["merge_pull_request"] = "merge_pull_request"
89 pr: PullRequestRef
90 
91 
92class RecordOutcome(Frozen):
⋯ 17 lines hidden (lines 93–109)
93 """Record the closed-loop outcome (label the PR, emit run telemetry)."""
94 
95 kind: Literal["record_outcome"] = "record_outcome"
96 outcome: LoopOutcome
97 
98 
99# What the spine should do after a transition.
100Effect = Annotated[
101 JudgeChangelog
102 | OpenPullRequest
103 | AwaitCi
104 | ClosePullRequest
105 | ReviewBump
106 | MergePullRequest
107 | RecordOutcome,
108 Field(discriminator="kind"),

ReviewBump carries the candidate (to re-read its changelog) and the PR; MergePullRequest carries only the PR. GateReviewed carries a ChangelogVerdict — clean approves, anything else holds.

src/froot/domain/events.py · 83 lines
src/froot/domain/events.py83 lines · Python
⋯ 48 lines hidden (lines 1–48)
1"""Lifecycle events: the decided inputs that drive the state machine.
2 
3Each event carries an already-decided result — the model judged the changelog,
4the PR was opened, CI resolved — so by the time an event reaches
5:func:`froot.policy.state_machine.advance`, the machine only decides *where the
6loop goes next*, never *what the judgment was*. All interpretation (the model
7call, the CI poll) happens in the spine's activities and arrives here as data.
8"""
9 
10from __future__ import annotations
11 
12from typing import Annotated, Literal
13 
14from pydantic import Field
15 
16from froot.domain.base import Frozen
17from froot.domain.changelog import ChangelogVerdict
18from froot.domain.ci import CIStatus
19from froot.domain.pull_request import PullRequestRef
20 
21 
22class ChangelogJudged(Frozen):
23 """The model returned its verdict on the candidate's changelog."""
24 
25 kind: Literal["changelog_judged"] = "changelog_judged"
26 verdict: ChangelogVerdict
27 
28 
29class PullRequestReady(Frozen):
30 """The PR for this bump is open (newly created or already existing)."""
31 
32 kind: Literal["pull_request_ready"] = "pull_request_ready"
33 pr: PullRequestRef
34 
35 
36class CiResolved(Frozen):
37 """CI reached a terminal status (never ``CIPending``; the spine waits)."""
38 
39 kind: Literal["ci_resolved"] = "ci_resolved"
40 status: CIStatus
41 
42 
43class PullRequestClosed(Frozen):
44 """The red bump's PR was closed (and its branch deleted)."""
45 
46 kind: Literal["pull_request_closed"] = "pull_request_closed"
47 
48 
49class GateReviewed(Frozen):
50 """The independent gate review returned its verdict on the bump.
51 
52 Carries a :data:`ChangelogVerdict` like the first judge pass: ``clean``
53 approves the merge; anything else (``risky``/``unknown``) holds the PR for
54 the human. Fail-closed — a review that could not run arrives non-clean.
55 """
56 
57 kind: Literal["gate_reviewed"] = "gate_reviewed"
58 verdict: ChangelogVerdict
59 
60 
61class PullRequestMerged(Frozen):
62 """The earned bump's PR was auto-merged by the acting gate."""
63 
64 kind: Literal["pull_request_merged"] = "pull_request_merged"
65 
66 
67class OutcomeRecorded(Frozen):
68 """The outcome was recorded; the loop has nothing left to do."""
69 
70 kind: Literal["outcome_recorded"] = "outcome_recorded"
71 
⋯ 12 lines hidden (lines 72–83)
72 
73# A decided input to the loop state machine.
74LoopEvent = Annotated[
75 ChangelogJudged
76 | PullRequestReady
77 | CiResolved
78 | PullRequestClosed
79 | GateReviewed
80 | PullRequestMerged
81 | OutcomeRecorded,
82 Field(discriminator="kind"),

The acting flip — where the machine decides to merge

In AwaitingCi, a terminal green CI plus a clean changelog plus the class-level grant now routes to the deep review rather than straight to record. The per-PR conditions (green + clean) are checked here in the pure machine; the class-level grant (auto_merge_eligible) is decided outside it and passed in, exactly like close_on_red — so the machine stays pure and replay-safe.

src/froot/policy/state_machine.py · 285 lines
src/froot/policy/state_machine.py285 lines · Python
⋯ 178 lines hidden (lines 1–178)
1"""The bump loop state machine: pure transitions, effects as data.
2 
3``start`` and ``advance`` are pure: given the current state and a decided event,
4they return a :class:`Transition` — the next state plus the effects the spine
5should run. No I/O, no clock, so a transition replays deterministically and is
6fully testable. Dispatch is per-state and ends in ``assert_never`` so the type
7checker proves every state is handled; an event a state does not expect yields a
8``REJECTED`` transition (no state change), never an exception.
9 
10The loop is linear: each advance emits exactly one effect, the spine runs it to
11obtain the next event, and the cycle repeats until ``Recorded`` (no effects). A
12:class:`~froot.domain.events.CiResolved` that is still pending is rejected — the
13spine must finish waiting before the machine will close the loop, so an outcome
14can never be recorded against an unresolved CI status.
15"""
16 
17from __future__ import annotations
18 
19from enum import StrEnum
20from typing import TYPE_CHECKING, assert_never
21 
22from froot.domain.base import Frozen
23from froot.domain.ci import CIFailed, CIPassed, is_terminal
24from froot.domain.effects import (
25 AwaitCi,
26 ClosePullRequest,
27 Effect,
28 JudgeChangelog,
29 MergePullRequest,
30 OpenPullRequest,
31 RecordOutcome,
32 ReviewBump,
34from froot.domain.events import (
35 ChangelogJudged,
36 CiResolved,
37 GateReviewed,
38 LoopEvent,
39 OutcomeRecorded,
40 PullRequestClosed,
41 PullRequestMerged,
42 PullRequestReady,
44from froot.domain.outcome import LoopOutcome
45from froot.domain.state import (
46 AwaitingCi,
47 BumpState,
48 Closing,
49 Discovered,
50 GateReviewing,
51 Judged,
52 Merging,
53 Recorded,
55 
56if TYPE_CHECKING:
57 from froot.domain.candidate import Candidate
58 
59 
60class TransitionKind(StrEnum):
61 """The disposition of an :func:`advance` call."""
62 
63 ADVANCED = "advanced"
64 IGNORED = "ignored"
65 REJECTED = "rejected"
66 
67 
68class Transition(Frozen):
69 """The result of a transition.
70 
71 Attributes:
72 kind: ``ADVANCED`` (moved and/or emitted effects), ``IGNORED`` (a legal
73 no-op, e.g. the terminal acknowledgement), or ``REJECTED`` (the
74 event is not valid in this state).
75 next: The resulting state (unchanged for ``IGNORED``/``REJECTED``).
76 effects: The effects the spine should run, in order. Empty terminates
77 the loop driver.
78 reason: A short explanation for an ``IGNORED``/``REJECTED`` transition.
79 """
80 
81 kind: TransitionKind
82 next: BumpState
83 effects: tuple[Effect, ...] = ()
84 reason: str | None = None
85 
86 
87def _advanced(nxt: BumpState, *effects: Effect) -> Transition:
88 return Transition(kind=TransitionKind.ADVANCED, next=nxt, effects=effects)
89 
90 
91def _rejected(state: BumpState, reason: str) -> Transition:
92 return Transition(kind=TransitionKind.REJECTED, next=state, reason=reason)
93 
94 
95def start(candidate: Candidate) -> Transition:
96 """The opening transition: enter ``Discovered`` and judge the changelog."""
97 return _advanced(
98 Discovered(candidate=candidate),
99 JudgeChangelog(candidate=candidate),
100 )
101 
102 
103def advance(
104 state: BumpState,
105 event: LoopEvent,
106 *,
107 close_on_red: bool = True,
108 auto_merge_eligible: bool = False,
109) -> Transition:
110 """Advance the loop one step (pure).
111 
112 Args:
113 state: The current bump state.
114 event: The decided event that just occurred.
115 close_on_red: Whether a terminal red CI should close the PR before
116 recording. Passed in rather than read from config so the machine
117 stays pure and replay-safe.
118 auto_merge_eligible: Whether this PR's (repo, loop) *class* has earned
119 the auto-merge grant on an allowlisted repo — the class-level half
120 of the gate, decided by the spine (it needs the class's history).
121 The per-PR half (clean changelog + green CI) the machine checks
122 itself. Defaults False, so the loop stays propose-only unless a
123 steward has granted the class autonomy.
124 
125 Returns:
126 The :class:`Transition` to apply.
127 """
128 match state:
129 case Discovered():
130 return _from_discovered(state, event)
131 case Judged():
132 return _from_judged(state, event)
133 case AwaitingCi():
134 return _from_awaiting_ci(
135 state,
136 event,
137 close_on_red=close_on_red,
138 auto_merge_eligible=auto_merge_eligible,
139 )
140 case Closing():
141 return _from_closing(state, event)
142 case GateReviewing():
143 return _from_gate_reviewing(state, event)
144 case Merging():
145 return _from_merging(state, event)
146 case Recorded():
147 return _from_recorded(state, event)
148 assert_never(state)
149 
150 
151def _from_discovered(state: Discovered, event: LoopEvent) -> Transition:
152 match event:
153 case ChangelogJudged():
154 return _advanced(
155 Judged(candidate=state.candidate, verdict=event.verdict),
156 OpenPullRequest(
157 candidate=state.candidate, verdict=event.verdict
158 ),
159 )
160 case _:
161 return _rejected(state, f"unexpected {event.kind} in discovered")
162 
163 
164def _from_judged(state: Judged, event: LoopEvent) -> Transition:
165 match event:
166 case PullRequestReady():
167 return _advanced(
168 AwaitingCi(
169 candidate=state.candidate,
170 verdict=state.verdict,
171 pr=event.pr,
172 ),
173 AwaitCi(pr=event.pr),
174 )
175 case _:
176 return _rejected(state, f"unexpected {event.kind} in judged")
177 
178 
179def _from_awaiting_ci(
180 state: AwaitingCi,
181 event: LoopEvent,
182 *,
183 close_on_red: bool,
184 auto_merge_eligible: bool,
185) -> Transition:
186 match event:
187 case CiResolved():
188 if not is_terminal(event.status):
189 return _rejected(state, "ci still pending; the spine must wait")
190 outcome = LoopOutcome(
191 candidate=state.candidate,
192 verdict=state.verdict,
193 pr=state.pr,
194 ci=event.status,
195 )
196 # Green CI, a clean changelog, and the class has earned the grant:
197 # deep-review the bump before merging (the fourth leg, §3.7), then
198 # merge on approval (the acting gate, §3.4 Stage 5). The per-PR
199 # conditions are checked here; the class-level grant arrives in
200 # auto_merge_eligible.
201 if (
202 isinstance(event.status, CIPassed)
203 and state.verdict.kind == "clean"
204 and auto_merge_eligible
205 ):
206 return _advanced(
207 GateReviewing(outcome=outcome),
208 ReviewBump(candidate=state.candidate, pr=state.pr),
209 )
210 # Red CI with close-on-red on: close the PR first (the loop leaves
211 # no rotting red proposal), then record the same outcome. Every
212 # other terminal reading (passed / absent / timed out), and red with
213 # close-on-red off, records straight away and leaves the PR for the
214 # human.
215 if isinstance(event.status, CIFailed) and close_on_red:
216 return _advanced(
217 Closing(outcome=outcome),
218 ClosePullRequest(pr=state.pr, failing=event.status.failing),
219 )
220 return _advanced(
221 Recorded(outcome=outcome), RecordOutcome(outcome=outcome)
222 )
223 case _:
224 return _rejected(state, f"unexpected {event.kind} in awaiting_ci")
225 
⋯ 60 lines hidden (lines 226–285)
226 
227def _from_gate_reviewing(state: GateReviewing, event: LoopEvent) -> Transition:
228 match event:
229 case GateReviewed():
230 # The independent deep review approved (clean): merge. Any other
231 # verdict holds — record the outcome and leave the PR for the human,
232 # exactly as a non-earned bump would. Fail-closed: a review that
233 # could not run arrives non-clean, so an unreviewable bump never
234 # auto-merges.
235 if event.verdict.kind == "clean":
236 return _advanced(
237 Merging(outcome=state.outcome),
238 MergePullRequest(pr=state.outcome.pr),
239 )
240 return _advanced(
241 Recorded(outcome=state.outcome),
242 RecordOutcome(outcome=state.outcome),
243 )
244 case _:
245 return _rejected(
246 state, f"unexpected {event.kind} in gate_reviewing"
247 )
248 
249 
250def _from_merging(state: Merging, event: LoopEvent) -> Transition:
251 match event:
252 case PullRequestMerged():
253 # The PR is merged; record the outcome it was carrying, exactly as
254 # the non-merging path would have.
255 return _advanced(
256 Recorded(outcome=state.outcome),
257 RecordOutcome(outcome=state.outcome),
258 )
259 case _:
260 return _rejected(state, f"unexpected {event.kind} in merging")
261 
262 
263def _from_closing(state: Closing, event: LoopEvent) -> Transition:
264 match event:
265 case PullRequestClosed():
266 # The PR is closed; record the outcome it was carrying, exactly as
267 # the non-closing path would have.
268 return _advanced(
269 Recorded(outcome=state.outcome),
270 RecordOutcome(outcome=state.outcome),
271 )
272 case _:
273 return _rejected(state, f"unexpected {event.kind} in closing")
274 
275 
276def _from_recorded(state: Recorded, event: LoopEvent) -> Transition:
277 # Terminal: the only expected event is the record acknowledgement, a no-op
278 # that ends the driver loop (no effects).
279 match event:
280 case OutcomeRecorded():
281 return Transition(
282 kind=TransitionKind.IGNORED, next=state, reason="loop complete"
283 )
284 case _:
285 return _rejected(state, f"unexpected {event.kind} after recorded")

GateReviewing then forks on the review's verdict: clean → Merging + MergePullRequest; anything else → Recorded, leaving the green PR open for the human. Fail-closed: an unreviewable bump arrives non-clean and never merges.

src/froot/policy/state_machine.py · 285 lines
src/froot/policy/state_machine.py285 lines · Python
⋯ 226 lines hidden (lines 1–226)
1"""The bump loop state machine: pure transitions, effects as data.
2 
3``start`` and ``advance`` are pure: given the current state and a decided event,
4they return a :class:`Transition` — the next state plus the effects the spine
5should run. No I/O, no clock, so a transition replays deterministically and is
6fully testable. Dispatch is per-state and ends in ``assert_never`` so the type
7checker proves every state is handled; an event a state does not expect yields a
8``REJECTED`` transition (no state change), never an exception.
9 
10The loop is linear: each advance emits exactly one effect, the spine runs it to
11obtain the next event, and the cycle repeats until ``Recorded`` (no effects). A
12:class:`~froot.domain.events.CiResolved` that is still pending is rejected — the
13spine must finish waiting before the machine will close the loop, so an outcome
14can never be recorded against an unresolved CI status.
15"""
16 
17from __future__ import annotations
18 
19from enum import StrEnum
20from typing import TYPE_CHECKING, assert_never
21 
22from froot.domain.base import Frozen
23from froot.domain.ci import CIFailed, CIPassed, is_terminal
24from froot.domain.effects import (
25 AwaitCi,
26 ClosePullRequest,
27 Effect,
28 JudgeChangelog,
29 MergePullRequest,
30 OpenPullRequest,
31 RecordOutcome,
32 ReviewBump,
34from froot.domain.events import (
35 ChangelogJudged,
36 CiResolved,
37 GateReviewed,
38 LoopEvent,
39 OutcomeRecorded,
40 PullRequestClosed,
41 PullRequestMerged,
42 PullRequestReady,
44from froot.domain.outcome import LoopOutcome
45from froot.domain.state import (
46 AwaitingCi,
47 BumpState,
48 Closing,
49 Discovered,
50 GateReviewing,
51 Judged,
52 Merging,
53 Recorded,
55 
56if TYPE_CHECKING:
57 from froot.domain.candidate import Candidate
58 
59 
60class TransitionKind(StrEnum):
61 """The disposition of an :func:`advance` call."""
62 
63 ADVANCED = "advanced"
64 IGNORED = "ignored"
65 REJECTED = "rejected"
66 
67 
68class Transition(Frozen):
69 """The result of a transition.
70 
71 Attributes:
72 kind: ``ADVANCED`` (moved and/or emitted effects), ``IGNORED`` (a legal
73 no-op, e.g. the terminal acknowledgement), or ``REJECTED`` (the
74 event is not valid in this state).
75 next: The resulting state (unchanged for ``IGNORED``/``REJECTED``).
76 effects: The effects the spine should run, in order. Empty terminates
77 the loop driver.
78 reason: A short explanation for an ``IGNORED``/``REJECTED`` transition.
79 """
80 
81 kind: TransitionKind
82 next: BumpState
83 effects: tuple[Effect, ...] = ()
84 reason: str | None = None
85 
86 
87def _advanced(nxt: BumpState, *effects: Effect) -> Transition:
88 return Transition(kind=TransitionKind.ADVANCED, next=nxt, effects=effects)
89 
90 
91def _rejected(state: BumpState, reason: str) -> Transition:
92 return Transition(kind=TransitionKind.REJECTED, next=state, reason=reason)
93 
94 
95def start(candidate: Candidate) -> Transition:
96 """The opening transition: enter ``Discovered`` and judge the changelog."""
97 return _advanced(
98 Discovered(candidate=candidate),
99 JudgeChangelog(candidate=candidate),
100 )
101 
102 
103def advance(
104 state: BumpState,
105 event: LoopEvent,
106 *,
107 close_on_red: bool = True,
108 auto_merge_eligible: bool = False,
109) -> Transition:
110 """Advance the loop one step (pure).
111 
112 Args:
113 state: The current bump state.
114 event: The decided event that just occurred.
115 close_on_red: Whether a terminal red CI should close the PR before
116 recording. Passed in rather than read from config so the machine
117 stays pure and replay-safe.
118 auto_merge_eligible: Whether this PR's (repo, loop) *class* has earned
119 the auto-merge grant on an allowlisted repo — the class-level half
120 of the gate, decided by the spine (it needs the class's history).
121 The per-PR half (clean changelog + green CI) the machine checks
122 itself. Defaults False, so the loop stays propose-only unless a
123 steward has granted the class autonomy.
124 
125 Returns:
126 The :class:`Transition` to apply.
127 """
128 match state:
129 case Discovered():
130 return _from_discovered(state, event)
131 case Judged():
132 return _from_judged(state, event)
133 case AwaitingCi():
134 return _from_awaiting_ci(
135 state,
136 event,
137 close_on_red=close_on_red,
138 auto_merge_eligible=auto_merge_eligible,
139 )
140 case Closing():
141 return _from_closing(state, event)
142 case GateReviewing():
143 return _from_gate_reviewing(state, event)
144 case Merging():
145 return _from_merging(state, event)
146 case Recorded():
147 return _from_recorded(state, event)
148 assert_never(state)
149 
150 
151def _from_discovered(state: Discovered, event: LoopEvent) -> Transition:
152 match event:
153 case ChangelogJudged():
154 return _advanced(
155 Judged(candidate=state.candidate, verdict=event.verdict),
156 OpenPullRequest(
157 candidate=state.candidate, verdict=event.verdict
158 ),
159 )
160 case _:
161 return _rejected(state, f"unexpected {event.kind} in discovered")
162 
163 
164def _from_judged(state: Judged, event: LoopEvent) -> Transition:
165 match event:
166 case PullRequestReady():
167 return _advanced(
168 AwaitingCi(
169 candidate=state.candidate,
170 verdict=state.verdict,
171 pr=event.pr,
172 ),
173 AwaitCi(pr=event.pr),
174 )
175 case _:
176 return _rejected(state, f"unexpected {event.kind} in judged")
177 
178 
179def _from_awaiting_ci(
180 state: AwaitingCi,
181 event: LoopEvent,
182 *,
183 close_on_red: bool,
184 auto_merge_eligible: bool,
185) -> Transition:
186 match event:
187 case CiResolved():
188 if not is_terminal(event.status):
189 return _rejected(state, "ci still pending; the spine must wait")
190 outcome = LoopOutcome(
191 candidate=state.candidate,
192 verdict=state.verdict,
193 pr=state.pr,
194 ci=event.status,
195 )
196 # Green CI, a clean changelog, and the class has earned the grant:
197 # deep-review the bump before merging (the fourth leg, §3.7), then
198 # merge on approval (the acting gate, §3.4 Stage 5). The per-PR
199 # conditions are checked here; the class-level grant arrives in
200 # auto_merge_eligible.
201 if (
202 isinstance(event.status, CIPassed)
203 and state.verdict.kind == "clean"
204 and auto_merge_eligible
205 ):
206 return _advanced(
207 GateReviewing(outcome=outcome),
208 ReviewBump(candidate=state.candidate, pr=state.pr),
209 )
210 # Red CI with close-on-red on: close the PR first (the loop leaves
211 # no rotting red proposal), then record the same outcome. Every
212 # other terminal reading (passed / absent / timed out), and red with
213 # close-on-red off, records straight away and leaves the PR for the
214 # human.
215 if isinstance(event.status, CIFailed) and close_on_red:
216 return _advanced(
217 Closing(outcome=outcome),
218 ClosePullRequest(pr=state.pr, failing=event.status.failing),
219 )
220 return _advanced(
221 Recorded(outcome=outcome), RecordOutcome(outcome=outcome)
222 )
223 case _:
224 return _rejected(state, f"unexpected {event.kind} in awaiting_ci")
225 
226 
227def _from_gate_reviewing(state: GateReviewing, event: LoopEvent) -> Transition:
228 match event:
229 case GateReviewed():
230 # The independent deep review approved (clean): merge. Any other
231 # verdict holds — record the outcome and leave the PR for the human,
232 # exactly as a non-earned bump would. Fail-closed: a review that
233 # could not run arrives non-clean, so an unreviewable bump never
234 # auto-merges.
235 if event.verdict.kind == "clean":
236 return _advanced(
237 Merging(outcome=state.outcome),
238 MergePullRequest(pr=state.outcome.pr),
239 )
240 return _advanced(
241 Recorded(outcome=state.outcome),
242 RecordOutcome(outcome=state.outcome),
243 )
244 case _:
245 return _rejected(
246 state, f"unexpected {event.kind} in gate_reviewing"
247 )
248 
⋯ 37 lines hidden (lines 249–285)
249 
250def _from_merging(state: Merging, event: LoopEvent) -> Transition:
251 match event:
252 case PullRequestMerged():
253 # The PR is merged; record the outcome it was carrying, exactly as
254 # the non-merging path would have.
255 return _advanced(
256 Recorded(outcome=state.outcome),
257 RecordOutcome(outcome=state.outcome),
258 )
259 case _:
260 return _rejected(state, f"unexpected {event.kind} in merging")
261 
262 
263def _from_closing(state: Closing, event: LoopEvent) -> Transition:
264 match event:
265 case PullRequestClosed():
266 # The PR is closed; record the outcome it was carrying, exactly as
267 # the non-closing path would have.
268 return _advanced(
269 Recorded(outcome=state.outcome),
270 RecordOutcome(outcome=state.outcome),
271 )
272 case _:
273 return _rejected(state, f"unexpected {event.kind} in closing")
274 
275 
276def _from_recorded(state: Recorded, event: LoopEvent) -> Transition:
277 # Terminal: the only expected event is the record acknowledgement, a no-op
278 # that ends the driver loop (no effects).
279 match event:
280 case OutcomeRecorded():
281 return Transition(
282 kind=TransitionKind.IGNORED, next=state, reason="loop complete"
283 )
284 case _:
285 return _rejected(state, f"unexpected {event.kind} after recorded")

The class-level grant — computed once, outside the machine

The grant is about the class's history, not this one PR, so the workflow decides it once up front and threads it into advance(). The activity short-circuits to False for any non-allowlisted repo — the common case costs nothing and touches no network — and otherwise re-derives standing from live GitHub via the dashboard's own logic.

src/froot/workflow/activities.py · 636 lines
src/froot/workflow/activities.py636 lines · Python
⋯ 356 lines hidden (lines 1–356)
1"""Activities: the effect interpreters that wrap the adapters.
2 
3Each activity is the impure boundary for one effect. Adapter imports are LAZY
4(inside the bodies) so the model and HTTP stacks never enter a workflow sandbox;
5the pure policy and domain imports stay at module level. Activities return
6domain values — the workflow wraps them into events and feeds the pure state
7machine. The activity signatures' types are evaluated at runtime by Temporal's
8data converter, so the domain imports here are deliberately not deferred.
9"""
10 
11from __future__ import annotations
12 
13import json
14import logging
15import tempfile
16from pathlib import Path
17from typing import TYPE_CHECKING, assert_never
18 
19from temporalio import activity
20 
21from froot.domain.candidate import Candidate
22from froot.domain.changelog import ChangelogVerdict, UnknownVerdict
23from froot.domain.ci import CIStatus
24from froot.domain.determinism import AnalysisResult, FrontierVerdict
25from froot.domain.loop import Loop
26from froot.domain.pull_request import PullRequestRef
27from froot.domain.repo import TargetRepo
28from froot.policy.compose import pr_labels, pull_request_draft
29from froot.policy.determinism import analyze_workflow_surface
30from froot.policy.naming import (
31 branch_name,
32 bump_workflow_id,
33 pr_review_workflow_id,
35from froot.policy.review_comment import REVIEW_MARKER, render_review_comment
36from froot.workflow.types import (
37 AdjudicateInput,
38 AutoMergeInput,
39 CiCheckInput,
40 CloseInput,
41 DispatchInput,
42 DispatchReviewInput,
43 GateReviewInput,
44 GateSelfTestInput,
45 JudgeInput,
46 MergeInput,
47 OpenPrInput,
48 PostReviewInput,
49 PrReviewParams,
50 ReconcileInput,
51 RecordInput,
52 ScanCandidatesInput,
54 
55if TYPE_CHECKING:
56 from froot.ports.protocols import PackageManager
57 
58_log = logging.getLogger("froot.outcome")
59_review_log = logging.getLogger("froot.review")
60_reconcile_log = logging.getLogger("froot.reconcile")
61_scan_log = logging.getLogger("froot.scan")
62_gate_log = logging.getLogger("froot.gate")
63 
64 
65def _manifest_dir(target: TargetRepo, workspace: Path) -> Path:
66 """The directory the manifest lives in (a monorepo subdir, or the root)."""
67 return workspace / target.manifest_dir if target.manifest_dir else workspace
68 
69 
70async def _select_candidates(
71 loop: Loop,
72 target: TargetRepo,
73 package_manager: PackageManager,
74 manifest_dir: Path,
75) -> tuple[int, tuple[Candidate, ...]]:
76 """Gather this loop's signal from the checkout and select its candidates.
77 
78 The one genuinely per-loop seam: dependency-patch reads the available
79 upgrades and picks the highest patch; security-patch reads the installed,
80 asks OSV for advisories, and picks the lowest version clearing each. The
81 impure sources are lazy-imported per arm so neither drags the other's stack
82 into a sandbox. Both feed a pure selection policy.
83 
84 Returns ``(considered, candidates)`` — ``considered`` is the size of the
85 upstream signal (available upgrades / advisories found) so the scan can make
86 its selectivity legible (how much was seen versus how much was kept).
87 """
88 match loop:
89 case Loop.DEPENDENCY_PATCH:
90 from froot.policy.candidates import select_patch_candidates
91 
92 upgrades = await package_manager.list_upgrades(target, manifest_dir)
93 return len(upgrades), select_patch_candidates(upgrades)
94 case Loop.SECURITY_PATCH:
95 return await _select_security_candidates(
96 target, package_manager, manifest_dir
97 )
98 assert_never(loop)
99 
100 
101async def _select_security_candidates(
102 target: TargetRepo, package_manager: PackageManager, manifest_dir: Path
103) -> tuple[int, tuple[Candidate, ...]]:
104 """Security signal: installed set, OSV advisories, clearing targets.
105 
106 ``considered`` is the count of advisories OSV returned for the installed
107 set — the vulnerabilities in scope this tick, before selection narrows to
108 the ones a forward-stable bump can actually clear.
109 """
110 from froot.adapters.osv import OsvAdvisorySource
111 from froot.policy.candidates import select_security_candidates
112 
113 installed = await package_manager.list_installed(target, manifest_dir)
114 advisories = await OsvAdvisorySource().advisories(installed)
115 return len(advisories), select_security_candidates(installed, advisories)
116 
117 
118@activity.defn
119async def scan_candidates(
120 params: ScanCandidatesInput,
121) -> tuple[Candidate, ...]:
122 """Check out the repo and select this loop's candidates.
123 
124 Emits the tick's selectivity — how much upstream signal was considered
125 versus how much was kept — as span attributes (when ``FROOT_OTEL`` is on)
126 and as a structured ``scan_tick`` log, so the signal stage is legible in the
127 run ledger even on a tick that proposes nothing.
128 """
129 from froot.adapters.github import GitHubForge
130 from froot.adapters.registry import package_manager_for
131 from froot.adapters.telemetry import set_span_attributes
132 
133 forge = GitHubForge()
134 package_manager = package_manager_for(params.target.ecosystem)
135 with tempfile.TemporaryDirectory() as tmp:
136 workspace = Path(tmp)
137 await forge.checkout(params.target, workspace)
138 considered, candidates = await _select_candidates(
139 params.loop,
140 params.target,
141 package_manager,
142 _manifest_dir(params.target, workspace),
143 )
144 selected = len(candidates)
145 dropped = max(considered - selected, 0)
146 set_span_attributes(
147 scan_loop=params.loop.value,
148 scan_repo=params.target.repo.slug,
149 scan_considered=considered,
150 scan_selected=selected,
151 scan_dropped=dropped,
152 )
153 _scan_log.info(
154 json.dumps(
155 {
156 "event": "scan_tick",
157 "loop": params.loop.value,
158 "repo": params.target.repo.slug,
159 "considered": considered,
160 "selected": selected,
161 "dropped": dropped,
162 }
163 )
164 )
165 return candidates
166 
167 
168@activity.defn
169async def judge_changelog(params: JudgeInput) -> ChangelogVerdict:
170 """Fetch the candidate's changelog and get the model's typed verdict.
171 
172 The model is froot's one thin, non-load-bearing judgment: a clean verdict
173 never *gates* a PR (CI is the oracle), so a model that is down, slow, or
174 erroring must not stall the spine. A judge failure degrades to
175 ``UnknownVerdict`` — the bump proceeds, the human still gets the PR, and the
176 dashboard records the verdict as unknown — rather than failing (and then
177 retrying) the activity. Only the model call is guarded; the fetch is already
178 best-effort (returns ``None``, not an exception). The loop selects what the
179 model is asked (clean-patch vs breaking-change-on-a-security-bump).
180 """
181 from froot.adapters.changelog_http import HttpChangelogSource
182 from froot.adapters.model_judge import PydanticAiJudge
183 
184 changelog = await HttpChangelogSource().fetch(params.candidate)
185 if changelog is None:
186 return UnknownVerdict(rationale="No changelog could be fetched.")
187 try:
188 return await PydanticAiJudge().judge(changelog, params.loop)
189 except Exception as exc:
190 activity.logger.warning(
191 "changelog judge unavailable for %s; degrading to unknown: %r",
192 params.candidate.package,
193 exc,
194 )
195 return UnknownVerdict(
196 rationale=f"Changelog judge unavailable ({type(exc).__name__})."
197 )
198 
199 
200@activity.defn
201async def gate_review(params: GateReviewInput) -> ChangelogVerdict:
202 """Independently deep-review a bump at the gate; fail-closed to a hold.
203 
204 The fourth trust leg (§3.7): a second, adversarial model pass over the
205 changelog, run only when a bump is about to auto-merge. ``clean`` approves
206 the merge; ``risky``/``unknown`` hold the PR for the human. Fail-CLOSED — a
207 missing changelog or a model error returns a non-clean verdict, so an
208 unreviewable bump never merges unattended. This is the opposite disposition
209 from :func:`judge_changelog` (which degrades-to-proceed): here the safe
210 direction is to hold, and a non-clean verdict already means hold.
211 """
212 from froot.adapters.changelog_http import HttpChangelogSource
213 from froot.adapters.model_judge import PydanticAiJudge
214 
215 changelog = await HttpChangelogSource().fetch(params.candidate)
216 if changelog is None:
217 verdict: ChangelogVerdict = UnknownVerdict(
218 rationale="No changelog to review; holding (fail-closed)."
219 )
220 else:
221 try:
222 verdict = await PydanticAiJudge().gate_review(
223 changelog, params.loop
224 )
225 except Exception as exc:
226 activity.logger.warning(
227 "gate reviewer unavailable for %s; holding: %r",
228 params.candidate.package,
229 exc,
230 )
231 verdict = UnknownVerdict(
232 rationale=f"Gate reviewer unavailable ({type(exc).__name__})."
233 )
234 _gate_log.info(
235 json.dumps(
236 {
237 "event": "gate_review",
238 "loop": params.loop.value,
239 "pr": params.pr.number,
240 "pr_url": params.pr.url,
241 "package": params.candidate.package,
242 "verdict": verdict.kind,
243 "approved": verdict.kind == "clean",
244 }
245 )
246 )
247 return verdict
248 
249 
250@activity.defn
251async def open_pull_request(params: OpenPrInput) -> PullRequestRef:
252 """Regenerate manifest+lockfile and open (idempotently) the bump's PR."""
253 from froot.adapters.github import GitHubForge
254 from froot.adapters.registry import package_manager_for
255 
256 forge = GitHubForge()
257 package_manager = package_manager_for(params.target.ecosystem)
258 branch = branch_name(params.candidate, params.loop)
259 existing = await forge.find_open_pull_request(params.target, branch)
260 if existing is not None:
261 return existing
262 draft = pull_request_draft(
263 params.target, params.candidate, params.verdict, params.loop
264 )
265 with tempfile.TemporaryDirectory() as tmp:
266 workspace = Path(tmp)
267 await forge.checkout(params.target, workspace)
268 await package_manager.apply_patch_bump(
269 params.candidate, _manifest_dir(params.target, workspace)
270 )
271 await forge.push_branch(workspace, branch, draft.title)
272 return await forge.open_pull_request(params.target, draft)
273 
274 
275@activity.defn
276async def check_ci(params: CiCheckInput) -> CIStatus:
277 """Read the repo's CI status for the PR's head commit (the oracle)."""
278 from froot.adapters.github import GitHubForge
279 
280 return await GitHubForge().ci_status(params.target, params.head_sha)
281 
282 
283@activity.defn
284async def record_outcome(params: RecordInput) -> None:
285 """Label the PR and log the run telemetry — the signal-update.
286 
287 The labels carry the loop *and* the judgment environment (the judge model)
288 the PR was opened under, so the gate can count only the track record earned
289 under the current environment and reset it when the model changes (§3.7).
290 """
291 from froot.adapters.github import GitHubForge
292 from froot.config.settings import ModelSettings
293 from froot.policy.environment import env_label
294 
295 outcome = params.outcome
296 labels = (
297 *pr_labels(params.loop),
298 env_label(ModelSettings().ollama_model),
299 )
300 await GitHubForge().add_labels(params.target, outcome.pr.number, labels)
301 _log.info(
302 json.dumps(
303 {
304 "event": "loop_outcome",
305 "loop": params.loop.value,
306 "repo": params.target.repo.slug,
307 "package": outcome.candidate.package,
308 "from": str(outcome.candidate.current),
309 "to": str(outcome.candidate.target),
310 "changelog": outcome.verdict.kind,
311 "ci": outcome.ci.kind,
312 "ci_passed": outcome.ci_passed,
313 "pr": outcome.pr.number,
314 "pr_url": outcome.pr.url,
315 }
316 )
317 )
318 
319 
320@activity.defn
321async def dispatch_bump(params: DispatchInput) -> None:
322 """Start the bump loop for a candidate (idempotent per bump identity).
323 
324 Reads the close-on-red toggle here, at the impure boundary, and pins it onto
325 the bump's params — so the running workflow never reads config itself and an
326 in-flight bump keeps the value it was dispatched with.
327 """
328 from temporalio.common import WorkflowIDReusePolicy
329 from temporalio.exceptions import WorkflowAlreadyStartedError
330 
331 from froot.config.settings import BehaviorSettings
332 from froot.workflow.bump_workflow import BumpWorkflow
333 from froot.workflow.temporal_client import client, task_queue
334 from froot.workflow.types import BumpParams
335 
336 temporal = await client()
337 try:
338 await temporal.start_workflow(
339 BumpWorkflow.run,
340 BumpParams(
341 target=params.target,
342 candidate=params.candidate,
343 close_on_red=BehaviorSettings().close_on_red,
344 loop=params.loop,
345 ),
346 id=bump_workflow_id(params.target, params.candidate, params.loop),
347 task_queue=task_queue(),
348 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
349 )
350 except WorkflowAlreadyStartedError:
351 # This bump already has a loop (running or completed) — a no-op, so
352 # re-scanning never opens a second PR for the same bump.
353 return
354 
355 
356@activity.defn
357async def auto_merge_eligible(params: AutoMergeInput) -> bool:
358 """Whether this (repo, loop) class has earned the auto-merge grant.
359 
360 Short-circuits to ``False`` for any repo a steward has not allowlisted (the
361 default), so the common case costs nothing. Otherwise it re-derives the
362 class's standing from the live GitHub history — the same triangulated,
363 windowed, environment-scoped computation the dashboard's shadow gate shows
364 (``read_model.earned_now``) — so the acting gate and the advisory panel can
365 never disagree. Best-effort: any read failure degrades to ``False`` (hold),
366 never an auto-merge.
367 """
368 from datetime import UTC, datetime
369 
370 from froot.config.settings import AutonomySettings, ModelSettings
371 from froot.dashboard import github_source, read_model
372 from froot.policy.environment import environment_slug
373 
374 policy = AutonomySettings().policy()
375 repo = params.target.repo.slug
376 if repo not in policy.allowlisted_repos:
377 return False
378 now = datetime.now(UTC)
379 prs, prs_error = await github_source.fetch((repo,))
380 if prs_error is not None:
381 return False # can't confirm the record -> hold, never merge blind
382 outcomes, _ = await github_source.fetch_outcomes(
383 (repo,), prs, now=now, window_days=policy.window_days
384 )
385 return read_model.earned_now(
386 now,
387 prs,
388 outcomes,
389 repo,
390 params.loop,
391 policy,
392 environment_slug(ModelSettings().ollama_model),
393 )
394 
395 
⋯ 241 lines hidden (lines 396–636)
396@activity.defn
397async def merge_pull_request(params: MergeInput) -> None:
398 """Auto-merge an earned, clean+green bump's PR (the acting gate's write).
399 
400 Reached only after the pure machine confirmed clean+green and the class
401 earned the grant on an allowlisted repo. Passes the head SHA so GitHub
402 refuses the merge if the head moved since the gate decided.
403 """
404 from froot.adapters.github import GitHubForge
405 
406 await GitHubForge().merge_pull_request(
407 params.target, params.pr.number, head_sha=params.pr.head_sha
408 )
409 _log.info(
410 json.dumps(
411 {
412 "event": "pr_merged",
413 "loop": params.loop.value,
414 "reason": "auto_merge",
415 "repo": params.target.repo.slug,
416 "pr": params.pr.number,
417 "pr_url": params.pr.url,
418 }
419 )
420 )
421 
422 
423@activity.defn
424async def close_pull_request(params: CloseInput) -> None:
425 """Comment why, then close a red bump's PR and delete its branch.
426 
427 The note goes through the idempotent ``upsert_issue_comment`` and the close
428 itself is idempotent, so a retried close edits its comment in place and
429 never double-posts. The bump's record step still runs after this, so the red
430 outcome is logged either way.
431 """
432 from froot.adapters.github import GitHubForge
433 from froot.policy.compose import CLOSE_MARKER, closed_on_red_comment
434 
435 forge = GitHubForge()
436 body = closed_on_red_comment(params.failing)
437 await forge.upsert_issue_comment(
438 params.target, params.pr.number, CLOSE_MARKER, body
439 )
440 await forge.close_pull_request(
441 params.target, params.pr.number, params.pr.branch
442 )
443 _log.info(
444 json.dumps(
445 {
446 "event": "pr_closed",
447 "loop": params.loop.value,
448 "reason": "ci_red",
449 "repo": params.target.repo.slug,
450 "pr": params.pr.number,
451 "pr_url": params.pr.url,
452 "failing": list(params.failing),
453 }
454 )
455 )
456 
457 
458@activity.defn
459async def reconcile_open_prs(params: ReconcileInput) -> int:
460 """Close this loop's PRs that a newer candidate or the base has overtaken.
461 
462 Self-contained: lists the repo's open PRs, re-derives this loop's live
463 candidates (a fresh checkout + the loop's signal, derive-never-store), asks
464 the pure :func:`~froot.policy.reconcile.reconciliations` policy: which of
465 loop's PRs to close, and closes each (deleting its branch). Scoped to the
466 loop's own branch namespace, so the two loops never reconcile each other's
467 PRs. A no-op when reconcile is off (``FROOT_RECONCILE``). Returns the count.
468 """
469 from froot.adapters.github import GitHubForge
470 from froot.adapters.registry import package_manager_for
471 from froot.config.settings import BehaviorSettings
472 from froot.policy.compose import CLOSE_MARKER
473 from froot.policy.reconcile import reconciliations
474 
475 if not BehaviorSettings().reconcile:
476 return 0
477 
478 target, loop = params.target, params.loop
479 forge = GitHubForge()
480 package_manager = package_manager_for(target.ecosystem)
481 open_prs = await forge.list_open_pull_requests(target)
482 with tempfile.TemporaryDirectory() as tmp:
483 workspace = Path(tmp)
484 await forge.checkout(target, workspace)
485 _considered, candidates = await _select_candidates(
486 loop, target, package_manager, _manifest_dir(target, workspace)
487 )
488 closures = reconciliations(open_prs, candidates, loop)
489 for closure in closures:
490 await forge.upsert_issue_comment(
491 target, closure.pr.number, CLOSE_MARKER, closure.comment
492 )
493 await forge.close_pull_request(
494 target, closure.pr.number, closure.pr.branch
495 )
496 if closures:
497 _reconcile_log.info(
498 json.dumps(
499 {
500 "event": "reconcile",
501 "loop": loop.value,
502 "repo": target.repo.slug,
503 "closed": len(closures),
504 "prs": [closure.pr.number for closure in closures],
505 }
506 )
507 )
508 return len(closures)
509 
510 
511@activity.defn
512async def gate_selftest(params: GateSelfTestInput) -> tuple[str, ...]:
513 """Run the adversarial gate probe against the live policy; alarm on escape.
514 
515 The §2.11 deliberate disturbance for the acting gate: a battery of synthetic
516 known-bad class histories a healthy gate must refuse, scored against the
517 policy froot is *actually running* (config and all). Any escape — a bad
518 class the live gate would grant — is logged at ERROR (the alarm) so it
519 surfaces in telemetry the moment config drifts; a clean pass logs a
520 heartbeat at INFO. Returns the escaped scenario names. Pure compute,
521 repo-independent (the ``target``/``loop`` are only the log's context).
522 """
523 from froot.config.settings import AutonomySettings
524 from froot.policy.gate_probe import gate_escapes
525 
526 escaped = gate_escapes(AutonomySettings().policy())
527 record = json.dumps(
528 {
529 "event": "gate_selftest",
530 "loop": params.loop.value,
531 "repo": params.target.repo.slug,
532 "healthy": not escaped,
533 "escaped": list(escaped),
534 }
535 )
536 if escaped:
537 _gate_log.error(record) # an alarm: the gate would trust a bad class
538 else:
539 _gate_log.info(record)
540 return escaped
541 
542 
543# ── The determinism reviewer (the transitive ring) ──────────────────────────
544@activity.defn
545async def list_review_prs(target: TargetRepo) -> tuple[PullRequestRef, ...]:
546 """List the repo's open PRs for the determinism reviewer to consider."""
547 from froot.adapters.github import GitHubForge
548 
549 return await GitHubForge().list_open_pull_requests(target)
550 
551 
552@activity.defn
553async def dispatch_pr_review(params: DispatchReviewInput) -> None:
554 """Start a PR's determinism review (idempotent per PR + head SHA)."""
555 from temporalio.common import WorkflowIDReusePolicy
556 from temporalio.exceptions import WorkflowAlreadyStartedError
557 
558 from froot.workflow.pr_review_workflow import PrReviewWorkflow
559 from froot.workflow.temporal_client import client, task_queue
560 
561 temporal = await client()
562 try:
563 await temporal.start_workflow(
564 PrReviewWorkflow.run,
565 PrReviewParams(target=params.target, pr=params.pr),
566 id=pr_review_workflow_id(
567 params.target, params.pr.number, params.pr.head_sha
568 ),
569 task_queue=task_queue(),
570 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
571 )
572 except WorkflowAlreadyStartedError:
573 # This (PR, head SHA) already has a review — a no-op, so re-polling
574 # never double-reviews the same commit.
575 return
576 
577 
578@activity.defn
579async def analyze_pr(params: PrReviewParams) -> AnalysisResult:
580 """Check out the PR head and analyze the workflow surface for hazards."""
581 from froot.adapters.github import GitHubForge
582 from froot.adapters.source_tree import load_modules
583 from froot.config.settings import ReviewSettings
584 
585 forge = GitHubForge()
586 with tempfile.TemporaryDirectory() as tmp:
587 workspace = Path(tmp)
588 await forge.checkout_pull_request(
589 params.target, workspace, params.pr.number
590 )
591 # The ASTs and source lines are read into memory here, so the analysis
592 # below is unaffected by the workspace being cleaned up.
593 modules = load_modules(workspace)
594 return analyze_workflow_surface(modules, max_depth=ReviewSettings().depth)
595 
596 
597@activity.defn
598async def adjudicate_frontier(
599 params: AdjudicateInput,
600) -> tuple[FrontierVerdict, ...]:
601 """Run the model over each frontier item; return aligned verdicts."""
602 from froot.adapters.determinism_judge import DeterminismFrontierJudge
603 
604 judge = DeterminismFrontierJudge()
605 verdicts: list[FrontierVerdict] = []
606 for item in params.frontier:
607 verdicts.append(await judge.adjudicate(item))
608 return tuple(verdicts)
609 
610 
611@activity.defn
612async def post_review(params: PostReviewInput) -> str | None:
613 """Upsert the advisory comment (when there are findings); log the ledger."""
614 from froot.adapters.github import GitHubForge
615 
616 body = render_review_comment(params.findings, params.pr.head_sha)
617 url: str | None = None
618 if body is not None:
619 url = await GitHubForge().upsert_issue_comment(
620 params.target, params.pr.number, REVIEW_MARKER, body
621 )
622 _review_log.info(
623 json.dumps(
624 {
625 "event": "loop_outcome",
626 "loop": "determinism-review",
627 "repo": params.target.repo.slug,
628 "pr": params.pr.number,
629 "head_sha": params.pr.head_sha,
630 "findings": len(params.findings),
631 "rules": sorted({f.rule for f in params.findings}),
632 "comment_url": url,
633 }
634 )
635 )
636 return url

auto_merge_eligible reuses read_model.earned_now — the exact windowed, environment-scoped triangulation the dashboard panel shows — so the acting gate and the advisory panel can never disagree.

src/froot/dashboard/read_model.py · 703 lines
src/froot/dashboard/read_model.py703 lines · Python
⋯ 410 lines hidden (lines 1–410)
1"""Assemble the dashboard view — pure, from the three readers' output.
2 
3This is the reputation read-model proper: it joins GitHub (authoritative
4outcomes) to Temporal (recent verdict + CI reading) by PR number, derives the
5MHE-framed aggregates (track record, verification, judgment, the approval
6queue), and returns a fully-computed
7:class:`~froot.dashboard.model.DashboardModel` the renderer just projects. No
8I/O and no clock of its own — ``now`` is passed in — so every figure on the
9page is unit-tested apart from the network.
10"""
11 
12from __future__ import annotations
13 
14from typing import TYPE_CHECKING
15 
16from froot.dashboard.model import (
17 BumpRow,
18 ClassGate,
19 DashboardModel,
20 Failure,
21 Judgment,
22 Probes,
23 Reliability,
24 ReviewLoop,
25 ReviewRecord,
26 ReviewRow,
27 RunTelemetry,
28 ScanLoop,
29 SourceHealth,
30 TrackRecord,
31 Verification,
33from froot.domain.loop import Loop
34from froot.domain.repo import RepoRef, TargetRepo
35from froot.policy.autonomy import AutonomyPolicy, class_earned, pr_autonomy
36from froot.policy.canary import is_canary, score_probe
37from froot.policy.naming import review_workflow_id, scan_workflow_id
38from froot.result import Ok
39 
40if TYPE_CHECKING:
41 from datetime import datetime
42 
43 from froot.dashboard.github_source import GithubPr
44 from froot.dashboard.temporal_source import (
45 BumpExecution,
46 PrReviewExecution,
47 ReviewExecution,
48 ScanExecution,
49 )
50 
51# The conservative fallback policy: empty allowlist, so the shadow gate holds
52# everything until a caller passes a real one (built from FROOT_AUTOMERGE_*).
53_DEFAULT_POLICY = AutonomyPolicy()
54 
55_LIVE_STATUSES = frozenset({"running", "continued_as_new"})
56# Every way a bump can end without closing cleanly — all belong in the honest
57# Failures panel, not silently dropped.
58_FAILURE_STATUSES = frozenset({"terminated", "failed", "canceled", "timed_out"})
59 
60 
61def _median(values: list[float]) -> float | None:
62 """The median of ``values``, or ``None`` if empty (pure)."""
63 if not values:
64 return None
65 ordered = sorted(values)
66 mid = len(ordered) // 2
67 if len(ordered) % 2 == 1:
68 return ordered[mid]
69 return (ordered[mid - 1] + ordered[mid]) / 2
70 
71 
72def _scan_id(repo: str, loop: Loop) -> str | None:
73 """The deterministic scan-loop id for an ``owner/name`` slug + loop."""
74 match RepoRef.parse(repo):
75 case Ok(ref):
76 return scan_workflow_id(TargetRepo(repo=ref), loop)
77 case _:
78 return None
79 
80 
81def _scan_loops(
82 repos: tuple[str, ...],
83 loops: tuple[Loop, ...],
84 scans: tuple[ScanExecution, ...],
85) -> tuple[ScanLoop, ...]:
86 """One liveness row per configured (repo, loop) (latest execution wins)."""
87 by_id: dict[str, ScanExecution] = {}
88 for scan in scans:
89 current = by_id.get(scan.workflow_id)
90 if current is None or _newer(scan.start, current.start):
91 by_id[scan.workflow_id] = scan
92 rows: list[ScanLoop] = []
93 for loop in loops:
94 for repo in repos:
95 scan_id = _scan_id(repo, loop)
96 execution = by_id.get(scan_id) if scan_id is not None else None
97 if execution is None:
98 rows.append(
99 ScanLoop(
100 repo=repo,
101 loop=loop.value,
102 status="none",
103 live=False,
104 last_tick=None,
105 )
106 )
107 else:
108 rows.append(
109 ScanLoop(
110 repo=repo,
111 loop=loop.value,
112 status=execution.status,
113 live=execution.status in _LIVE_STATUSES,
114 last_tick=execution.start,
115 )
116 )
117 return tuple(rows)
118 
119 
120def _newer(a: datetime | None, b: datetime | None) -> bool:
121 """True if ``a`` is a later instant than ``b`` (``None`` is oldest)."""
122 if a is None:
123 return False
124 if b is None:
125 return True
126 return a > b
127 
128 
129def _bump_rows(
130 now: datetime,
131 prs: tuple[GithubPr, ...],
132 bumps: tuple[BumpExecution, ...],
133 outcomes: dict[tuple[str, int], str],
134) -> tuple[BumpRow, ...]:
135 """Join GitHub PRs (authoritative) to Temporal outcomes by PR number.
136 
137 ``outcomes`` carries the post-merge signal (``held`` / ``broke`` /
138 ``reverted`` / ``unknown``) per recently-merged ``(repo, number)``; it is
139 absent for older or non-merged PRs, which leaves ``post_merge`` ``None``.
140 """
141 # Keyed on (repo, PR number), not the bare number: Temporal lists every
142 # repo's bumps in the namespace, so two repos that each have a PR #N would
143 # otherwise cross-attribute one's verdict/CI onto the other's row.
144 by_pr: dict[tuple[str, int], BumpExecution] = {
145 (bump.repo, bump.pr_number): bump
146 for bump in bumps
147 if bump.repo is not None and bump.pr_number is not None
148 }
149 rows: list[BumpRow] = []
150 for pr in prs:
151 execution = by_pr.get((pr.repo, pr.number))
152 verdict = (execution.verdict if execution else None) or pr.verdict
153 ci = execution.ci if execution else None
154 ttm = _minutes_between(pr.opened_at, pr.merged_at)
155 age = _hours_between(pr.opened_at, now) if pr.state == "open" else None
156 rows.append(
157 BumpRow(
158 repo=pr.repo,
159 loop=pr.loop,
160 package=pr.package or "?",
161 from_version=pr.from_version,
162 to_version=pr.to_version or "?",
163 state=pr.state,
164 verdict=verdict,
165 ci=ci,
166 pr_number=pr.number,
167 pr_url=pr.url,
168 opened_at=pr.opened_at,
169 merged_at=pr.merged_at,
170 ttm_minutes=ttm,
171 age_hours=age,
172 post_merge=outcomes.get((pr.repo, pr.number)),
173 env=pr.env,
174 )
175 )
176 rows.sort(key=_opened_sort_key, reverse=True)
177 return tuple(rows)
178 
179 
180def _opened_sort_key(row: BumpRow) -> float:
181 """Sort key putting the most recently opened PR first (unknown last)."""
182 return row.opened_at.timestamp() if row.opened_at is not None else 0.0
183 
184 
185def _minutes_between(
186 start: datetime | None, end: datetime | None
187) -> float | None:
188 """Whole-ish minutes from ``start`` to ``end``, or ``None``."""
189 if start is None or end is None:
190 return None
191 return round((end - start).total_seconds() / 60, 1)
192 
193 
194def _hours_between(
195 start: datetime | None, end: datetime | None
196) -> float | None:
197 """Hours from ``start`` to ``end``, or ``None``."""
198 if start is None or end is None:
199 return None
200 return round((end - start).total_seconds() / 3600, 1)
201 
202 
203def _track_record(rows: tuple[BumpRow, ...]) -> TrackRecord:
204 """Counts, merge rate, and median time-to-merge from the bump rows."""
205 merged = [r for r in rows if r.state == "merged"]
206 closed = sum(1 for r in rows if r.state == "closed")
207 open_now = sum(1 for r in rows if r.state == "open")
208 decided = len(merged) + closed
209 ttms = [r.ttm_minutes for r in merged if r.ttm_minutes is not None]
210 return TrackRecord(
211 opened=len(rows),
212 merged=len(merged),
213 closed_unmerged=closed,
214 open_now=open_now,
215 merge_rate=(len(merged) / decided) if decided else None,
216 median_ttm_minutes=_median(ttms),
217 )
218 
219 
220def _verification(rows: tuple[BumpRow, ...]) -> Verification:
221 """The CI-oracle breakdown, keeping ``absent`` distinct from a failure."""
222 passed = sum(1 for r in rows if r.ci == "passed")
223 failed = sum(1 for r in rows if r.ci == "failed")
224 absent = sum(1 for r in rows if r.ci == "absent")
225 timed_out = sum(1 for r in rows if r.ci == "timed_out")
226 unknown = sum(1 for r in rows if r.ci is None)
227 return Verification(
228 passed=passed,
229 failed=failed,
230 absent=absent,
231 timed_out=timed_out,
232 unknown=unknown,
233 oracle_existed=passed + failed,
234 with_reading=passed + failed + absent + timed_out,
235 )
236 
237 
238def _reliability(rows: tuple[BumpRow, ...], window_days: int) -> Reliability:
239 """The post-merge outcome breakdown — did the merges hold? (coarse).
240 
241 Reads only the ``post_merge`` tag the outcome reader set: ``unknown`` means
242 an in-window merge we could not classify (no branch oracle / aged out), kept
243 distinct from a held one; ``None`` (older / non-merged) is not counted here.
244 The defect rate is a floor — manual and bundled reverts are invisible.
245 """
246 held = sum(1 for r in rows if r.post_merge == "held")
247 broke = sum(1 for r in rows if r.post_merge == "broke")
248 reverted = sum(1 for r in rows if r.post_merge == "reverted")
249 unverified = sum(1 for r in rows if r.post_merge == "unknown")
250 determined = held + broke + reverted
251 return Reliability(
252 held=held,
253 broke=broke,
254 reverted=reverted,
255 unverified=unverified,
256 determined=determined,
257 defect_rate=((broke + reverted) / determined) if determined else None,
258 window_days=window_days,
259 )
260 
261 
262def _probes(canary_rows: tuple[BumpRow, ...]) -> Probes:
263 """Tally the adversarial canary probes — caught / escaped / pending.
264 
265 These rows are the synthetic bad bumps; they are scored on the strict bar
266 (a canary must never merge) and kept out of every genuine bearing.
267 """
268 scored = [score_probe(r.state) for r in canary_rows]
269 return Probes(
270 caught=sum(1 for s in scored if s == "caught"),
271 escaped=sum(1 for s in scored if s == "escaped"),
272 pending=sum(1 for s in scored if s == "pending"),
273 total=len(scored),
274 )
275 
276 
277def _judgment(rows: tuple[BumpRow, ...]) -> Judgment:
278 """The verdict mix plus the two calibration cells worth flagging."""
279 clean = sum(1 for r in rows if r.verdict == "clean")
280 risky = sum(1 for r in rows if r.verdict == "risky")
281 unknown = sum(1 for r in rows if r.verdict == "unknown")
282 none = sum(1 for r in rows if r.verdict is None)
283 clean_but_failed = sum(
284 1 for r in rows if r.verdict == "clean" and r.ci == "failed"
285 )
286 flagged_but_passed = sum(
287 1
288 for r in rows
289 if r.verdict in ("risky", "unknown") and r.ci == "passed"
290 )
291 return Judgment(
292 clean=clean,
293 risky=risky,
294 unknown=unknown,
295 none=none,
296 clean_but_failed=clean_but_failed,
297 flagged_but_passed=flagged_but_passed,
298 )
299 
300 
301def _decided_at(row: BumpRow) -> datetime | None:
302 """When a decided PR was decided — merge time, else open time as a proxy.
303 
304 A merged PR has an exact merge instant; a closed-unmerged one does not (the
305 issues list froot reads carries no close timestamp), so it is windowed by
306 when it opened. The proxy only nudges a closed PR's window membership by its
307 own lifetime — close enough for a recency window measured in months.
308 """
309 return row.merged_at or row.opened_at
310 
311 
312def _within(when: datetime | None, now: datetime, window_days: int) -> bool:
313 """Whether ``when`` falls within ``window_days`` before ``now`` (pure)."""
314 if when is None:
315 return False
316 return (now - when).total_seconds() <= window_days * 86400.0
317 
318 
319def _class_gates(
320 now: datetime,
321 rows: tuple[BumpRow, ...],
322 repos: tuple[str, ...],
323 loops: tuple[Loop, ...],
324 policy: AutonomyPolicy,
325 environment: str,
326) -> tuple[ClassGate, ...]:
327 """The earned-autonomy standing of each (repo, loop) class.
328 
329 Counts only PRs *decided within the window* — trust is recent, not lifetime
330 (§2.11) — *and* earned under the current ``environment`` (§3.7's conditional
331 property): a PR opened under a different judge model no longer counts, so a
332 model swap resets the class. ``prior_env_decided`` keeps that reset legible.
333 The budget figures (approvals / reclaim per week) translate the record into
334 the steward-time MHE meters (§3.6): what the class costs now, and what
335 moving its gate would hand back.
336 """
337 weeks = max(policy.window_days / 7.0, 1.0)
338 gates: list[ClassGate] = []
339 for loop in loops:
340 for repo in repos:
341 in_window = [
342 r
343 for r in rows
344 if r.repo == repo
345 and r.loop == loop.value
346 and r.state in ("merged", "closed")
347 and _within(_decided_at(r), now, policy.window_days)
348 ]
349 # An empty ``environment`` means "don't filter" (none configured);
350 # otherwise only PRs stamped with the current one count.
351 decided_rows = [
352 r for r in in_window if not environment or r.env == environment
353 ]
354 prior_env_decided = len(in_window) - len(decided_rows)
355 merged_rows = [r for r in decided_rows if r.state == "merged"]
356 decided = len(decided_rows)
357 merged = len(merged_rows)
358 # The post-merge defect bearing, per class (§3.8): confirmed-held
359 # outcomes and how many of those went bad. Independent of the rate.
360 determined = sum(
361 1
362 for r in merged_rows
363 if r.post_merge in ("held", "broke", "reverted")
364 )
365 defects = sum(
366 1 for r in merged_rows if r.post_merge in ("broke", "reverted")
367 )
368 earned, blocker = class_earned(
369 decided=decided,
370 merged=merged,
371 determined=determined,
372 defects=defects,
373 policy=policy,
374 )
375 # Reclaim is the budget a gate *move* hands back — so it is zero
376 # until the class has actually earned the move. Counting the
377 # clean-and-green merges of an un-earned class would imply savings
378 # the gate would refuse (every PR stays held at "class not earned").
379 reclaimable = (
380 sum(
381 1
382 for r in merged_rows
383 if r.verdict == "clean" and r.ci == "passed"
384 )
385 if earned
386 else 0
387 )
388 gates.append(
389 ClassGate(
390 repo=repo,
391 loop=loop.value,
392 decided=decided,
393 merged=merged,
394 merge_rate=(merged / decided) if decided else None,
395 determined=determined,
396 defects=defects,
397 defect_rate=(
398 (defects / determined) if determined else None
399 ),
400 prior_env_decided=prior_env_decided,
401 earned=earned,
402 blocker=blocker,
403 approvals_per_week=round(merged / weeks, 2),
404 reclaim_per_week=round(reclaimable / weeks, 2),
405 window_days=policy.window_days,
406 )
407 )
408 return tuple(gates)
409 
410 
411def earned_now(
412 now: datetime,
413 prs: tuple[GithubPr, ...],
414 outcomes: dict[tuple[str, int], str],
415 repo: str,
416 loop: Loop,
417 policy: AutonomyPolicy,
418 environment: str,
419) -> bool:
420 """Whether one (repo, loop) class has earned its gate, from live data.
421 
422 The exact computation the dashboard's class-gate panel shows, exposed so the
423 acting gate (the loop) reuses it rather than duplicating the windowing,
424 environment filter, and triangulation. Pure over already-fetched data.
425 """
426 rows = _bump_rows(now, prs, (), outcomes)
427 gates = _class_gates(now, rows, (repo,), (loop,), policy, environment)
428 return any(g.earned for g in gates)
⋯ 275 lines hidden (lines 429–703)
429 
430 
431def _gate(
432 rows: tuple[BumpRow, ...],
433 gates: tuple[ClassGate, ...],
434 policy: AutonomyPolicy,
435) -> tuple[BumpRow, ...]:
436 """Open PRs awaiting a human, most-aged first, each carrying a verdict.
437 
438 The verdict is the gate's: would this PR auto-merge under its class's grant
439 — the loop's real decision where the repo is allowlisted, advisory (the
440 shadow gate) where it is not. The reason is the grant met, or the first
441 blocker to fix.
442 """
443 earned_by_class = {(g.repo, g.loop): (g.earned, g.blocker) for g in gates}
444 open_rows = [r for r in rows if r.state == "open"]
445 open_rows.sort(
446 key=lambda r: r.age_hours if r.age_hours is not None else 0.0,
447 reverse=True,
448 )
449 annotated: list[BumpRow] = []
450 for row in open_rows:
451 earned, blocker = earned_by_class.get(
452 (row.repo, row.loop), (False, "no record for this class")
453 )
454 verdict = pr_autonomy(
455 repo=row.repo,
456 verdict=row.verdict,
457 ci=row.ci,
458 earned=earned,
459 blocker=blocker,
460 policy=policy,
461 )
462 annotated.append(
463 row.model_copy(
464 update={
465 "would_auto_merge": verdict.would_merge,
466 "held_reason": (
467 None if verdict.would_merge else verdict.reason
468 ),
469 }
470 )
471 )
472 return tuple(annotated)
473 
474 
475def _failures(bumps: tuple[BumpExecution, ...]) -> tuple[Failure, ...]:
476 """Bump loops that did not close, newest first."""
477 failures = [
478 Failure(
479 workflow_id=bump.workflow_id,
480 kind=bump.status,
481 reason=bump.reason,
482 when=bump.close,
483 )
484 for bump in bumps
485 if bump.status in _FAILURE_STATUSES
486 ]
487 failures.sort(
488 key=lambda f: f.when.timestamp() if f.when is not None else 0.0,
489 reverse=True,
490 )
491 return tuple(failures)
492 
493 
494def _review_id(repo: str) -> str | None:
495 """The deterministic review-loop id for an ``owner/name`` slug, if valid."""
496 match RepoRef.parse(repo):
497 case Ok(ref):
498 return review_workflow_id(TargetRepo(repo=ref))
499 case _:
500 return None
501 
502 
503def _pr_review_prefix(repo: str) -> str | None:
504 """The id prefix every per-PR review of ``repo`` shares (the join key)."""
505 review_id = _review_id(repo)
506 if review_id is None:
507 return None
508 # froot-review-<slug> -> froot-pr-review-<slug>- ; the pr/sha tail follows.
509 return "froot-pr-review-" + review_id.removeprefix("froot-review-") + "-"
510 
511 
512def _attribute_repo(workflow_id: str, repos: tuple[str, ...]) -> str | None:
513 """The configured repo a per-PR-review id belongs to (longest prefix)."""
514 best: str | None = None
515 best_len = -1
516 for repo in repos:
517 prefix = _pr_review_prefix(repo)
518 if prefix and workflow_id.startswith(prefix) and len(prefix) > best_len:
519 best, best_len = repo, len(prefix)
520 return best
521 
522 
523def _review_loops(
524 repos: tuple[str, ...], reviews: tuple[ReviewExecution, ...]
525) -> tuple[ReviewLoop, ...]:
526 """One liveness row per repo that actually has a review loop.
527 
528 Reviews are scoped to the Temporal repos, so a configured npm repo with no
529 review loop is omitted rather than shown as a dead one.
530 """
531 by_id: dict[str, ReviewExecution] = {}
532 for review in reviews:
533 current = by_id.get(review.workflow_id)
534 if current is None or _newer(review.start, current.start):
535 by_id[review.workflow_id] = review
536 loops: list[ReviewLoop] = []
537 for repo in repos:
538 review_id = _review_id(repo)
539 execution = by_id.get(review_id) if review_id is not None else None
540 if execution is None:
541 continue
542 loops.append(
543 ReviewLoop(
544 repo=repo,
545 status=execution.status,
546 live=execution.status in _LIVE_STATUSES,
547 last_tick=execution.start,
548 )
549 )
550 return tuple(loops)
551 
552 
553def _review_rows(
554 pr_reviews: tuple[PrReviewExecution, ...], repos: tuple[str, ...]
555) -> tuple[ReviewRow, ...]:
556 """Project each per-PR review into a row, newest review first."""
557 rows: list[ReviewRow] = []
558 for execution in pr_reviews:
559 repo = _attribute_repo(execution.workflow_id, repos)
560 pr_url = (
561 f"https://github.com/{repo}/pull/{execution.pr_number}"
562 if repo is not None and execution.pr_number is not None
563 else None
564 )
565 rows.append(
566 ReviewRow(
567 repo=repo or "?",
568 pr_number=execution.pr_number,
569 pr_url=pr_url,
570 head_sha=execution.head_sha,
571 findings=execution.findings,
572 rules=execution.rules,
573 comment_url=execution.comment_url,
574 status=execution.status,
575 reviewed_at=execution.close or execution.start,
576 )
577 )
578 rows.sort(
579 key=lambda r: r.reviewed_at.timestamp() if r.reviewed_at else 0.0,
580 reverse=True,
581 )
582 return tuple(rows)
583 
584 
585def _review_record(
586 loops: tuple[ReviewLoop, ...], rows: tuple[ReviewRow, ...]
587) -> ReviewRecord:
588 """Counts over the completed reviews (resolved-rate is a later loop)."""
589 completed = [r for r in rows if r.status == "completed"]
590 flagged = sum(1 for r in completed if r.findings > 0)
591 hazards = sum(r.findings for r in completed)
592 return ReviewRecord(
593 reviewed=len(completed),
594 flagged=flagged,
595 clean=len(completed) - flagged,
596 hazards=hazards,
597 repos_covered=len(loops),
598 )
599 
600 
601def _sources(
602 github_error: str | None,
603 github_count: int,
604 temporal_error: str | None,
605 temporal_count: int,
606 telemetry: RunTelemetry,
607 clickhouse_error: str | None,
608) -> tuple[SourceHealth, ...]:
609 """Per-source health for the header strip."""
610 clickhouse_ok = clickhouse_error is None
611 if clickhouse_error == "off":
612 clickhouse_detail = "off"
613 elif clickhouse_error is not None:
614 clickhouse_detail = clickhouse_error
615 else:
616 spans = telemetry.total_spans
617 clickhouse_detail = f"{spans} spans / {telemetry.window_days}d"
618 return (
619 SourceHealth(
620 name="github",
621 ok=github_error is None,
622 detail=github_error or f"{github_count} PRs",
623 ),
624 SourceHealth(
625 name="temporal",
626 ok=temporal_error is None,
627 detail=temporal_error or f"{temporal_count} workflows",
628 ),
629 SourceHealth(
630 name="clickhouse", ok=clickhouse_ok, detail=clickhouse_detail
631 ),
632 )
633 
634 
635def assemble(
636 *,
637 now: datetime,
638 repos: tuple[str, ...],
639 loops: tuple[Loop, ...] = (Loop.DEPENDENCY_PATCH,),
640 policy: AutonomyPolicy = _DEFAULT_POLICY,
641 scan_interval_seconds: int,
642 review_interval_seconds: int,
643 github: tuple[tuple[GithubPr, ...], str | None],
644 temporal: tuple[
645 tuple[
646 tuple[ScanExecution, ...],
647 tuple[BumpExecution, ...],
648 tuple[ReviewExecution, ...],
649 tuple[PrReviewExecution, ...],
650 ],
651 str | None,
652 ],
653 telemetry: tuple[RunTelemetry, str | None],
654 outcomes: dict[tuple[str, int], str] | None = None,
655 reliability_window_days: int = 90,
656 environment: str = "",
657) -> DashboardModel:
658 """Build the whole view from the readers' ``(data, error)`` outputs.
659 
660 ``outcomes`` is the post-merge signal per ``(repo, number)`` from the
661 outcome reader (best-effort); absent for a merge older than the window.
662 """
663 prs, github_error = github
664 (scans, bumps, reviews, pr_reviews), temporal_error = temporal
665 run_telemetry, clickhouse_error = telemetry
666 
667 all_rows = _bump_rows(now, prs, bumps, outcomes or {})
668 # Canary probes are synthetic bad bumps — keep them out of every genuine
669 # bearing (track record, defect rate, gates) so a planted failure can never
670 # pollute the real reputation; they get their own tally.
671 canary_rows = tuple(r for r in all_rows if is_canary(r.to_version))
672 rows = tuple(r for r in all_rows if not is_canary(r.to_version))
673 class_gates = _class_gates(now, rows, repos, loops, policy, environment)
674 review_loops = _review_loops(repos, reviews)
675 review_rows = _review_rows(pr_reviews, repos)
676 return DashboardModel(
677 generated_at=now,
678 repos_configured=repos,
679 scan_interval_seconds=scan_interval_seconds,
680 sources=_sources(
681 github_error,
682 len(prs),
683 temporal_error,
684 len(scans) + len(bumps) + len(reviews) + len(pr_reviews),
685 run_telemetry,
686 clickhouse_error,
687 ),
688 scan_loops=_scan_loops(repos, loops, scans),
689 track_record=_track_record(rows),
690 class_gates=class_gates,
691 verification=_verification(rows),
692 reliability=_reliability(rows, reliability_window_days),
693 probes=_probes(canary_rows),
694 judgment=_judgment(rows),
695 gate=_gate(rows, class_gates, policy),
696 bumps=rows,
697 failures=_failures(bumps),
698 review_interval_seconds=review_interval_seconds,
699 review_loops=review_loops,
700 review_record=_review_record(review_loops, review_rows),
701 reviews=review_rows,
702 telemetry=run_telemetry,
703 )
src/froot/workflow/bump_workflow.py · 213 lines
src/froot/workflow/bump_workflow.py213 lines · Python
⋯ 79 lines hidden (lines 1–79)
1"""The per-bump lifecycle workflow — a thin driver over the pure core.
2 
3One durable workflow per (repo, package, target), keyed by the deterministic
4:func:`~froot.policy.naming.bump_workflow_id`, so a bump is proposed at most
5once. It loops: take the pure state machine's next effect, run it as an activity
6(or, for the CI wait, a durable poll-and-sleep), feed the resulting event back
7to :func:`~froot.policy.state_machine.advance`, and repeat until ``Recorded``.
8All nondeterminism is in the activities; the workflow uses only pure state and
9Temporal's own time APIs, so replay is deterministic.
10"""
11 
12from __future__ import annotations
13 
14from typing import TYPE_CHECKING, assert_never
15 
16from temporalio import workflow
17from temporalio.exceptions import ApplicationError
18 
19with workflow.unsafe.imports_passed_through():
20 from froot.domain.ci import CIStatus, CITimedOut, is_terminal
21 from froot.domain.effects import (
22 AwaitCi,
23 ClosePullRequest,
24 Effect,
25 JudgeChangelog,
26 MergePullRequest,
27 OpenPullRequest,
28 RecordOutcome,
29 ReviewBump,
30 )
31 from froot.domain.events import (
32 ChangelogJudged,
33 CiResolved,
34 GateReviewed,
35 LoopEvent,
36 OutcomeRecorded,
37 PullRequestClosed,
38 PullRequestMerged,
39 PullRequestReady,
40 )
41 from froot.domain.outcome import LoopOutcome
42 from froot.domain.state import Recorded
43 from froot.policy.state_machine import TransitionKind, advance, start
44 from froot.workflow import activities
45 from froot.workflow.constants import (
46 ACTIVITY_TIMEOUT,
47 CI_CHECK_TIMEOUT,
48 CI_POLL_INTERVAL,
49 CI_WAIT_DEADLINE,
50 TOOL_RETRY,
51 )
52 from froot.workflow.types import (
53 AutoMergeInput,
54 BumpParams,
55 CiCheckInput,
56 CloseInput,
57 GateReviewInput,
58 JudgeInput,
59 MergeInput,
60 OpenPrInput,
61 RecordInput,
62 )
63 
64if TYPE_CHECKING:
65 # Used only in the (non-workflow-decorated) helper signatures, so these are
66 # type-only — unlike the run() signature, Temporal does not evaluate them.
67 from froot.domain.loop import Loop
68 from froot.domain.pull_request import PullRequestRef
69 from froot.domain.repo import TargetRepo
70 
71 
72@workflow.defn
73class BumpWorkflow:
74 """The durable loop for a single dependency patch bump."""
75 
76 @workflow.run
77 async def run(self, params: BumpParams) -> LoopOutcome:
78 """Drive the pure state machine to a recorded outcome."""
79 transition = start(params.candidate)
80 # The class-level half of the gate: has this (repo, loop) earned the
81 # auto-merge grant on an allowlisted repo? Decided once, up front (the
82 # grant is about the class's history, not this PR), and cheap by default
83 # — the activity short-circuits to False for any non-allowlisted repo.
84 auto_merge_eligible = await workflow.execute_activity(
85 activities.auto_merge_eligible,
86 AutoMergeInput(target=params.target, loop=params.loop),
⋯ 80 lines hidden (lines 87–166)
87 start_to_close_timeout=ACTIVITY_TIMEOUT,
88 retry_policy=TOOL_RETRY,
89 )
90 while transition.effects:
91 state = transition.next
92 if len(transition.effects) != 1:
93 raise ApplicationError(
94 f"non-linear transition ({len(transition.effects)} "
95 "effects)",
96 non_retryable=True,
97 )
98 event = await self._execute(
99 params.target, params.loop, transition.effects[0]
100 )
101 transition = advance(
102 state,
103 event,
104 close_on_red=params.close_on_red,
105 auto_merge_eligible=auto_merge_eligible,
106 )
107 if transition.kind is TransitionKind.REJECTED:
108 raise ApplicationError(
109 f"rejected transition: {transition.reason}",
110 non_retryable=True,
111 )
112 final = transition.next
113 if not isinstance(final, Recorded):
114 raise ApplicationError(
115 f"loop ended in non-terminal state: {final.kind}",
116 non_retryable=True,
117 )
118 return final.outcome
119 
120 async def _execute(
121 self, target: TargetRepo, loop: Loop, effect: Effect
122 ) -> LoopEvent:
123 """Interpret one effect into an activity (or a durable CI wait).
124 
125 ``loop`` rides in from the workflow's params and is handed to the impure
126 activities (the branch namespace, the labels, the judge's prompt) — so
127 the pure state machine never learns which loop it is running.
128 """
129 match effect:
130 case JudgeChangelog():
131 verdict = await workflow.execute_activity(
132 activities.judge_changelog,
133 JudgeInput(candidate=effect.candidate, loop=loop),
134 start_to_close_timeout=ACTIVITY_TIMEOUT,
135 retry_policy=TOOL_RETRY,
136 )
137 return ChangelogJudged(verdict=verdict)
138 case OpenPullRequest():
139 pr = await workflow.execute_activity(
140 activities.open_pull_request,
141 OpenPrInput(
142 target=target,
143 candidate=effect.candidate,
144 verdict=effect.verdict,
145 loop=loop,
146 ),
147 start_to_close_timeout=ACTIVITY_TIMEOUT,
148 retry_policy=TOOL_RETRY,
149 )
150 return PullRequestReady(pr=pr)
151 case AwaitCi():
152 status = await self._await_ci(target, effect.pr)
153 return CiResolved(status=status)
154 case ClosePullRequest():
155 await workflow.execute_activity(
156 activities.close_pull_request,
157 CloseInput(
158 target=target,
159 pr=effect.pr,
160 failing=effect.failing,
161 loop=loop,
162 ),
163 start_to_close_timeout=CI_CHECK_TIMEOUT,
164 retry_policy=TOOL_RETRY,
165 )
166 return PullRequestClosed()
167 case ReviewBump():
168 verdict = await workflow.execute_activity(
169 activities.gate_review,
170 GateReviewInput(
171 candidate=effect.candidate, pr=effect.pr, loop=loop
172 ),
173 start_to_close_timeout=ACTIVITY_TIMEOUT,
174 retry_policy=TOOL_RETRY,
175 )
176 return GateReviewed(verdict=verdict)
177 case MergePullRequest():
178 await workflow.execute_activity(
179 activities.merge_pull_request,
180 MergeInput(target=target, pr=effect.pr, loop=loop),
181 start_to_close_timeout=CI_CHECK_TIMEOUT,
182 retry_policy=TOOL_RETRY,
183 )
184 return PullRequestMerged()
185 case RecordOutcome():
⋯ 28 lines hidden (lines 186–213)
186 await workflow.execute_activity(
187 activities.record_outcome,
188 RecordInput(
189 target=target, outcome=effect.outcome, loop=loop
190 ),
191 start_to_close_timeout=CI_CHECK_TIMEOUT,
192 retry_policy=TOOL_RETRY,
193 )
194 return OutcomeRecorded()
195 assert_never(effect)
196 
197 async def _await_ci(
198 self, target: TargetRepo, pr: PullRequestRef
199 ) -> CIStatus:
200 """Durably poll the repo's CI until it resolves or the deadline."""
201 deadline = workflow.now() + CI_WAIT_DEADLINE
202 while True:
203 status = await workflow.execute_activity(
204 activities.check_ci,
205 CiCheckInput(target=target, head_sha=pr.head_sha),
206 start_to_close_timeout=CI_CHECK_TIMEOUT,
207 retry_policy=TOOL_RETRY,
208 )
209 if is_terminal(status):
210 return status
211 if workflow.now() >= deadline:
212 return CITimedOut()
213 await workflow.sleep(CI_POLL_INTERVAL)

Bearings 1 & 2 — rate and post-merge defect rate

class_earned is the gate's arithmetic: it triangulates two independent bearings. The approval rate over enough decided PRs (track record), and the post-merge defect rate over enough confirmed-held merges (did reality punish the merge). They fail differently — rate to rubber-stamping, defect rate to a weak oracle — so a class earns only when both clear with evidence behind each. Checks run cheapest-first; the first to fail is the blocker to fix.

src/froot/policy/autonomy.py · 168 lines
src/froot/policy/autonomy.py168 lines · Python
⋯ 83 lines hidden (lines 1–83)
1"""The earned-autonomy policy — the gate's logic, now load-bearing.
2 
3The pure logic that says, per the Many Hands Engineering trust economy
4(§3.6-3.7), whether a *class* of work (a loop, on a repo) has earned its gate
5move, and whether a given PR auto-merges under that grant. The acting gate
6(Phase 4) reuses these same functions: on an **allowlisted** repo, a class that
7clears them has its clean+green bumps merged by the loop itself. Everywhere else
8— the default, the allowlist empty — the very same verdict stays advisory and
9the dashboard renders it as the *shadow gate* a steward watches before opting a
10repo in. Pure throughout; nothing here mutates anything (the merge is an effect
11the spine runs only once every condition, allowlist included, is met).
12 
13The grant has MHE's five properties. **Earned**: a class earns its gate by a
14track record — a high enough *approval rate* over enough *decided* PRs.
15**Narrow**: the class is one loop on one repo; dependency-patch and
16security-patch are separate trust classes (§3.9), never sharing a record.
17**Conditional**: a PR rides the grant only if its own changelog read clean and
18its CI went green. **Revocable**: the grant is gated behind an explicit
19per-repo allowlist a steward controls, and lapses on its own when the rate
20falls. **Expiring**: the rate is measured over a recent window, not all of
21history (§2.11) — a class that has not acted lately starts from a lower
22baseline.
23 
24MHE is blunt that the approval rate is the headline, *not the whole truth* —
25"track record alone can lie" (§3.7). So the gate triangulates it (§3.8): a class
26earns only when the rate **and** the post-merge **defect rate** both clear,
27each with enough evidence behind it — two bearings that fail independently and
28cannot both lie at once. Two further legs MHE names — *sampled deep review* and
29*adversarial probing* — strengthen the triangle and arrive in later slices; the
30*environment*-reset (a judge-model swap or refactor resets the record, §3.7's
31fuller "conditional") rides alongside them.
32"""
33 
34from __future__ import annotations
35 
36from froot.domain.base import Frozen
37 
38 
39class AutonomyPolicy(Frozen):
40 """The thresholds a class must clear to earn its gate move.
41 
42 Attributes:
43 min_rate: The approval (merge) rate a class needs over the window —
44 the first bearing (track record). High alone is a *smell*, not
45 proof (a 95% gate "is rubber-stamping", §2.10), so it never earns
46 the gate by itself.
47 min_decided: How many PRs must have been decided in the window before
48 the rate means anything — a 100% rate on one PR is not a record.
49 window_days: The look-back window; trust is recent, not lifetime.
50 min_determined: How many merges must have a *confirmed* post-merge
51 outcome (held / broke / reverted) before the defect bearing counts
52 — the rate can lie, so the outcome bearing needs evidence too
53 (§3.7). Until then the class is not earned.
54 max_defect_rate: The ceiling on the post-merge defect rate — the second,
55 independent bearing (§3.8: a target needs ≥2 metrics that gaming
56 would harm). Zero by default: one confirmed merge that broke or was
57 reverted blocks the gate until it ages out of the window.
58 allowlisted_repos: The repos a steward has opted into auto-merge for.
59 Empty by default — the revocable switch, off until trust is granted.
60 """
61 
62 min_rate: float = 0.95
63 min_decided: int = 5
64 window_days: int = 90
65 min_determined: int = 3
66 max_defect_rate: float = 0.0
67 allowlisted_repos: frozenset[str] = frozenset()
68 
69 
70class AutonomyVerdict(Frozen):
71 """Whether a PR would auto-merge under the grant, and the reason either way.
72 
73 Attributes:
74 would_merge: True iff every condition is met, the allowlist included —
75 so on an allowlisted repo this is the loop's actual merge decision,
76 and elsewhere (the default) the advisory shadow-gate verdict.
77 reason: The deciding factor — the grant met, or the first blocker.
78 """
79 
80 would_merge: bool
81 reason: str
82 
83 
84def class_earned(
85 *,
86 decided: int,
87 merged: int,
88 determined: int,
89 defects: int,
90 policy: AutonomyPolicy,
91) -> tuple[bool, str | None]:
92 """Whether a class has earned its gate move, and why not if it hasn't.
93 
94 Triangulates two *independent* bearings (§3.8): the approval **rate** (did
95 the steward say yes) and the post-merge **defect rate** (did reality punish
96 the merge). They fail differently — the rate to rubber-stamping, the defect
97 rate to a weak oracle — so a class earns only when both clear *and* there is
98 enough evidence behind each. Checks run cheapest-first, and the first to
99 fail is the blocker, so the reason is the thing to fix.
100 
101 Args:
102 decided: PRs of this class decided (merged or closed) in the window.
103 merged: How many of those were merged.
104 determined: Merges with a confirmed post-merge outcome in the window
105 (held / broke / reverted) — the defect bearing's evidence.
106 defects: Of those, how many broke the branch or were reverted.
107 policy: The thresholds.
108 
109 Returns:
110 ``(earned, blocker)`` — ``blocker`` is ``None`` when earned, else the
111 short reason the gate has not moved.
112 """
113 if decided < policy.min_decided or decided == 0:
114 return False, f"only {decided}/{policy.min_decided} decided recently"
115 rate = merged / decided
116 if rate < policy.min_rate:
117 return False, (f"approval rate {rate:.0%} < {policy.min_rate:.0%}")
118 if determined < policy.min_determined:
119 return False, (
120 f"only {determined}/{policy.min_determined} merges confirmed held"
121 )
122 defect_rate = defects / determined
123 if defect_rate > policy.max_defect_rate:
⋯ 45 lines hidden (lines 124–168)
124 return False, (
125 f"defect rate {defect_rate:.0%} > {policy.max_defect_rate:.0%}"
126 )
127 return True, None
128 
129 
130def pr_autonomy(
131 *,
132 repo: str,
133 verdict: str | None,
134 ci: str | None,
135 earned: bool,
136 blocker: str | None,
137 policy: AutonomyPolicy,
138) -> AutonomyVerdict:
139 """Whether one open PR would auto-merge under its class's grant.
140 
141 Reports the *substantive* blockers first — the earned grant, then this PR's
142 own clean-and-green conditions — and the steward's own revocable switch
143 (the allowlist) *last*. That ordering is what makes the shadow gate
144 watchable in its default, allowlist-off state: a held PR shows the real
145 thing to fix (``CI pending`` / ``class not earned``), and the bare
146 ``auto-merge not enabled`` reason appears only once a PR is otherwise fully
147 ready — i.e. exactly when flipping the switch would change the outcome.
148 ``would_merge`` still requires *every* condition, the allowlist included.
149 """
150 if not earned:
151 return AutonomyVerdict(
152 would_merge=False, reason=f"class not earned ({blocker})"
153 )
154 if verdict != "clean":
155 return AutonomyVerdict(
156 would_merge=False, reason=f"verdict is {verdict or 'unknown'}"
157 )
158 if ci != "passed":
159 return AutonomyVerdict(
160 would_merge=False, reason=f"CI {ci or 'pending'}"
161 )
162 if repo not in policy.allowlisted_repos:
163 return AutonomyVerdict(
164 would_merge=False, reason="auto-merge not enabled for this repo"
165 )
166 return AutonomyVerdict(
167 would_merge=True, reason="clean + green on an earned class"
168 )

The outcome detector — did the merge hold? (P4.1)

The defect bearing needs an oracle for what happened after a merge. fetch_outcomes reads the default branch's recent commits to match froot's (#N) squash tail to its merge commit and spot Revert commits, then reads each merge commit's check-runs. Deliberately coarse and low-recall: a manual or bundled revert is invisible, so the defect rate is a floor, not the truth — the adversarial leg is what exercises the gap.

src/froot/dashboard/github_source.py · 355 lines
src/froot/dashboard/github_source.py355 lines · Python
⋯ 189 lines hidden (lines 1–189)
1"""GitHub reader: froot's PRs as the durable outcome ledger.
2 
3One issues-list call per repo (PRs are issues) filtered to the ``froot`` label
4returns every proposed bump with its outcome — state, timestamps, and the
5deterministic title/body froot writes (``compose.py``). Package + target parse
6from the title (``deps: bump <pkg> to <ver>``); the from-version and the model's
7changelog verdict parse from the body, so the judgment survives even after the
8bump ages out of Temporal's window. Read-only: the dashboard needs no write
9scope (it reuses ``FROOT_GITHUB_TOKEN`` only because it is already in the pod).
10 
11The parsers are pure and unit-tested apart from the network; the HTTP shape is
12read at the boundary as untyped JSON and coerced here.
13"""
14 
15from __future__ import annotations
16 
17import re
18from datetime import UTC, datetime
19from typing import Any, Final
20 
21import httpx
22 
23from froot.config.settings import GitHubSettings
24from froot.domain.base import Frozen
25from froot.domain.loop import Loop
26from froot.policy.environment import env_from_labels
27 
28_API: Final = "https://api.github.com"
29_API_VERSION: Final = "2022-11-28"
30_TIMEOUT: Final = 15.0
31_LABEL: Final = "froot"
32_PER_PAGE: Final = 100
33 
34_TITLE_RE: Final = re.compile(r"^deps: bump (?P<pkg>.+) to (?P<ver>\S+)\s*$")
35_FROM_RE: Final = re.compile(r"from (?P<from>\S+) to (?P<to>\S+)")
36# froot's squash merges leave a ``(#N)`` tail on the default-branch commit, so a
37# merged PR can be matched to its merge commit without a per-PR API call.
38_PR_REF_RE: Final = re.compile(r"\(#(\d+)\)")
39 
40# Check-run conclusions, split into "the branch held" vs "the branch broke". A
41# commit with no check runs at all is neither — it is ``unknown`` (no oracle on
42# the branch), never silently counted as a pass.
43_OK_CONCLUSIONS: Final = frozenset({"success", "neutral", "skipped", "stale"})
44_BAD_CONCLUSIONS: Final = frozenset(
45 {"failure", "cancelled", "timed_out", "action_required", "startup_failure"}
47 
48 
49class GithubPr(Frozen):
50 """A froot pull request, reduced to what the read-model needs."""
51 
52 repo: str
53 number: int
54 url: str
55 # The loop label (``dependency-patch`` | ``security-patch``); defaults to
56 # dependency-patch so a PR with no loop label (or a hand-built test row)
57 # attributes to the original loop rather than failing to construct.
58 loop: str = "dependency-patch"
59 # The judgment environment (judge-model slug) the PR was opened under, from
60 # its ``froot-env:`` label; ``None`` if unstamped (a prior environment).
61 env: str | None = None
62 package: str | None
63 from_version: str | None
64 to_version: str | None
65 verdict: str | None
66 state: str # open | merged | closed
67 opened_at: datetime | None
68 merged_at: datetime | None
69 
70 
71def _label_names(payload: Any) -> set[str]:
72 """The set of label names on an issues-API row (empty if none/malformed)."""
73 labels = payload.get("labels")
74 if not isinstance(labels, list):
75 return set()
76 return {
77 name
78 for lbl in labels
79 if isinstance(lbl, dict) and isinstance((name := lbl.get("name")), str)
80 }
81 
82 
83def _loop_from_labels(names: set[str]) -> str:
84 """The loop a PR belongs to, from its labels (defaults dependency-patch).
85 
86 Every froot PR carries its loop's label alongside the ``froot`` label, so
87 the loop is durable on the PR even after its Temporal run ages out.
88 """
89 for loop in Loop:
90 if loop.value in names:
91 return loop.value
92 return Loop.DEPENDENCY_PATCH.value
93 
94 
95def parse_title(title: str) -> tuple[str, str] | None:
96 """Parse ``deps: bump <pkg> to <ver>`` into ``(package, target)`` (pure)."""
97 match = _TITLE_RE.match(title.strip())
98 if match is None:
99 return None
100 return match.group("pkg"), match.group("ver")
101 
102 
103def parse_from_version(body: str | None) -> str | None:
104 """Pull the from-version out of froot's PR body template (pure)."""
105 if not body:
106 return None
107 match = _FROM_RE.search(body)
108 return match.group("from") if match else None
109 
110 
111def parse_verdict(body: str | None) -> str | None:
112 """Recover the changelog verdict from froot's body template (pure).
113 
114 Mirrors ``policy/compose.py``'s ``_verdict_summary`` openers, so an old PR
115 whose Temporal run has aged out still shows the judgment it carried.
116 """
117 if not body:
118 return None
119 if "Changelog reads clean" in body:
120 return "clean"
121 if "Review carefully" in body:
122 return "risky"
123 if "Changelog unavailable" in body:
124 return "unknown"
125 return None
126 
127 
128def _parse_dt(value: Any) -> datetime | None:
129 """Coerce a GitHub ISO-8601 timestamp into an *aware* datetime (boundary).
130 
131 GitHub stamps everything ``...Z`` (UTC), which ``fromisoformat`` parses as
132 aware — but a missing/odd offset would yield a naive datetime, and the
133 read-model subtracts these against an aware ``now`` (a naive operand raises
134 ``TypeError`` and would blank the whole page). So any naive result is
135 pinned to UTC here, at the one boundary, the way the other readers do.
136 """
137 if not isinstance(value, str) or not value:
138 return None
139 try:
140 parsed = datetime.fromisoformat(value)
141 except ValueError:
142 return None
143 return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
144 
145 
146def _to_pr(repo: str, payload: Any) -> GithubPr | None:
147 """Coerce one issues-API row into a :class:`GithubPr`, or skip non-PRs."""
148 if not isinstance(payload, dict):
149 return None
150 pull_request = payload.get("pull_request")
151 if not isinstance(pull_request, dict):
152 return None # a plain issue, not a PR
153 merged_at = _parse_dt(pull_request.get("merged_at"))
154 if merged_at is not None:
155 state = "merged"
156 elif payload.get("state") == "open":
157 state = "open"
158 else:
159 state = "closed"
160 parsed = parse_title(str(payload.get("title", "")))
161 package, to_version = parsed if parsed is not None else (None, None)
162 body = payload.get("body")
163 names = _label_names(payload)
164 return GithubPr(
165 repo=repo,
166 number=int(payload["number"]),
167 url=str(payload.get("html_url", "")),
168 loop=_loop_from_labels(names),
169 env=env_from_labels(names),
170 package=package,
171 from_version=parse_from_version(body),
172 to_version=to_version,
173 verdict=parse_verdict(body),
174 state=state,
175 opened_at=_parse_dt(payload.get("created_at")),
176 merged_at=merged_at,
177 )
178 
179 
180def _commit_pr_refs(message: str) -> list[int]:
181 """PR numbers referenced as ``(#N)`` in a commit message (pure)."""
182 return [int(ref) for ref in _PR_REF_RE.findall(message)]
183 
184 
185def _is_revert(message: str) -> bool:
186 """True if a commit message reads as a git revert (``Revert "..."``)."""
187 return message.lstrip().lower().startswith("revert")
188 
189 
190def classify_check_runs(payload: Any) -> str:
191 """Reduce a commit's check-runs to held / broke / unknown (pure boundary).
192 
193 ``broke`` if any run reported a failing conclusion; ``held`` if runs exist
194 and none failed; ``unknown`` if no check ran on the commit — i.e. there is
195 no oracle on the branch, which is never conflated with a pass (the same
196 discipline the verification panel keeps for ``absent`` CI).
197 """
198 runs = payload.get("check_runs") if isinstance(payload, dict) else None
199 if not isinstance(runs, list) or not runs:
200 return "unknown"
201 conclusions = {r.get("conclusion") for r in runs if isinstance(r, dict)}
202 if conclusions & _BAD_CONCLUSIONS:
203 return "broke"
204 if conclusions & _OK_CONCLUSIONS:
205 return "held"
206 return "unknown"
207 
⋯ 32 lines hidden (lines 208–239)
208 
209def _merge_index(
210 commits: Any, merged_numbers: frozenset[int]
211) -> tuple[dict[int, str], set[int]]:
212 """From a default-branch commit list, map PR# → merge SHA and the reverted.
213 
214 Commits arrive newest-first, so the first commit referencing ``(#N)`` is the
215 merge; a ``Revert`` commit's refs mark those PRs reverted instead. Only
216 froot's own merged numbers are considered, so an unrelated ``(#N)`` can't
217 cross-attribute. Pure over already-fetched JSON.
218 """
219 merge_sha: dict[int, str] = {}
220 reverted: set[int] = set()
221 if not isinstance(commits, list):
222 return merge_sha, reverted
223 for entry in commits:
224 if not isinstance(entry, dict):
225 continue
226 sha = entry.get("sha")
227 commit = entry.get("commit")
228 message = commit.get("message", "") if isinstance(commit, dict) else ""
229 refs = [n for n in _commit_pr_refs(message) if n in merged_numbers]
230 if not refs:
231 continue
232 if _is_revert(message):
233 reverted.update(refs)
234 elif isinstance(sha, str):
235 for number in refs:
236 merge_sha.setdefault(number, sha)
237 return merge_sha, reverted
238 
239 
240async def fetch_outcomes(
241 repos: tuple[str, ...],
242 prs: tuple[GithubPr, ...],
243 *,
244 now: datetime,
245 window_days: int,
246) -> tuple[dict[tuple[str, int], str], str | None]:
247 """Best-effort post-merge outcome per recently-merged PR; never raises.
248 
249 Per repo: read the default branch's recent commits once (to match froot's
250 ``(#N)`` squash tail to the merge commit and spot ``Revert`` commits), then
251 read each merge commit's check-runs to see whether the branch's CI held.
252 Returns ``{(repo, number): "held"|"broke"|"reverted"|"unknown"}``.
253 
254 Deliberately coarse and low-recall: a *manual* or *bundled* revert is
255 invisible here (most are — git-reverts are the minority), and a merge older
256 than one commit page falls to ``unknown`` rather than a false ``held``. This
257 is the natural-traffic floor; the adversarial canary leg is what exercises
258 it. ``(prs, error)``-style: degrades to a reason, keeping partial results.
259 """
260 token = GitHubSettings().github_token
261 if token is None:
262 return {}, "FROOT_GITHUB_TOKEN unset"
263 headers = {
264 "Authorization": f"Bearer {token.get_secret_value()}",
265 "Accept": "application/vnd.github+json",
266 "X-GitHub-Api-Version": _API_VERSION,
267 }
268 horizon = window_days * 86400.0
269 outcomes: dict[tuple[str, int], str] = {}
270 try:
271 async with httpx.AsyncClient(
272 base_url=_API, timeout=_TIMEOUT, headers=headers
273 ) as client:
274 for repo in repos:
275 merged = [
276 pr
277 for pr in prs
278 if pr.repo == repo
279 and pr.state == "merged"
280 and pr.merged_at is not None
281 and (now - pr.merged_at).total_seconds() <= horizon
282 ]
283 if not merged:
284 continue
285 resp = await client.get(
286 f"/repos/{repo}/commits", params={"per_page": _PER_PAGE}
287 )
288 resp.raise_for_status()
289 merge_sha, reverted = _merge_index(
290 resp.json(), frozenset(pr.number for pr in merged)
291 )
292 for pr in merged:
293 if pr.number in reverted:
294 outcomes[(repo, pr.number)] = "reverted"
295 continue
296 sha = merge_sha.get(pr.number)
297 if sha is None:
298 outcomes[(repo, pr.number)] = "unknown"
299 continue
300 checks = await client.get(
301 f"/repos/{repo}/commits/{sha}/check-runs"
302 )
303 checks.raise_for_status()
304 outcomes[(repo, pr.number)] = classify_check_runs(
305 checks.json()
306 )
307 except Exception as exc: # never raise into gather — degrade to an error
308 return outcomes, f"{type(exc).__name__}: {exc}"
309 return outcomes, None
310 
311 
⋯ 44 lines hidden (lines 312–355)
312async def fetch(
313 repos: tuple[str, ...],
314) -> tuple[tuple[GithubPr, ...], str | None]:
315 """Read every froot PR across ``repos``; never raises.
316 
317 Returns:
318 ``(prs, error)`` — ``error`` is ``None`` on success, else a short
319 reason and ``prs`` is whatever was gathered before the failure.
320 """
321 token = GitHubSettings().github_token
322 if token is None:
323 return (), "FROOT_GITHUB_TOKEN unset"
324 headers = {
325 "Authorization": f"Bearer {token.get_secret_value()}",
326 "Accept": "application/vnd.github+json",
327 "X-GitHub-Api-Version": _API_VERSION,
328 }
329 prs: list[GithubPr] = []
330 try:
331 async with httpx.AsyncClient(
332 base_url=_API, timeout=_TIMEOUT, headers=headers
333 ) as client:
334 for repo in repos:
335 resp = await client.get(
336 f"/repos/{repo}/issues",
337 params={
338 "labels": _LABEL,
339 "state": "all",
340 "per_page": _PER_PAGE,
341 "sort": "created",
342 "direction": "desc",
343 },
344 )
345 resp.raise_for_status()
346 rows = resp.json()
347 if isinstance(rows, list):
348 prs.extend(
349 pr
350 for row in rows
351 if (pr := _to_pr(repo, row)) is not None
352 )
353 except Exception as exc: # never raise into gather — degrade to an error
354 return tuple(prs), f"{type(exc).__name__}: {exc}"
355 return tuple(prs), None

Bearing 3 — the adversarial gate self-test (P4.2)

The rate and defect bearings need volume to mean anything; froot's per-class volume is tiny. A probe needs none — it is informative at N=1. gate_probe is the §2.11 deliberate disturbance aimed at the gate's new risk surface: a battery of synthetic class histories a healthy gate MUST refuse, each engineered to fail a different threshold, scored against the live policy. Any it would grant is an escape — the alarm. It catches a gate loosened by config drift (§3.7 silent drift) the moment it happens.

src/froot/policy/gate_probe.py · 111 lines
src/froot/policy/gate_probe.py111 lines · Python
1"""The adversarial gate self-test — would the live gate refuse a bad class?
2 
3The acting flip (Phase 4.3c) lets an earned class auto-merge its own clean+green
4bumps. Its risk surface is a gate that *grants when it must not* — through a
5code regression, or (the insidious one) a production **config** loosening a
6steward makes by hand that no CI test ever sees: drop the ``MIN_RATE`` floor or
7raise the ``MAX_DEFECT_RATE`` ceiling (the ``FROOT_AUTOMERGE_*`` env) and the
8gate quietly starts trusting classes it shouldn't. That is §3.7's "silent
9drift": trust comes apart without a single visible failure.
10 
11This is the §2.11 deliberate disturbance aimed straight at that surface — the
12third trust leg, adversarial probing. A battery of synthetic class histories
13that a *healthy* gate must refuse, each engineered to fail a different
14threshold, run against the **live** policy. Any scenario the gate would grant is
15an escape: the alarm. It needs no volume (informative at N=1, unlike the rate
16and defect bearings) and runs every tick, so a loosened gate is caught the
17moment config drifts — not after a bad bump has merged.
18 
19Pure: scenarios and a policy in, the names that escaped out. The schedule and
20the structured alarm are the impure wiring (the scan tick + the activity log).
21"""
22 
23from __future__ import annotations
24 
25from typing import TYPE_CHECKING, Final
26 
27from froot.domain.base import Frozen
28from froot.policy.autonomy import class_earned
29 
30if TYPE_CHECKING:
31 from froot.policy.autonomy import AutonomyPolicy
32 
33 
34class GateScenario(Frozen):
35 """One synthetic class history a healthy gate must never auto-merge-grant.
36 
37 The same shape :func:`~froot.policy.autonomy.class_earned` reads — a class's
38 windowed record — so the probe exercises the *real* gate, not a model of it.
39 
40 Attributes:
41 name: What makes this history untrustworthy (the threshold it probes).
42 decided: PRs decided in the window.
43 merged: How many were merged (the rate's numerator).
44 determined: Merges with a confirmed post-merge outcome.
45 defects: Of those, how many broke or were reverted.
46 """
47 
48 name: str
49 decided: int
50 merged: int
51 determined: int
52 defects: int
53 
54 
55# The battery. Each scenario clears every threshold *but one*, so each guards a
56# distinct knob against being loosened: evidence, rate, confirmation, defects.
57KNOWN_BAD: Final[tuple[GateScenario, ...]] = (
58 # No record at all — the cold-start floor (zero evidence).
59 GateScenario(
60 name="no record", decided=0, merged=0, determined=0, defects=0
61 ),
62 # A perfect but thin record — a 100% rate on too few PRs is not a record.
63 GateScenario(
64 name="thin record", decided=1, merged=1, determined=1, defects=0
65 ),
66 # Plenty of volume, but the steward rejects half — a poor track record.
67 GateScenario(
68 name="low approval rate",
69 decided=20,
70 merged=10,
71 determined=10,
72 defects=0,
73 ),
74 # A spotless rate, but almost no merge has a *confirmed* outcome yet — the
75 # defect bearing has no evidence behind it.
76 GateScenario(
77 name="unconfirmed merges",
78 decided=20,
79 merged=20,
80 determined=1,
81 defects=0,
82 ),
83 # Everything clears except reality: one confirmed merge broke or reverted.
84 GateScenario(
85 name="a defect on record",
86 decided=20,
87 merged=20,
88 determined=10,
89 defects=1,
90 ),
92 
93 
94def gate_escapes(policy: AutonomyPolicy) -> tuple[str, ...]:
95 """Names of known-bad scenarios the live gate would *wrongly* grant.
96 
97 Empty is healthy: every deliberately-bad class is refused. Non-empty is the
98 alarm — the gate has loosened (in code or, more likely, in config) enough to
99 trust a class it must not. Pure over the supplied policy.
100 """
101 return tuple(
102 s.name
103 for s in KNOWN_BAD
104 if class_earned(
105 decided=s.decided,
106 merged=s.merged,
107 determined=s.determined,
108 defects=s.defects,
109 policy=policy,
110 )[0]
111 )

The gate_selftest activity runs the battery against the live AutonomySettings every scan tick, logging healthy at INFO (a heartbeat) and any escape at ERROR (surfaces in ClickStack). Repo-independent and cheap.

src/froot/workflow/activities.py · 636 lines
src/froot/workflow/activities.py636 lines · Python
⋯ 511 lines hidden (lines 1–511)
1"""Activities: the effect interpreters that wrap the adapters.
2 
3Each activity is the impure boundary for one effect. Adapter imports are LAZY
4(inside the bodies) so the model and HTTP stacks never enter a workflow sandbox;
5the pure policy and domain imports stay at module level. Activities return
6domain values — the workflow wraps them into events and feeds the pure state
7machine. The activity signatures' types are evaluated at runtime by Temporal's
8data converter, so the domain imports here are deliberately not deferred.
9"""
10 
11from __future__ import annotations
12 
13import json
14import logging
15import tempfile
16from pathlib import Path
17from typing import TYPE_CHECKING, assert_never
18 
19from temporalio import activity
20 
21from froot.domain.candidate import Candidate
22from froot.domain.changelog import ChangelogVerdict, UnknownVerdict
23from froot.domain.ci import CIStatus
24from froot.domain.determinism import AnalysisResult, FrontierVerdict
25from froot.domain.loop import Loop
26from froot.domain.pull_request import PullRequestRef
27from froot.domain.repo import TargetRepo
28from froot.policy.compose import pr_labels, pull_request_draft
29from froot.policy.determinism import analyze_workflow_surface
30from froot.policy.naming import (
31 branch_name,
32 bump_workflow_id,
33 pr_review_workflow_id,
35from froot.policy.review_comment import REVIEW_MARKER, render_review_comment
36from froot.workflow.types import (
37 AdjudicateInput,
38 AutoMergeInput,
39 CiCheckInput,
40 CloseInput,
41 DispatchInput,
42 DispatchReviewInput,
43 GateReviewInput,
44 GateSelfTestInput,
45 JudgeInput,
46 MergeInput,
47 OpenPrInput,
48 PostReviewInput,
49 PrReviewParams,
50 ReconcileInput,
51 RecordInput,
52 ScanCandidatesInput,
54 
55if TYPE_CHECKING:
56 from froot.ports.protocols import PackageManager
57 
58_log = logging.getLogger("froot.outcome")
59_review_log = logging.getLogger("froot.review")
60_reconcile_log = logging.getLogger("froot.reconcile")
61_scan_log = logging.getLogger("froot.scan")
62_gate_log = logging.getLogger("froot.gate")
63 
64 
65def _manifest_dir(target: TargetRepo, workspace: Path) -> Path:
66 """The directory the manifest lives in (a monorepo subdir, or the root)."""
67 return workspace / target.manifest_dir if target.manifest_dir else workspace
68 
69 
70async def _select_candidates(
71 loop: Loop,
72 target: TargetRepo,
73 package_manager: PackageManager,
74 manifest_dir: Path,
75) -> tuple[int, tuple[Candidate, ...]]:
76 """Gather this loop's signal from the checkout and select its candidates.
77 
78 The one genuinely per-loop seam: dependency-patch reads the available
79 upgrades and picks the highest patch; security-patch reads the installed,
80 asks OSV for advisories, and picks the lowest version clearing each. The
81 impure sources are lazy-imported per arm so neither drags the other's stack
82 into a sandbox. Both feed a pure selection policy.
83 
84 Returns ``(considered, candidates)`` — ``considered`` is the size of the
85 upstream signal (available upgrades / advisories found) so the scan can make
86 its selectivity legible (how much was seen versus how much was kept).
87 """
88 match loop:
89 case Loop.DEPENDENCY_PATCH:
90 from froot.policy.candidates import select_patch_candidates
91 
92 upgrades = await package_manager.list_upgrades(target, manifest_dir)
93 return len(upgrades), select_patch_candidates(upgrades)
94 case Loop.SECURITY_PATCH:
95 return await _select_security_candidates(
96 target, package_manager, manifest_dir
97 )
98 assert_never(loop)
99 
100 
101async def _select_security_candidates(
102 target: TargetRepo, package_manager: PackageManager, manifest_dir: Path
103) -> tuple[int, tuple[Candidate, ...]]:
104 """Security signal: installed set, OSV advisories, clearing targets.
105 
106 ``considered`` is the count of advisories OSV returned for the installed
107 set — the vulnerabilities in scope this tick, before selection narrows to
108 the ones a forward-stable bump can actually clear.
109 """
110 from froot.adapters.osv import OsvAdvisorySource
111 from froot.policy.candidates import select_security_candidates
112 
113 installed = await package_manager.list_installed(target, manifest_dir)
114 advisories = await OsvAdvisorySource().advisories(installed)
115 return len(advisories), select_security_candidates(installed, advisories)
116 
117 
118@activity.defn
119async def scan_candidates(
120 params: ScanCandidatesInput,
121) -> tuple[Candidate, ...]:
122 """Check out the repo and select this loop's candidates.
123 
124 Emits the tick's selectivity — how much upstream signal was considered
125 versus how much was kept — as span attributes (when ``FROOT_OTEL`` is on)
126 and as a structured ``scan_tick`` log, so the signal stage is legible in the
127 run ledger even on a tick that proposes nothing.
128 """
129 from froot.adapters.github import GitHubForge
130 from froot.adapters.registry import package_manager_for
131 from froot.adapters.telemetry import set_span_attributes
132 
133 forge = GitHubForge()
134 package_manager = package_manager_for(params.target.ecosystem)
135 with tempfile.TemporaryDirectory() as tmp:
136 workspace = Path(tmp)
137 await forge.checkout(params.target, workspace)
138 considered, candidates = await _select_candidates(
139 params.loop,
140 params.target,
141 package_manager,
142 _manifest_dir(params.target, workspace),
143 )
144 selected = len(candidates)
145 dropped = max(considered - selected, 0)
146 set_span_attributes(
147 scan_loop=params.loop.value,
148 scan_repo=params.target.repo.slug,
149 scan_considered=considered,
150 scan_selected=selected,
151 scan_dropped=dropped,
152 )
153 _scan_log.info(
154 json.dumps(
155 {
156 "event": "scan_tick",
157 "loop": params.loop.value,
158 "repo": params.target.repo.slug,
159 "considered": considered,
160 "selected": selected,
161 "dropped": dropped,
162 }
163 )
164 )
165 return candidates
166 
167 
168@activity.defn
169async def judge_changelog(params: JudgeInput) -> ChangelogVerdict:
170 """Fetch the candidate's changelog and get the model's typed verdict.
171 
172 The model is froot's one thin, non-load-bearing judgment: a clean verdict
173 never *gates* a PR (CI is the oracle), so a model that is down, slow, or
174 erroring must not stall the spine. A judge failure degrades to
175 ``UnknownVerdict`` — the bump proceeds, the human still gets the PR, and the
176 dashboard records the verdict as unknown — rather than failing (and then
177 retrying) the activity. Only the model call is guarded; the fetch is already
178 best-effort (returns ``None``, not an exception). The loop selects what the
179 model is asked (clean-patch vs breaking-change-on-a-security-bump).
180 """
181 from froot.adapters.changelog_http import HttpChangelogSource
182 from froot.adapters.model_judge import PydanticAiJudge
183 
184 changelog = await HttpChangelogSource().fetch(params.candidate)
185 if changelog is None:
186 return UnknownVerdict(rationale="No changelog could be fetched.")
187 try:
188 return await PydanticAiJudge().judge(changelog, params.loop)
189 except Exception as exc:
190 activity.logger.warning(
191 "changelog judge unavailable for %s; degrading to unknown: %r",
192 params.candidate.package,
193 exc,
194 )
195 return UnknownVerdict(
196 rationale=f"Changelog judge unavailable ({type(exc).__name__})."
197 )
198 
199 
200@activity.defn
201async def gate_review(params: GateReviewInput) -> ChangelogVerdict:
202 """Independently deep-review a bump at the gate; fail-closed to a hold.
203 
204 The fourth trust leg (§3.7): a second, adversarial model pass over the
205 changelog, run only when a bump is about to auto-merge. ``clean`` approves
206 the merge; ``risky``/``unknown`` hold the PR for the human. Fail-CLOSED — a
207 missing changelog or a model error returns a non-clean verdict, so an
208 unreviewable bump never merges unattended. This is the opposite disposition
209 from :func:`judge_changelog` (which degrades-to-proceed): here the safe
210 direction is to hold, and a non-clean verdict already means hold.
211 """
212 from froot.adapters.changelog_http import HttpChangelogSource
213 from froot.adapters.model_judge import PydanticAiJudge
214 
215 changelog = await HttpChangelogSource().fetch(params.candidate)
216 if changelog is None:
217 verdict: ChangelogVerdict = UnknownVerdict(
218 rationale="No changelog to review; holding (fail-closed)."
219 )
220 else:
221 try:
222 verdict = await PydanticAiJudge().gate_review(
223 changelog, params.loop
224 )
225 except Exception as exc:
226 activity.logger.warning(
227 "gate reviewer unavailable for %s; holding: %r",
228 params.candidate.package,
229 exc,
230 )
231 verdict = UnknownVerdict(
232 rationale=f"Gate reviewer unavailable ({type(exc).__name__})."
233 )
234 _gate_log.info(
235 json.dumps(
236 {
237 "event": "gate_review",
238 "loop": params.loop.value,
239 "pr": params.pr.number,
240 "pr_url": params.pr.url,
241 "package": params.candidate.package,
242 "verdict": verdict.kind,
243 "approved": verdict.kind == "clean",
244 }
245 )
246 )
247 return verdict
248 
249 
250@activity.defn
251async def open_pull_request(params: OpenPrInput) -> PullRequestRef:
252 """Regenerate manifest+lockfile and open (idempotently) the bump's PR."""
253 from froot.adapters.github import GitHubForge
254 from froot.adapters.registry import package_manager_for
255 
256 forge = GitHubForge()
257 package_manager = package_manager_for(params.target.ecosystem)
258 branch = branch_name(params.candidate, params.loop)
259 existing = await forge.find_open_pull_request(params.target, branch)
260 if existing is not None:
261 return existing
262 draft = pull_request_draft(
263 params.target, params.candidate, params.verdict, params.loop
264 )
265 with tempfile.TemporaryDirectory() as tmp:
266 workspace = Path(tmp)
267 await forge.checkout(params.target, workspace)
268 await package_manager.apply_patch_bump(
269 params.candidate, _manifest_dir(params.target, workspace)
270 )
271 await forge.push_branch(workspace, branch, draft.title)
272 return await forge.open_pull_request(params.target, draft)
273 
274 
275@activity.defn
276async def check_ci(params: CiCheckInput) -> CIStatus:
277 """Read the repo's CI status for the PR's head commit (the oracle)."""
278 from froot.adapters.github import GitHubForge
279 
280 return await GitHubForge().ci_status(params.target, params.head_sha)
281 
282 
283@activity.defn
284async def record_outcome(params: RecordInput) -> None:
285 """Label the PR and log the run telemetry — the signal-update.
286 
287 The labels carry the loop *and* the judgment environment (the judge model)
288 the PR was opened under, so the gate can count only the track record earned
289 under the current environment and reset it when the model changes (§3.7).
290 """
291 from froot.adapters.github import GitHubForge
292 from froot.config.settings import ModelSettings
293 from froot.policy.environment import env_label
294 
295 outcome = params.outcome
296 labels = (
297 *pr_labels(params.loop),
298 env_label(ModelSettings().ollama_model),
299 )
300 await GitHubForge().add_labels(params.target, outcome.pr.number, labels)
301 _log.info(
302 json.dumps(
303 {
304 "event": "loop_outcome",
305 "loop": params.loop.value,
306 "repo": params.target.repo.slug,
307 "package": outcome.candidate.package,
308 "from": str(outcome.candidate.current),
309 "to": str(outcome.candidate.target),
310 "changelog": outcome.verdict.kind,
311 "ci": outcome.ci.kind,
312 "ci_passed": outcome.ci_passed,
313 "pr": outcome.pr.number,
314 "pr_url": outcome.pr.url,
315 }
316 )
317 )
318 
319 
320@activity.defn
321async def dispatch_bump(params: DispatchInput) -> None:
322 """Start the bump loop for a candidate (idempotent per bump identity).
323 
324 Reads the close-on-red toggle here, at the impure boundary, and pins it onto
325 the bump's params — so the running workflow never reads config itself and an
326 in-flight bump keeps the value it was dispatched with.
327 """
328 from temporalio.common import WorkflowIDReusePolicy
329 from temporalio.exceptions import WorkflowAlreadyStartedError
330 
331 from froot.config.settings import BehaviorSettings
332 from froot.workflow.bump_workflow import BumpWorkflow
333 from froot.workflow.temporal_client import client, task_queue
334 from froot.workflow.types import BumpParams
335 
336 temporal = await client()
337 try:
338 await temporal.start_workflow(
339 BumpWorkflow.run,
340 BumpParams(
341 target=params.target,
342 candidate=params.candidate,
343 close_on_red=BehaviorSettings().close_on_red,
344 loop=params.loop,
345 ),
346 id=bump_workflow_id(params.target, params.candidate, params.loop),
347 task_queue=task_queue(),
348 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
349 )
350 except WorkflowAlreadyStartedError:
351 # This bump already has a loop (running or completed) — a no-op, so
352 # re-scanning never opens a second PR for the same bump.
353 return
354 
355 
356@activity.defn
357async def auto_merge_eligible(params: AutoMergeInput) -> bool:
358 """Whether this (repo, loop) class has earned the auto-merge grant.
359 
360 Short-circuits to ``False`` for any repo a steward has not allowlisted (the
361 default), so the common case costs nothing. Otherwise it re-derives the
362 class's standing from the live GitHub history — the same triangulated,
363 windowed, environment-scoped computation the dashboard's shadow gate shows
364 (``read_model.earned_now``) — so the acting gate and the advisory panel can
365 never disagree. Best-effort: any read failure degrades to ``False`` (hold),
366 never an auto-merge.
367 """
368 from datetime import UTC, datetime
369 
370 from froot.config.settings import AutonomySettings, ModelSettings
371 from froot.dashboard import github_source, read_model
372 from froot.policy.environment import environment_slug
373 
374 policy = AutonomySettings().policy()
375 repo = params.target.repo.slug
376 if repo not in policy.allowlisted_repos:
377 return False
378 now = datetime.now(UTC)
379 prs, prs_error = await github_source.fetch((repo,))
380 if prs_error is not None:
381 return False # can't confirm the record -> hold, never merge blind
382 outcomes, _ = await github_source.fetch_outcomes(
383 (repo,), prs, now=now, window_days=policy.window_days
384 )
385 return read_model.earned_now(
386 now,
387 prs,
388 outcomes,
389 repo,
390 params.loop,
391 policy,
392 environment_slug(ModelSettings().ollama_model),
393 )
394 
395 
396@activity.defn
397async def merge_pull_request(params: MergeInput) -> None:
398 """Auto-merge an earned, clean+green bump's PR (the acting gate's write).
399 
400 Reached only after the pure machine confirmed clean+green and the class
401 earned the grant on an allowlisted repo. Passes the head SHA so GitHub
402 refuses the merge if the head moved since the gate decided.
403 """
404 from froot.adapters.github import GitHubForge
405 
406 await GitHubForge().merge_pull_request(
407 params.target, params.pr.number, head_sha=params.pr.head_sha
408 )
409 _log.info(
410 json.dumps(
411 {
412 "event": "pr_merged",
413 "loop": params.loop.value,
414 "reason": "auto_merge",
415 "repo": params.target.repo.slug,
416 "pr": params.pr.number,
417 "pr_url": params.pr.url,
418 }
419 )
420 )
421 
422 
423@activity.defn
424async def close_pull_request(params: CloseInput) -> None:
425 """Comment why, then close a red bump's PR and delete its branch.
426 
427 The note goes through the idempotent ``upsert_issue_comment`` and the close
428 itself is idempotent, so a retried close edits its comment in place and
429 never double-posts. The bump's record step still runs after this, so the red
430 outcome is logged either way.
431 """
432 from froot.adapters.github import GitHubForge
433 from froot.policy.compose import CLOSE_MARKER, closed_on_red_comment
434 
435 forge = GitHubForge()
436 body = closed_on_red_comment(params.failing)
437 await forge.upsert_issue_comment(
438 params.target, params.pr.number, CLOSE_MARKER, body
439 )
440 await forge.close_pull_request(
441 params.target, params.pr.number, params.pr.branch
442 )
443 _log.info(
444 json.dumps(
445 {
446 "event": "pr_closed",
447 "loop": params.loop.value,
448 "reason": "ci_red",
449 "repo": params.target.repo.slug,
450 "pr": params.pr.number,
451 "pr_url": params.pr.url,
452 "failing": list(params.failing),
453 }
454 )
455 )
456 
457 
458@activity.defn
459async def reconcile_open_prs(params: ReconcileInput) -> int:
460 """Close this loop's PRs that a newer candidate or the base has overtaken.
461 
462 Self-contained: lists the repo's open PRs, re-derives this loop's live
463 candidates (a fresh checkout + the loop's signal, derive-never-store), asks
464 the pure :func:`~froot.policy.reconcile.reconciliations` policy: which of
465 loop's PRs to close, and closes each (deleting its branch). Scoped to the
466 loop's own branch namespace, so the two loops never reconcile each other's
467 PRs. A no-op when reconcile is off (``FROOT_RECONCILE``). Returns the count.
468 """
469 from froot.adapters.github import GitHubForge
470 from froot.adapters.registry import package_manager_for
471 from froot.config.settings import BehaviorSettings
472 from froot.policy.compose import CLOSE_MARKER
473 from froot.policy.reconcile import reconciliations
474 
475 if not BehaviorSettings().reconcile:
476 return 0
477 
478 target, loop = params.target, params.loop
479 forge = GitHubForge()
480 package_manager = package_manager_for(target.ecosystem)
481 open_prs = await forge.list_open_pull_requests(target)
482 with tempfile.TemporaryDirectory() as tmp:
483 workspace = Path(tmp)
484 await forge.checkout(target, workspace)
485 _considered, candidates = await _select_candidates(
486 loop, target, package_manager, _manifest_dir(target, workspace)
487 )
488 closures = reconciliations(open_prs, candidates, loop)
489 for closure in closures:
490 await forge.upsert_issue_comment(
491 target, closure.pr.number, CLOSE_MARKER, closure.comment
492 )
493 await forge.close_pull_request(
494 target, closure.pr.number, closure.pr.branch
495 )
496 if closures:
497 _reconcile_log.info(
498 json.dumps(
499 {
500 "event": "reconcile",
501 "loop": loop.value,
502 "repo": target.repo.slug,
503 "closed": len(closures),
504 "prs": [closure.pr.number for closure in closures],
505 }
506 )
507 )
508 return len(closures)
509 
510 
511@activity.defn
512async def gate_selftest(params: GateSelfTestInput) -> tuple[str, ...]:
513 """Run the adversarial gate probe against the live policy; alarm on escape.
514 
515 The §2.11 deliberate disturbance for the acting gate: a battery of synthetic
516 known-bad class histories a healthy gate must refuse, scored against the
517 policy froot is *actually running* (config and all). Any escape — a bad
518 class the live gate would grant — is logged at ERROR (the alarm) so it
519 surfaces in telemetry the moment config drifts; a clean pass logs a
520 heartbeat at INFO. Returns the escaped scenario names. Pure compute,
521 repo-independent (the ``target``/``loop`` are only the log's context).
522 """
523 from froot.config.settings import AutonomySettings
524 from froot.policy.gate_probe import gate_escapes
525 
526 escaped = gate_escapes(AutonomySettings().policy())
527 record = json.dumps(
528 {
529 "event": "gate_selftest",
530 "loop": params.loop.value,
531 "repo": params.target.repo.slug,
532 "healthy": not escaped,
533 "escaped": list(escaped),
534 }
535 )
536 if escaped:
537 _gate_log.error(record) # an alarm: the gate would trust a bad class
538 else:
539 _gate_log.info(record)
540 return escaped
541 
⋯ 95 lines hidden (lines 542–636)
542 
543# ── The determinism reviewer (the transitive ring) ──────────────────────────
544@activity.defn
545async def list_review_prs(target: TargetRepo) -> tuple[PullRequestRef, ...]:
546 """List the repo's open PRs for the determinism reviewer to consider."""
547 from froot.adapters.github import GitHubForge
548 
549 return await GitHubForge().list_open_pull_requests(target)
550 
551 
552@activity.defn
553async def dispatch_pr_review(params: DispatchReviewInput) -> None:
554 """Start a PR's determinism review (idempotent per PR + head SHA)."""
555 from temporalio.common import WorkflowIDReusePolicy
556 from temporalio.exceptions import WorkflowAlreadyStartedError
557 
558 from froot.workflow.pr_review_workflow import PrReviewWorkflow
559 from froot.workflow.temporal_client import client, task_queue
560 
561 temporal = await client()
562 try:
563 await temporal.start_workflow(
564 PrReviewWorkflow.run,
565 PrReviewParams(target=params.target, pr=params.pr),
566 id=pr_review_workflow_id(
567 params.target, params.pr.number, params.pr.head_sha
568 ),
569 task_queue=task_queue(),
570 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
571 )
572 except WorkflowAlreadyStartedError:
573 # This (PR, head SHA) already has a review — a no-op, so re-polling
574 # never double-reviews the same commit.
575 return
576 
577 
578@activity.defn
579async def analyze_pr(params: PrReviewParams) -> AnalysisResult:
580 """Check out the PR head and analyze the workflow surface for hazards."""
581 from froot.adapters.github import GitHubForge
582 from froot.adapters.source_tree import load_modules
583 from froot.config.settings import ReviewSettings
584 
585 forge = GitHubForge()
586 with tempfile.TemporaryDirectory() as tmp:
587 workspace = Path(tmp)
588 await forge.checkout_pull_request(
589 params.target, workspace, params.pr.number
590 )
591 # The ASTs and source lines are read into memory here, so the analysis
592 # below is unaffected by the workspace being cleaned up.
593 modules = load_modules(workspace)
594 return analyze_workflow_surface(modules, max_depth=ReviewSettings().depth)
595 
596 
597@activity.defn
598async def adjudicate_frontier(
599 params: AdjudicateInput,
600) -> tuple[FrontierVerdict, ...]:
601 """Run the model over each frontier item; return aligned verdicts."""
602 from froot.adapters.determinism_judge import DeterminismFrontierJudge
603 
604 judge = DeterminismFrontierJudge()
605 verdicts: list[FrontierVerdict] = []
606 for item in params.frontier:
607 verdicts.append(await judge.adjudicate(item))
608 return tuple(verdicts)
609 
610 
611@activity.defn
612async def post_review(params: PostReviewInput) -> str | None:
613 """Upsert the advisory comment (when there are findings); log the ledger."""
614 from froot.adapters.github import GitHubForge
615 
616 body = render_review_comment(params.findings, params.pr.head_sha)
617 url: str | None = None
618 if body is not None:
619 url = await GitHubForge().upsert_issue_comment(
620 params.target, params.pr.number, REVIEW_MARKER, body
621 )
622 _review_log.info(
623 json.dumps(
624 {
625 "event": "loop_outcome",
626 "loop": "determinism-review",
627 "repo": params.target.repo.slug,
628 "pr": params.pr.number,
629 "head_sha": params.pr.head_sha,
630 "findings": len(params.findings),
631 "rules": sorted({f.rule for f in params.findings}),
632 "comment_url": url,
633 }
634 )
635 )
636 return url
src/froot/workflow/scan_workflow.py · 95 lines
src/froot/workflow/scan_workflow.py95 lines · Python
⋯ 59 lines hidden (lines 1–59)
1"""The self-scheduling scan loop — froot's durable trigger (per repo).
2 
3One long-lived workflow per target repo. Each tick checks out the repo, selects
4the patch candidates, and dispatches a bump loop per candidate (idempotent, so
5re-dispatch is a no-op); then it sleeps and continues-as-new, keeping history
6bounded to one tick. There is no stored seen-set — the outstanding work is
7re-derived from the repo each tick (derive, never store), and the per-bump
8workflow id makes re-proposing an already-handled bump a no-op.
9 
10A one-shot run (``continuous=False``, the default) performs a single tick and
11returns; production starts it once with ``continuous=True`` and it runs forever.
12"""
13 
14from __future__ import annotations
15 
16from datetime import timedelta
17 
18from temporalio import workflow
19 
20with workflow.unsafe.imports_passed_through():
21 from froot.workflow import activities
22 from froot.workflow.constants import (
23 ACTIVITY_TIMEOUT,
24 DISPATCH_TIMEOUT,
25 TOOL_RETRY,
26 )
27 from froot.workflow.types import (
28 DispatchInput,
29 GateSelfTestInput,
30 ReconcileInput,
31 ScanCandidatesInput,
32 ScanParams,
33 ScanResult,
34 )
35 
36 
37@workflow.defn
38class ScanWorkflow:
39 """The durable dependency-scan loop (one tick per continue-as-new)."""
40 
41 @workflow.run
42 async def run(self, params: ScanParams) -> ScanResult:
43 """Scan, dispatch each, reconcile stale PRs, then loop or return."""
44 candidates = await workflow.execute_activity(
45 activities.scan_candidates,
46 ScanCandidatesInput(target=params.target, loop=params.loop),
47 start_to_close_timeout=ACTIVITY_TIMEOUT,
48 retry_policy=TOOL_RETRY,
49 )
50 for candidate in candidates:
51 await workflow.execute_activity(
52 activities.dispatch_bump,
53 DispatchInput(
54 target=params.target,
55 candidate=candidate,
56 loop=params.loop,
57 ),
58 start_to_close_timeout=DISPATCH_TIMEOUT,
59 retry_policy=TOOL_RETRY,
60 )
61 # Close this loop's PRs a newer target superseded — re-derived from the
62 # repo each tick, same as the scan, and scoped to the loop's namespace.
63 reconciled = await workflow.execute_activity(
64 activities.reconcile_open_prs,
65 ReconcileInput(target=params.target, loop=params.loop),
66 start_to_close_timeout=ACTIVITY_TIMEOUT,
67 retry_policy=TOOL_RETRY,
68 )
69 # The adversarial probe (§2.11): every tick, confirm the *live* gate
70 # still refuses a battery of known-bad classes. Cheap, repo-independent,
71 # and alarms in telemetry the moment a config drift loosens the gate.
72 await workflow.execute_activity(
73 activities.gate_selftest,
74 GateSelfTestInput(target=params.target, loop=params.loop),
75 start_to_close_timeout=ACTIVITY_TIMEOUT,
76 retry_policy=TOOL_RETRY,
77 )
78 result = ScanResult(
79 found=len(candidates),
80 dispatched=len(candidates),
81 reconciled=reconciled,
82 )
83 if not params.continuous:
84 return result
85 # Durable loop: sleep, then restart fresh. continue_as_new raises, so
86 # nothing runs after it and history stays bounded to one tick.
⋯ 9 lines hidden (lines 87–95)
87 await workflow.sleep(timedelta(seconds=params.interval_seconds))
88 workflow.continue_as_new(
89 ScanParams(
90 target=params.target,
91 interval_seconds=params.interval_seconds,
92 continuous=True,
93 loop=params.loop,
94 )
95 )

Bearing 4 — the independent deep review at the gate (P4.4)

The fourth leg (§3.7's sampled deep review, here run on every auto-merge candidate). The first changelog judge frames risk neutrally and never gates; this one is the opposite — an independent, adversarial re-read whose approval is required to merge. Its system prompt makes the stance asymmetric: the burden is on the changelog to prove safety.

src/froot/adapters/model_judge.py · 166 lines
src/froot/adapters/model_judge.py166 lines · Python
⋯ 50 lines hidden (lines 1–50)
1"""The thin model judgment: how risky is this dependency bump's changelog?
2 
3froot's one model call. A Pydantic AI agent reads the changelog and returns a
4typed :class:`_Assessment`, which :func:`assessment_to_verdict` maps to the
5domain :data:`~froot.domain.changelog.ChangelogVerdict`. The verdict is framing,
6not a gate — the spine proposes the bump regardless — so even a "risky" reading
7just shapes the PR. The *loop* shapes what the model is asked: a patch bump asks
8"is this clean?", a security bump (often a minor/major) asks "what breaks?". The
9model is injected, so tests run it offline with a ``TestModel`` /
10``FunctionModel``; the mapping is a pure function, tested apart from any model.
11"""
12 
13from __future__ import annotations
14 
15from typing import TYPE_CHECKING, Literal, assert_never
16 
17from pydantic import BaseModel, Field
18from pydantic_ai import Agent
19 
20from froot.adapters.model import build_model
21from froot.domain.changelog import (
22 CleanVerdict,
23 RiskyVerdict,
24 UnknownVerdict,
26from froot.domain.loop import Loop
27 
28if TYPE_CHECKING:
29 from pydantic_ai.models import Model
30 
31 from froot.domain.changelog import Changelog, ChangelogVerdict
32 
33_SYSTEM_PROMPT = (
34 "You assess the changelog of a dependency upgrade for a code-maintenance "
35 "bot. The bot proposes the bump either way; your job is only to frame the "
36 "risk for the human reviewer.\n"
37 "Return one verdict:\n"
38 "- clean: the notes describe only fixes / docs / internal changes with no "
39 "behavioral or API impact.\n"
40 "- risky: the notes hint at behavior change, deprecations, or anything a "
41 "reviewer should look at closely; list the concerns.\n"
42 "- unknown: the notes are empty or uninformative.\n"
43 "Quote-or-omit: base 'risky' concerns on what the text actually says; do "
44 "not speculate. Keep the rationale to one or two sentences."
46 
47# The gate reviewer's stance is adversarial and asymmetric: this bump is about
48# to merge with NO human in the loop, so the burden is on the changelog to prove
49# it is safe, not on the reviewer to prove it is dangerous. The same _Assessment
50# shape, read as approve/hold (clean = approve).
51_GATE_SYSTEM_PROMPT = (
52 "You are the LAST line of defense before a dependency upgrade auto-merges "
53 "with no human review. Re-read the changelog adversarially and decide "
54 "whether it is safe to merge unattended.\n"
55 "Return one verdict:\n"
56 "- clean: ONLY if the notes clearly describe nothing beyond fixes / docs / "
57 "internal changes — no behavioral change, no API change, no deprecation, "
58 "no ambiguity. This approves the merge.\n"
59 "- risky: any hint of behavior change, removal, deprecation, or surface a "
60 "reviewer should see; list the concerns. This holds the PR.\n"
61 "- unknown: the notes are empty, uninformative, or you are unsure. This "
62 "holds the PR.\n"
63 "The burden is on the changelog to prove safety: when in doubt, do NOT "
64 "return clean. Base concerns on what the text says; keep it to one or two "
65 "sentences."
67 
68 
69def _loop_context(loop: Loop) -> str:
70 """The one line that tells the model what kind of bump it is judging."""
71 match loop:
72 case Loop.DEPENDENCY_PATCH:
73 return (
74 "This is a patch-level upgrade; weigh whether the notes hide "
⋯ 60 lines hidden (lines 75–134)
75 "any behavioral change behind a 'patch'."
76 )
77 case Loop.SECURITY_PATCH:
78 return (
79 "This is a SECURITY upgrade that may cross a minor or major "
80 "line to clear a vulnerability; weigh breaking changes the "
81 "human should know before merging — the fix is still worth it."
82 )
83 assert_never(loop)
84 
85 
86class _Assessment(BaseModel):
87 """The model's structured output, mapped to a domain verdict."""
88 
89 verdict: Literal["clean", "risky", "unknown"]
90 rationale: str
91 concerns: list[str] = Field(default_factory=list)
92 
93 
94def assessment_to_verdict(assessment: _Assessment) -> ChangelogVerdict:
95 """Map the model's structured assessment to a domain verdict (pure)."""
96 match assessment.verdict:
97 case "clean":
98 return CleanVerdict(rationale=assessment.rationale)
99 case "risky":
100 return RiskyVerdict(
101 rationale=assessment.rationale,
102 concerns=tuple(assessment.concerns),
103 )
104 case "unknown":
105 return UnknownVerdict(rationale=assessment.rationale)
106 assert_never(assessment.verdict)
107 
108 
109def _gate_model() -> Model:
110 """Build the gate reviewer's model: the override, else the judge model."""
111 from froot.config.settings import ModelSettings
112 
113 return build_model(model_name=ModelSettings().gate_review_model or None)
114 
115 
116def _changelog_prompt(changelog: Changelog, loop: Loop) -> str:
117 """The shared user prompt: the bump's context and its changelog text."""
118 return (
119 f"{_loop_context(loop)}\n"
120 f"Package: {changelog.package}\n"
121 f"Target version: {changelog.version}\n"
122 f"Changelog:\n{changelog.text}"
123 )
124 
125 
126class PydanticAiJudge:
127 """A :class:`~froot.ports.protocols.ModelJudge` backed by Pydantic AI.
128 
129 Two agents share the structured ``_Assessment`` output but differ in stance:
130 the neutral framing judge (:meth:`judge`) and the adversarial gate reviewer
131 (:meth:`gate_review`), each its own model so a steward can make the deep
132 review independent in capability, not just prompt.
133 """
134 
135 def __init__(
136 self, model: Model | None = None, gate_model: Model | None = None
137 ) -> None:
138 """Build both agents.
139 
140 ``model`` defaults to the configured Ollama; ``gate_model`` to the
141 gate-review override, else ``model``, else the gate-review model.
142 """
143 self._agent: Agent[None, _Assessment] = Agent(
144 model or build_model(),
145 output_type=_Assessment,
146 system_prompt=_SYSTEM_PROMPT,
147 )
148 self._gate_agent: Agent[None, _Assessment] = Agent(
149 gate_model or model or _gate_model(),
150 output_type=_Assessment,
151 system_prompt=_GATE_SYSTEM_PROMPT,
152 )
153 
154 async def judge(
155 self, changelog: Changelog, loop: Loop = Loop.DEPENDENCY_PATCH
156 ) -> ChangelogVerdict:
157 """Assess a changelog into a verdict, framed by the loop."""
158 result = await self._agent.run(_changelog_prompt(changelog, loop))
159 return assessment_to_verdict(result.output)
160 
161 async def gate_review(
162 self, changelog: Changelog, loop: Loop = Loop.DEPENDENCY_PATCH
163 ) -> ChangelogVerdict:
164 """Adversarially re-review a bump at the gate (clean = approve)."""
165 result = await self._gate_agent.run(_changelog_prompt(changelog, loop))
166 return assessment_to_verdict(result.output)

The gate_review activity fetches the changelog and runs the second pass, fail-CLOSED throughout: a missing changelog or a model error returns a non-clean verdict, so an unreviewable bump never merges unattended (the opposite disposition from judge_changelog, which degrades-to-proceed).

src/froot/workflow/activities.py · 636 lines
src/froot/workflow/activities.py636 lines · Python
⋯ 200 lines hidden (lines 1–200)
1"""Activities: the effect interpreters that wrap the adapters.
2 
3Each activity is the impure boundary for one effect. Adapter imports are LAZY
4(inside the bodies) so the model and HTTP stacks never enter a workflow sandbox;
5the pure policy and domain imports stay at module level. Activities return
6domain values — the workflow wraps them into events and feeds the pure state
7machine. The activity signatures' types are evaluated at runtime by Temporal's
8data converter, so the domain imports here are deliberately not deferred.
9"""
10 
11from __future__ import annotations
12 
13import json
14import logging
15import tempfile
16from pathlib import Path
17from typing import TYPE_CHECKING, assert_never
18 
19from temporalio import activity
20 
21from froot.domain.candidate import Candidate
22from froot.domain.changelog import ChangelogVerdict, UnknownVerdict
23from froot.domain.ci import CIStatus
24from froot.domain.determinism import AnalysisResult, FrontierVerdict
25from froot.domain.loop import Loop
26from froot.domain.pull_request import PullRequestRef
27from froot.domain.repo import TargetRepo
28from froot.policy.compose import pr_labels, pull_request_draft
29from froot.policy.determinism import analyze_workflow_surface
30from froot.policy.naming import (
31 branch_name,
32 bump_workflow_id,
33 pr_review_workflow_id,
35from froot.policy.review_comment import REVIEW_MARKER, render_review_comment
36from froot.workflow.types import (
37 AdjudicateInput,
38 AutoMergeInput,
39 CiCheckInput,
40 CloseInput,
41 DispatchInput,
42 DispatchReviewInput,
43 GateReviewInput,
44 GateSelfTestInput,
45 JudgeInput,
46 MergeInput,
47 OpenPrInput,
48 PostReviewInput,
49 PrReviewParams,
50 ReconcileInput,
51 RecordInput,
52 ScanCandidatesInput,
54 
55if TYPE_CHECKING:
56 from froot.ports.protocols import PackageManager
57 
58_log = logging.getLogger("froot.outcome")
59_review_log = logging.getLogger("froot.review")
60_reconcile_log = logging.getLogger("froot.reconcile")
61_scan_log = logging.getLogger("froot.scan")
62_gate_log = logging.getLogger("froot.gate")
63 
64 
65def _manifest_dir(target: TargetRepo, workspace: Path) -> Path:
66 """The directory the manifest lives in (a monorepo subdir, or the root)."""
67 return workspace / target.manifest_dir if target.manifest_dir else workspace
68 
69 
70async def _select_candidates(
71 loop: Loop,
72 target: TargetRepo,
73 package_manager: PackageManager,
74 manifest_dir: Path,
75) -> tuple[int, tuple[Candidate, ...]]:
76 """Gather this loop's signal from the checkout and select its candidates.
77 
78 The one genuinely per-loop seam: dependency-patch reads the available
79 upgrades and picks the highest patch; security-patch reads the installed,
80 asks OSV for advisories, and picks the lowest version clearing each. The
81 impure sources are lazy-imported per arm so neither drags the other's stack
82 into a sandbox. Both feed a pure selection policy.
83 
84 Returns ``(considered, candidates)`` — ``considered`` is the size of the
85 upstream signal (available upgrades / advisories found) so the scan can make
86 its selectivity legible (how much was seen versus how much was kept).
87 """
88 match loop:
89 case Loop.DEPENDENCY_PATCH:
90 from froot.policy.candidates import select_patch_candidates
91 
92 upgrades = await package_manager.list_upgrades(target, manifest_dir)
93 return len(upgrades), select_patch_candidates(upgrades)
94 case Loop.SECURITY_PATCH:
95 return await _select_security_candidates(
96 target, package_manager, manifest_dir
97 )
98 assert_never(loop)
99 
100 
101async def _select_security_candidates(
102 target: TargetRepo, package_manager: PackageManager, manifest_dir: Path
103) -> tuple[int, tuple[Candidate, ...]]:
104 """Security signal: installed set, OSV advisories, clearing targets.
105 
106 ``considered`` is the count of advisories OSV returned for the installed
107 set — the vulnerabilities in scope this tick, before selection narrows to
108 the ones a forward-stable bump can actually clear.
109 """
110 from froot.adapters.osv import OsvAdvisorySource
111 from froot.policy.candidates import select_security_candidates
112 
113 installed = await package_manager.list_installed(target, manifest_dir)
114 advisories = await OsvAdvisorySource().advisories(installed)
115 return len(advisories), select_security_candidates(installed, advisories)
116 
117 
118@activity.defn
119async def scan_candidates(
120 params: ScanCandidatesInput,
121) -> tuple[Candidate, ...]:
122 """Check out the repo and select this loop's candidates.
123 
124 Emits the tick's selectivity — how much upstream signal was considered
125 versus how much was kept — as span attributes (when ``FROOT_OTEL`` is on)
126 and as a structured ``scan_tick`` log, so the signal stage is legible in the
127 run ledger even on a tick that proposes nothing.
128 """
129 from froot.adapters.github import GitHubForge
130 from froot.adapters.registry import package_manager_for
131 from froot.adapters.telemetry import set_span_attributes
132 
133 forge = GitHubForge()
134 package_manager = package_manager_for(params.target.ecosystem)
135 with tempfile.TemporaryDirectory() as tmp:
136 workspace = Path(tmp)
137 await forge.checkout(params.target, workspace)
138 considered, candidates = await _select_candidates(
139 params.loop,
140 params.target,
141 package_manager,
142 _manifest_dir(params.target, workspace),
143 )
144 selected = len(candidates)
145 dropped = max(considered - selected, 0)
146 set_span_attributes(
147 scan_loop=params.loop.value,
148 scan_repo=params.target.repo.slug,
149 scan_considered=considered,
150 scan_selected=selected,
151 scan_dropped=dropped,
152 )
153 _scan_log.info(
154 json.dumps(
155 {
156 "event": "scan_tick",
157 "loop": params.loop.value,
158 "repo": params.target.repo.slug,
159 "considered": considered,
160 "selected": selected,
161 "dropped": dropped,
162 }
163 )
164 )
165 return candidates
166 
167 
168@activity.defn
169async def judge_changelog(params: JudgeInput) -> ChangelogVerdict:
170 """Fetch the candidate's changelog and get the model's typed verdict.
171 
172 The model is froot's one thin, non-load-bearing judgment: a clean verdict
173 never *gates* a PR (CI is the oracle), so a model that is down, slow, or
174 erroring must not stall the spine. A judge failure degrades to
175 ``UnknownVerdict`` — the bump proceeds, the human still gets the PR, and the
176 dashboard records the verdict as unknown — rather than failing (and then
177 retrying) the activity. Only the model call is guarded; the fetch is already
178 best-effort (returns ``None``, not an exception). The loop selects what the
179 model is asked (clean-patch vs breaking-change-on-a-security-bump).
180 """
181 from froot.adapters.changelog_http import HttpChangelogSource
182 from froot.adapters.model_judge import PydanticAiJudge
183 
184 changelog = await HttpChangelogSource().fetch(params.candidate)
185 if changelog is None:
186 return UnknownVerdict(rationale="No changelog could be fetched.")
187 try:
188 return await PydanticAiJudge().judge(changelog, params.loop)
189 except Exception as exc:
190 activity.logger.warning(
191 "changelog judge unavailable for %s; degrading to unknown: %r",
192 params.candidate.package,
193 exc,
194 )
195 return UnknownVerdict(
196 rationale=f"Changelog judge unavailable ({type(exc).__name__})."
197 )
198 
199 
200@activity.defn
201async def gate_review(params: GateReviewInput) -> ChangelogVerdict:
202 """Independently deep-review a bump at the gate; fail-closed to a hold.
203 
204 The fourth trust leg (§3.7): a second, adversarial model pass over the
205 changelog, run only when a bump is about to auto-merge. ``clean`` approves
206 the merge; ``risky``/``unknown`` hold the PR for the human. Fail-CLOSED — a
207 missing changelog or a model error returns a non-clean verdict, so an
208 unreviewable bump never merges unattended. This is the opposite disposition
209 from :func:`judge_changelog` (which degrades-to-proceed): here the safe
210 direction is to hold, and a non-clean verdict already means hold.
211 """
212 from froot.adapters.changelog_http import HttpChangelogSource
213 from froot.adapters.model_judge import PydanticAiJudge
214 
215 changelog = await HttpChangelogSource().fetch(params.candidate)
216 if changelog is None:
217 verdict: ChangelogVerdict = UnknownVerdict(
218 rationale="No changelog to review; holding (fail-closed)."
219 )
220 else:
221 try:
222 verdict = await PydanticAiJudge().gate_review(
223 changelog, params.loop
224 )
225 except Exception as exc:
226 activity.logger.warning(
227 "gate reviewer unavailable for %s; holding: %r",
228 params.candidate.package,
229 exc,
230 )
231 verdict = UnknownVerdict(
232 rationale=f"Gate reviewer unavailable ({type(exc).__name__})."
233 )
234 _gate_log.info(
235 json.dumps(
236 {
237 "event": "gate_review",
238 "loop": params.loop.value,
239 "pr": params.pr.number,
240 "pr_url": params.pr.url,
241 "package": params.candidate.package,
242 "verdict": verdict.kind,
243 "approved": verdict.kind == "clean",
244 }
245 )
246 )
247 return verdict
⋯ 389 lines hidden (lines 248–636)
248 
249 
250@activity.defn
251async def open_pull_request(params: OpenPrInput) -> PullRequestRef:
252 """Regenerate manifest+lockfile and open (idempotently) the bump's PR."""
253 from froot.adapters.github import GitHubForge
254 from froot.adapters.registry import package_manager_for
255 
256 forge = GitHubForge()
257 package_manager = package_manager_for(params.target.ecosystem)
258 branch = branch_name(params.candidate, params.loop)
259 existing = await forge.find_open_pull_request(params.target, branch)
260 if existing is not None:
261 return existing
262 draft = pull_request_draft(
263 params.target, params.candidate, params.verdict, params.loop
264 )
265 with tempfile.TemporaryDirectory() as tmp:
266 workspace = Path(tmp)
267 await forge.checkout(params.target, workspace)
268 await package_manager.apply_patch_bump(
269 params.candidate, _manifest_dir(params.target, workspace)
270 )
271 await forge.push_branch(workspace, branch, draft.title)
272 return await forge.open_pull_request(params.target, draft)
273 
274 
275@activity.defn
276async def check_ci(params: CiCheckInput) -> CIStatus:
277 """Read the repo's CI status for the PR's head commit (the oracle)."""
278 from froot.adapters.github import GitHubForge
279 
280 return await GitHubForge().ci_status(params.target, params.head_sha)
281 
282 
283@activity.defn
284async def record_outcome(params: RecordInput) -> None:
285 """Label the PR and log the run telemetry — the signal-update.
286 
287 The labels carry the loop *and* the judgment environment (the judge model)
288 the PR was opened under, so the gate can count only the track record earned
289 under the current environment and reset it when the model changes (§3.7).
290 """
291 from froot.adapters.github import GitHubForge
292 from froot.config.settings import ModelSettings
293 from froot.policy.environment import env_label
294 
295 outcome = params.outcome
296 labels = (
297 *pr_labels(params.loop),
298 env_label(ModelSettings().ollama_model),
299 )
300 await GitHubForge().add_labels(params.target, outcome.pr.number, labels)
301 _log.info(
302 json.dumps(
303 {
304 "event": "loop_outcome",
305 "loop": params.loop.value,
306 "repo": params.target.repo.slug,
307 "package": outcome.candidate.package,
308 "from": str(outcome.candidate.current),
309 "to": str(outcome.candidate.target),
310 "changelog": outcome.verdict.kind,
311 "ci": outcome.ci.kind,
312 "ci_passed": outcome.ci_passed,
313 "pr": outcome.pr.number,
314 "pr_url": outcome.pr.url,
315 }
316 )
317 )
318 
319 
320@activity.defn
321async def dispatch_bump(params: DispatchInput) -> None:
322 """Start the bump loop for a candidate (idempotent per bump identity).
323 
324 Reads the close-on-red toggle here, at the impure boundary, and pins it onto
325 the bump's params — so the running workflow never reads config itself and an
326 in-flight bump keeps the value it was dispatched with.
327 """
328 from temporalio.common import WorkflowIDReusePolicy
329 from temporalio.exceptions import WorkflowAlreadyStartedError
330 
331 from froot.config.settings import BehaviorSettings
332 from froot.workflow.bump_workflow import BumpWorkflow
333 from froot.workflow.temporal_client import client, task_queue
334 from froot.workflow.types import BumpParams
335 
336 temporal = await client()
337 try:
338 await temporal.start_workflow(
339 BumpWorkflow.run,
340 BumpParams(
341 target=params.target,
342 candidate=params.candidate,
343 close_on_red=BehaviorSettings().close_on_red,
344 loop=params.loop,
345 ),
346 id=bump_workflow_id(params.target, params.candidate, params.loop),
347 task_queue=task_queue(),
348 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
349 )
350 except WorkflowAlreadyStartedError:
351 # This bump already has a loop (running or completed) — a no-op, so
352 # re-scanning never opens a second PR for the same bump.
353 return
354 
355 
356@activity.defn
357async def auto_merge_eligible(params: AutoMergeInput) -> bool:
358 """Whether this (repo, loop) class has earned the auto-merge grant.
359 
360 Short-circuits to ``False`` for any repo a steward has not allowlisted (the
361 default), so the common case costs nothing. Otherwise it re-derives the
362 class's standing from the live GitHub history — the same triangulated,
363 windowed, environment-scoped computation the dashboard's shadow gate shows
364 (``read_model.earned_now``) — so the acting gate and the advisory panel can
365 never disagree. Best-effort: any read failure degrades to ``False`` (hold),
366 never an auto-merge.
367 """
368 from datetime import UTC, datetime
369 
370 from froot.config.settings import AutonomySettings, ModelSettings
371 from froot.dashboard import github_source, read_model
372 from froot.policy.environment import environment_slug
373 
374 policy = AutonomySettings().policy()
375 repo = params.target.repo.slug
376 if repo not in policy.allowlisted_repos:
377 return False
378 now = datetime.now(UTC)
379 prs, prs_error = await github_source.fetch((repo,))
380 if prs_error is not None:
381 return False # can't confirm the record -> hold, never merge blind
382 outcomes, _ = await github_source.fetch_outcomes(
383 (repo,), prs, now=now, window_days=policy.window_days
384 )
385 return read_model.earned_now(
386 now,
387 prs,
388 outcomes,
389 repo,
390 params.loop,
391 policy,
392 environment_slug(ModelSettings().ollama_model),
393 )
394 
395 
396@activity.defn
397async def merge_pull_request(params: MergeInput) -> None:
398 """Auto-merge an earned, clean+green bump's PR (the acting gate's write).
399 
400 Reached only after the pure machine confirmed clean+green and the class
401 earned the grant on an allowlisted repo. Passes the head SHA so GitHub
402 refuses the merge if the head moved since the gate decided.
403 """
404 from froot.adapters.github import GitHubForge
405 
406 await GitHubForge().merge_pull_request(
407 params.target, params.pr.number, head_sha=params.pr.head_sha
408 )
409 _log.info(
410 json.dumps(
411 {
412 "event": "pr_merged",
413 "loop": params.loop.value,
414 "reason": "auto_merge",
415 "repo": params.target.repo.slug,
416 "pr": params.pr.number,
417 "pr_url": params.pr.url,
418 }
419 )
420 )
421 
422 
423@activity.defn
424async def close_pull_request(params: CloseInput) -> None:
425 """Comment why, then close a red bump's PR and delete its branch.
426 
427 The note goes through the idempotent ``upsert_issue_comment`` and the close
428 itself is idempotent, so a retried close edits its comment in place and
429 never double-posts. The bump's record step still runs after this, so the red
430 outcome is logged either way.
431 """
432 from froot.adapters.github import GitHubForge
433 from froot.policy.compose import CLOSE_MARKER, closed_on_red_comment
434 
435 forge = GitHubForge()
436 body = closed_on_red_comment(params.failing)
437 await forge.upsert_issue_comment(
438 params.target, params.pr.number, CLOSE_MARKER, body
439 )
440 await forge.close_pull_request(
441 params.target, params.pr.number, params.pr.branch
442 )
443 _log.info(
444 json.dumps(
445 {
446 "event": "pr_closed",
447 "loop": params.loop.value,
448 "reason": "ci_red",
449 "repo": params.target.repo.slug,
450 "pr": params.pr.number,
451 "pr_url": params.pr.url,
452 "failing": list(params.failing),
453 }
454 )
455 )
456 
457 
458@activity.defn
459async def reconcile_open_prs(params: ReconcileInput) -> int:
460 """Close this loop's PRs that a newer candidate or the base has overtaken.
461 
462 Self-contained: lists the repo's open PRs, re-derives this loop's live
463 candidates (a fresh checkout + the loop's signal, derive-never-store), asks
464 the pure :func:`~froot.policy.reconcile.reconciliations` policy: which of
465 loop's PRs to close, and closes each (deleting its branch). Scoped to the
466 loop's own branch namespace, so the two loops never reconcile each other's
467 PRs. A no-op when reconcile is off (``FROOT_RECONCILE``). Returns the count.
468 """
469 from froot.adapters.github import GitHubForge
470 from froot.adapters.registry import package_manager_for
471 from froot.config.settings import BehaviorSettings
472 from froot.policy.compose import CLOSE_MARKER
473 from froot.policy.reconcile import reconciliations
474 
475 if not BehaviorSettings().reconcile:
476 return 0
477 
478 target, loop = params.target, params.loop
479 forge = GitHubForge()
480 package_manager = package_manager_for(target.ecosystem)
481 open_prs = await forge.list_open_pull_requests(target)
482 with tempfile.TemporaryDirectory() as tmp:
483 workspace = Path(tmp)
484 await forge.checkout(target, workspace)
485 _considered, candidates = await _select_candidates(
486 loop, target, package_manager, _manifest_dir(target, workspace)
487 )
488 closures = reconciliations(open_prs, candidates, loop)
489 for closure in closures:
490 await forge.upsert_issue_comment(
491 target, closure.pr.number, CLOSE_MARKER, closure.comment
492 )
493 await forge.close_pull_request(
494 target, closure.pr.number, closure.pr.branch
495 )
496 if closures:
497 _reconcile_log.info(
498 json.dumps(
499 {
500 "event": "reconcile",
501 "loop": loop.value,
502 "repo": target.repo.slug,
503 "closed": len(closures),
504 "prs": [closure.pr.number for closure in closures],
505 }
506 )
507 )
508 return len(closures)
509 
510 
511@activity.defn
512async def gate_selftest(params: GateSelfTestInput) -> tuple[str, ...]:
513 """Run the adversarial gate probe against the live policy; alarm on escape.
514 
515 The §2.11 deliberate disturbance for the acting gate: a battery of synthetic
516 known-bad class histories a healthy gate must refuse, scored against the
517 policy froot is *actually running* (config and all). Any escape — a bad
518 class the live gate would grant — is logged at ERROR (the alarm) so it
519 surfaces in telemetry the moment config drifts; a clean pass logs a
520 heartbeat at INFO. Returns the escaped scenario names. Pure compute,
521 repo-independent (the ``target``/``loop`` are only the log's context).
522 """
523 from froot.config.settings import AutonomySettings
524 from froot.policy.gate_probe import gate_escapes
525 
526 escaped = gate_escapes(AutonomySettings().policy())
527 record = json.dumps(
528 {
529 "event": "gate_selftest",
530 "loop": params.loop.value,
531 "repo": params.target.repo.slug,
532 "healthy": not escaped,
533 "escaped": list(escaped),
534 }
535 )
536 if escaped:
537 _gate_log.error(record) # an alarm: the gate would trust a bad class
538 else:
539 _gate_log.info(record)
540 return escaped
541 
542 
543# ── The determinism reviewer (the transitive ring) ──────────────────────────
544@activity.defn
545async def list_review_prs(target: TargetRepo) -> tuple[PullRequestRef, ...]:
546 """List the repo's open PRs for the determinism reviewer to consider."""
547 from froot.adapters.github import GitHubForge
548 
549 return await GitHubForge().list_open_pull_requests(target)
550 
551 
552@activity.defn
553async def dispatch_pr_review(params: DispatchReviewInput) -> None:
554 """Start a PR's determinism review (idempotent per PR + head SHA)."""
555 from temporalio.common import WorkflowIDReusePolicy
556 from temporalio.exceptions import WorkflowAlreadyStartedError
557 
558 from froot.workflow.pr_review_workflow import PrReviewWorkflow
559 from froot.workflow.temporal_client import client, task_queue
560 
561 temporal = await client()
562 try:
563 await temporal.start_workflow(
564 PrReviewWorkflow.run,
565 PrReviewParams(target=params.target, pr=params.pr),
566 id=pr_review_workflow_id(
567 params.target, params.pr.number, params.pr.head_sha
568 ),
569 task_queue=task_queue(),
570 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
571 )
572 except WorkflowAlreadyStartedError:
573 # This (PR, head SHA) already has a review — a no-op, so re-polling
574 # never double-reviews the same commit.
575 return
576 
577 
578@activity.defn
579async def analyze_pr(params: PrReviewParams) -> AnalysisResult:
580 """Check out the PR head and analyze the workflow surface for hazards."""
581 from froot.adapters.github import GitHubForge
582 from froot.adapters.source_tree import load_modules
583 from froot.config.settings import ReviewSettings
584 
585 forge = GitHubForge()
586 with tempfile.TemporaryDirectory() as tmp:
587 workspace = Path(tmp)
588 await forge.checkout_pull_request(
589 params.target, workspace, params.pr.number
590 )
591 # The ASTs and source lines are read into memory here, so the analysis
592 # below is unaffected by the workspace being cleaned up.
593 modules = load_modules(workspace)
594 return analyze_workflow_surface(modules, max_depth=ReviewSettings().depth)
595 
596 
597@activity.defn
598async def adjudicate_frontier(
599 params: AdjudicateInput,
600) -> tuple[FrontierVerdict, ...]:
601 """Run the model over each frontier item; return aligned verdicts."""
602 from froot.adapters.determinism_judge import DeterminismFrontierJudge
603 
604 judge = DeterminismFrontierJudge()
605 verdicts: list[FrontierVerdict] = []
606 for item in params.frontier:
607 verdicts.append(await judge.adjudicate(item))
608 return tuple(verdicts)
609 
610 
611@activity.defn
612async def post_review(params: PostReviewInput) -> str | None:
613 """Upsert the advisory comment (when there are findings); log the ledger."""
614 from froot.adapters.github import GitHubForge
615 
616 body = render_review_comment(params.findings, params.pr.head_sha)
617 url: str | None = None
618 if body is not None:
619 url = await GitHubForge().upsert_issue_comment(
620 params.target, params.pr.number, REVIEW_MARKER, body
621 )
622 _review_log.info(
623 json.dumps(
624 {
625 "event": "loop_outcome",
626 "loop": "determinism-review",
627 "repo": params.target.repo.slug,
628 "pr": params.pr.number,
629 "head_sha": params.pr.head_sha,
630 "findings": len(params.findings),
631 "rules": sorted({f.rule for f in params.findings}),
632 "comment_url": url,
633 }
634 )
635 )
636 return url

The environment fingerprint — trust resets on a model swap (P4.3b)

Trust is conditional on the environment that earned it (§3.7). Every recorded PR is stamped with a froot-env:<model> label; the dashboard and the gate count only PRs from the current environment, so a judge-model swap resets each class's record rather than letting trust silently ride across a capability change.

src/froot/policy/environment.py · 56 lines
src/froot/policy/environment.py56 lines · Python
1"""The environment fingerprint — what trust was earned *under* (§3.7).
2 
3MHE's "conditional" property: trust belongs to a task class *in a specific
4environment*, and when the environment changes some trust resets. The dominant
5piece of froot's judgment environment is the judge model — a changelog verdict
6from ``gemma4:26b`` is not the same evidence as one from ``gemma4:e4b`` — so a
7track record earned under one model must not silently transfer to another
8("The model underneath changes... Trust drops from auto-merge back to
9propose-plus-review until the new model re-earns it", §3.7).
10 
11froot stamps each PR with the environment it was opened under (a ``froot-env:``
12label), and the gate counts only the record earned under the *current*
13environment. A model swap therefore resets every class to unearned until fresh
14PRs accrue — exactly the calibration window §3.7 prescribes, and with no failure
15required, just a changed anchor. The slug is kept human-readable rather than an
16opaque hash so a steward can see at a glance which environment earned the trust.
17 
18Today the fingerprint captures the model dimension; tools, permissions, and
19codebase shape are the other dimensions §3.7 names, and fold in here when froot
20learns to track them.
21"""
22 
23from __future__ import annotations
24 
25import re
26from typing import Final
27 
28_LABEL_PREFIX: Final = "froot-env:"
29_SLUG_RE: Final = re.compile(r"[^a-z0-9]+")
30 
31 
32def environment_slug(model: str) -> str:
33 """A short, legible slug of the judgment environment (currently the model).
34 
35 ``gemma4:26b`` -> ``gemma4-26b``. Readable on purpose: the steward should be
36 able to read which environment a class's trust was earned under.
37 """
38 slug = _SLUG_RE.sub("-", model.strip().lower()).strip("-")
39 return slug or "unknown"
40 
41 
42def env_label(model: str) -> str:
43 """The label that stamps a PR with the environment it was opened under."""
44 return _LABEL_PREFIX + environment_slug(model)
45 
46 
47def env_from_labels(names: set[str]) -> str | None:
48 """The environment slug recovered from a PR's ``froot-env:`` label.
49 
50 ``None`` if the PR carries no such stamp (opened before stamping existed — a
51 prior environment by definition, so it does not count for the current one).
52 """
53 for name in names:
54 if name.startswith(_LABEL_PREFIX):
55 return name[len(_LABEL_PREFIX) :]
56 return None

Honest framing & verification

The acting flip made the gate load-bearing, so the prose that called it a dry run that 'acts on nothing' became the most dangerous kind of stale doc — it understates the authority a steward is granting. The dashboard footer now states the conditional merge authority plainly and the class-gate note names all four legs.

src/froot/dashboard/render.py · 883 lines
src/froot/dashboard/render.py883 lines · Python
⋯ 708 lines hidden (lines 1–708)
1"""Render the view model to one self-contained HTML page (pure).
2 
3All CSS is inline, there is no JavaScript, and the page makes no network
4request of its own — it is a static projection of an already-computed
5:class:`~froot.dashboard.model.DashboardModel`. Every dynamic value is
6HTML-escaped at the boundary. The ordering is trust-first (is it alive → track
7record → oracle → judgment → the human's queue), so it reads top to bottom.
8"""
9 
10from __future__ import annotations
11 
12from datetime import UTC, datetime, timedelta
13from html import escape
14from typing import TYPE_CHECKING
15 
16if TYPE_CHECKING:
17 from froot.dashboard.model import (
18 BumpRow,
19 ClassGate,
20 DashboardModel,
21 ReviewLoop,
22 ReviewRow,
23 RunTelemetry,
24 ScanLoop,
25 )
26 
27_CSS = """
28:root{--fg:#1a1a1a;--mut:#6b6b6b;--line:#e4e4e4;--bg:#fff;--ok:#1a7f37;
29--warn:#9a6700;--bad:#cf222e;--accent:#0969da;--card:#fafafa}
30@media(prefers-color-scheme:dark){:root{--fg:#e6e6e6;--mut:#9a9a9a;
31--line:#262626;--bg:#0d0d0d;--ok:#3fb950;--warn:#d29922;--bad:#f85149;
32--accent:#58a6ff;--card:#141414}}
33*{box-sizing:border-box}
34body{margin:0;background:var(--bg);color:var(--fg);
35font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
36main{max-width:820px;margin:0 auto;padding:34px 20px 72px}
37h1{font-size:22px;margin:0;letter-spacing:-.01em}
38.tag{color:var(--mut);margin:3px 0 0;font-size:13px}
39.meta{color:var(--mut);font-size:12px;margin:12px 0 0}
40.sources{display:flex;flex-wrap:wrap;gap:16px;margin:10px 0 0;font-size:12px}
41section{margin:30px 0 0}
42h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;
43color:var(--mut);margin:0 0 12px;font-weight:600;
44border-bottom:1px solid var(--line);padding-bottom:6px}
45.stats{display:flex;flex-wrap:wrap;gap:26px}
46.stat .n{font-size:26px;font-weight:600;line-height:1.1}
47.stat .l{color:var(--mut);font-size:12px;margin-top:2px}
48.dot{display:inline-block;width:9px;height:9px;border-radius:50%;
49margin-right:7px}
50.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}
51.dot.bad{background:var(--bad)}.dot.mute{background:var(--mut)}
52table{width:100%;border-collapse:collapse;font-size:13px}
53th,td{text-align:left;padding:6px 12px 6px 0;
54border-bottom:1px solid var(--line);vertical-align:top}
55th{color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;
56letter-spacing:.04em}
57.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
58a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
59.row{display:flex;align-items:baseline;gap:8px;padding:4px 0}
60.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}
61.bad{color:var(--bad)}
62.note{color:var(--mut);font-size:12px;margin:10px 0 0}
63footer{margin:44px 0 0;padding-top:16px;border-top:1px solid var(--line);
64color:var(--mut);font-size:12px;line-height:1.7}
65footer b{color:var(--fg);font-weight:600}
66/* loop groups — collapsible (collapsed by default), two-level hierarchy */
67details.loop{margin:24px 0 0}
68summary.loophead{display:flex;align-items:baseline;gap:6px 12px;flex-wrap:wrap;
69cursor:pointer;list-style:none;margin:0;padding:0 0 7px;
70border-bottom:2px solid var(--fg)}
71summary.loophead::-webkit-details-marker{display:none}
72summary.loophead::before{content:"\\25B8";color:var(--mut);margin-right:5px;
73font-size:11px;position:relative;top:-1px}
74details.loop[open]>summary.loophead::before{content:"\\25BE"}
75summary.loophead h2{font-size:16px;text-transform:none;letter-spacing:-.01em;
76color:var(--fg);border:0;margin:0;padding:0;font-weight:700}
77summary.loophead .sub{color:var(--mut);font-size:12px}
78summary.loophead .glance{margin-left:auto;display:flex;flex-wrap:wrap;
79align-items:baseline;gap:3px 14px;font-size:12px;color:var(--mut)}
80summary.loophead .glance b{color:var(--fg);font-weight:600}
81/* the shared-infra group reads as not-a-loop: lighter, dashed, muted */
82details.loop.shared>summary.loophead{border-bottom:1px dashed var(--line)}
83details.loop.shared>summary.loophead h2{font-size:13px;color:var(--mut);
84font-weight:600;text-transform:uppercase;letter-spacing:.06em}
85/* subsections sit tighter inside a loop; the loop rule carries the weight */
86details.loop section{margin:18px 0 0}
87details.loop section>h2{border-bottom:0;padding-bottom:0;margin-bottom:9px}
88/* foldable framing — the MHE notes, one click away, so the page scans clean */
89details.why{margin:9px 0 0}
90details.why>summary{color:var(--accent);font-size:12px;cursor:pointer;
91list-style:none}
92details.why>summary::-webkit-details-marker{display:none}
93details.why>summary::before{content:"\\002b why";font-weight:600}
94details.why[open]>summary::before{content:"\\2212 why"}
95details.why .note{margin:6px 0 0}
96"""
97 
98_CI_CLASS = {
99 "passed": "ok",
100 "failed": "bad",
101 "absent": "mut",
102 "timed_out": "warn",
104_VERDICT_CLASS = {"clean": "ok", "risky": "warn", "unknown": "mut"}
105_POST_MERGE_CLASS = {
106 "held": "ok",
107 "broke": "bad",
108 "reverted": "bad",
109 "unknown": "mut",
111 
112 
113def _aware(when: datetime) -> datetime:
114 """Treat a stray naive timestamp as UTC so arithmetic never raises."""
115 return when if when.tzinfo is not None else when.replace(tzinfo=UTC)
116 
117 
118def _ago(when: datetime | None, now: datetime) -> str:
119 """A compact 'time since' label (``6h ago``)."""
120 if when is None:
121 return ""
122 secs = (now - _aware(when)).total_seconds()
123 if secs < 90:
124 return "just now"
125 if secs < 5400:
126 return f"{int(secs // 60)}m ago"
127 if secs < 129600:
128 return f"{int(secs // 3600)}h ago"
129 return f"{int(secs // 86400)}d ago"
130 
131 
132def _until(when: datetime | None, now: datetime) -> str:
133 """A compact 'time until' label (``in 18h`` / ``due now``)."""
134 if when is None:
135 return ""
136 secs = (_aware(when) - now).total_seconds()
137 if secs <= 0:
138 return "due now"
139 if secs < 5400:
140 return f"in {int(secs // 60)}m"
141 if secs < 129600:
142 return f"in {int(secs // 3600)}h"
143 return f"in {int(secs // 86400)}d"
144 
145 
146def _dot(kind: str) -> str:
147 """A status dot span (``ok`` / ``warn`` / ``bad`` / ``mute``)."""
148 return f'<span class="dot {kind}"></span>'
149 
150 
151def _tag(value: str | None, classes: dict[str, str]) -> str:
152 """A small coloured label for a verdict/CI value, or an em-dash."""
153 if value is None:
154 return '<span class="mut">—</span>'
155 cls = classes.get(value, "mut")
156 return f'<span class="{cls}">{escape(value)}</span>'
157 
158 
159def _stat(n: object, label: str) -> str:
160 return (
161 f'<div class="stat"><div class="n">{escape(str(n))}</div>'
162 f'<div class="l">{escape(label)}</div></div>'
163 )
164 
165 
166def _why(note_html: str) -> str:
167 """Fold a framing note behind a native ``<details>`` so the page scans."""
168 return f'<details class="why"><summary></summary>{note_html}</details>'
169 
170 
171def _glance(n: object, label: str) -> str:
172 """One at-a-glance item for a loop header (``<b>12</b> proposed``)."""
173 return f"<span><b>{escape(str(n))}</b> {escape(label)}</span>"
174 
175 
176def _header(model: DashboardModel) -> str:
177 now = model.generated_at
178 repos = ", ".join(model.repos_configured) or "none configured"
179 dots = "".join(
180 f"<span>{_dot('ok' if s.ok else 'bad')}"
181 f'{escape(s.name)} <span class="mut">{escape(s.detail)}</span></span>'
182 for s in model.sources
183 )
184 stamp = now.strftime("%Y-%m-%d %H:%M UTC")
185 return (
186 "<header>"
187 "<h1>froot</h1>"
188 '<p class="tag">durable maintenance loops &middot; '
189 "reputation read-model</p>"
190 f'<p class="meta">watching <span class="mono">{escape(repos)}</span>'
191 f" &middot; generated {escape(stamp)} &middot; "
192 "derived live, stored nowhere</p>"
193 f'<div class="sources">{dots}</div>'
194 "</header>"
195 )
196 
197 
198def _heartbeat(model: DashboardModel) -> str:
199 now = model.generated_at
200 interval = model.scan_interval_seconds
201 
202 def line(loop: ScanLoop) -> str:
203 if loop.live:
204 dot, tail = "ok", ""
205 if loop.last_tick is not None:
206 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
207 last = _ago(loop.last_tick, now)
208 tail = (
209 f' <span class="mut">&middot; last {last}'
210 f" &middot; next {_until(nxt, now)}</span>"
211 )
212 else:
213 dot = "bad" if loop.status in ("terminated", "none") else "warn"
214 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
215 return (
216 f'<div class="row">{_dot(dot)}'
217 f'<span class="mono">{escape(loop.repo)}</span>'
218 f' <span class="mut">{escape(loop.loop)}</span>{tail}</div>'
219 )
220 
221 if not model.scan_loops:
222 body = '<p class="note">No repos configured (FROOT_REPOS unset).</p>'
223 else:
224 body = "".join(line(loop) for loop in model.scan_loops)
225 return f"<section><h2>Is it alive?</h2>{body}</section>"
226 
227 
228def _track_record(model: DashboardModel) -> str:
229 t = model.track_record
230 rate = "" if t.merge_rate is None else f"{t.merge_rate * 100:.0f}%"
231 ttm = (
232 ""
233 if t.median_ttm_minutes is None
234 else f"{t.median_ttm_minutes:.0f} min"
235 )
236 stats = "".join(
237 (
238 _stat(t.opened, "proposed"),
239 _stat(t.merged, "merged"),
240 _stat(t.open_now, "awaiting"),
241 _stat(t.closed_unmerged, "closed"),
242 _stat(rate, "merge rate"),
243 _stat(ttm, "median time-to-merge"),
244 )
245 )
246 note = (
247 '<p class="note">Merge rate is the Stage-1 signal &mdash; narrow to '
248 "npm patch bumps by construction. It counts a human merge, not a "
249 "confirmed good outcome: revert tracking is a later loop, so merge is "
250 "not yet proof of success.</p>"
251 )
252 return (
253 "<section><h2>Track record &middot; the reputation</h2>"
254 f'<div class="stats">{stats}</div>{_why(note)}</section>'
255 )
256 
257 
258def _rate_cell(rate: float | None) -> str:
259 """A class's approval rate as a percent, or an em-dash if none decided."""
260 if rate is None:
261 return '<span class="mut">—</span>'
262 return f"{rate * 100:.0f}%"
263 
264 
265def _earned_cell(g: ClassGate) -> str:
266 """A class's gate standing: a green 'earned', else the muted blocker.
267 
268 A model change resets the record (§3.7), which can read as a mysterious drop
269 to zero; when prior-environment history exists, it is named so the reset is
270 legible rather than alarming.
271 """
272 reset = (
273 f' <span class="mut">&middot; reset: {g.prior_env_decided} under a '
274 "prior env</span>"
275 if g.prior_env_decided
276 else ""
277 )
278 if g.earned:
279 return f'{_dot("ok")}<span class="ok">earned</span>{reset}'
280 blocker = escape(g.blocker or "not earned")
281 return f'{_dot("mut")}<span class="mut">{blocker}</span>{reset}'
282 
283 
284def _budget_cell(g: ClassGate) -> str:
285 """The gate in steward-time: approvals/week now &rarr; reclaimable/week."""
286 return (
287 f'<span class="mono">{g.approvals_per_week:.1f}</span> appr'
288 f' <span class="mut">&rarr; {g.reclaim_per_week:.1f} reclaimable</span>'
289 )
290 
291 
292def _defect_cell(g: ClassGate) -> str:
293 """The post-merge defect bearing: rate over confirmed-held outcomes."""
294 if g.determined == 0:
295 return '<span class="mut">— (0 confirmed)</span>'
296 rate = g.defect_rate if g.defect_rate is not None else 0.0
297 cls = "ok" if g.defects == 0 else "bad"
298 return (
299 f'<span class="{cls}">{rate * 100:.0f}%</span>'
300 f' <span class="mut">({g.defects}/{g.determined})</span>'
301 )
302 
303 
304def _class_gates(model: DashboardModel) -> str:
305 """The earned-autonomy panel — the gate's per-class standing.
306 
307 Renders whether each (repo, loop) class has earned its gate move (the two
308 record bearings: rate + defect rate) and the steward-budget that move
309 reclaims. On an allowlisted repo a met gate auto-merges; elsewhere — the
310 default — it is the advisory shadow gate a steward watches.
311 """
312 gates = model.class_gates
313 if not gates:
314 body = (
315 '<p class="note">No classes yet &mdash; a (repo, loop) earns a '
316 "gate move from its own track record.</p>"
317 )
318 else:
319 rows = "".join(
320 "<tr>"
321 f'<td class="mono">{escape(g.repo)}</td>'
322 f'<td class="mut">{escape(g.loop)}</td>'
323 f"<td>{g.decided} in {g.window_days}d</td>"
324 f"<td>{_rate_cell(g.merge_rate)}</td>"
325 f"<td>{_defect_cell(g)}</td>"
326 f"<td>{_earned_cell(g)}</td>"
327 f"<td>{_budget_cell(g)}</td>"
328 "</tr>"
329 for g in gates
330 )
331 body = (
332 "<table><thead><tr><th>repo</th><th>loop</th><th>decided</th>"
333 "<th>rate</th><th>defect</th><th>gate</th><th>budget / week</th>"
334 "</tr></thead>"
335 f"<tbody>{rows}</tbody></table>"
336 )
337 note = (
338 '<p class="note">Each <b>class</b> is one loop on one repo &mdash; a '
339 "distinct trust unit (a security patch is not a version bump, and they "
340 "never share a record). A class earns its gate by <b>triangulation</b> "
341 "(&sect;3.8): a high enough <b>approval rate</b> over enough "
342 "recently-<b>decided</b> PRs <i>and</i> a low <b>defect rate</b> over "
343 "enough confirmed-held merges &mdash; two bearings that fail "
344 "differently (the rate to rubber-stamping, the defect rate to a weak "
345 "oracle), so they can&rsquo;t both lie at once. The window keeps trust "
346 "recent. <b>Budget</b> reads the gate in steward-time: approvals/week "
347 "the class costs now &rarr; the reclaimable slice a move hands back. "
348 "Two further legs guard the live merge beyond this panel: an "
349 "<b>adversarial gate self-test</b> (a known-bad class must never be "
350 "granted) and an <b>independent deep review</b> of each bump at merge. "
351 "Where a repo is <b>allowlisted</b> a met gate auto-merges; the "
352 "allowlist is empty by default &mdash; the revocable switch &mdash; so "
353 "elsewhere this stays advisory.</p>"
354 )
355 return (
356 "<section><h2>Earned autonomy &middot; the gate</h2>"
357 f"{body}{_why(note)}</section>"
358 )
359 
360 
361def _verification(model: DashboardModel) -> str:
362 v = model.verification
363 stats = "".join(
364 (
365 _stat(v.passed, "CI passed"),
366 _stat(v.failed, "CI failed"),
367 _stat(v.absent, "no checks"),
368 _stat(v.timed_out, "timed out"),
369 _stat(v.unknown, "unknown"),
370 )
371 )
372 if v.with_reading == 0:
373 note = '<p class="note">No CI readings yet.</p>'
374 else:
375 note = _why(
376 f'<p class="note">A real oracle reported on '
377 f"<b>{v.oracle_existed}</b> of {v.with_reading} bumps with a "
378 'reading. <span class="mut">&lsquo;no checks&rsquo; means CI was '
379 "absent &mdash; not a pass; never conflated.</span>"
380 "</p>"
381 )
382 return (
383 "<section><h2>Verification &middot; CI is the oracle</h2>"
384 f'<div class="stats">{stats}</div>{note}</section>'
385 )
386 
387 
388def _reliability(model: DashboardModel) -> str:
389 r = model.reliability
390 rate = "" if r.defect_rate is None else f"{r.defect_rate * 100:.0f}%"
391 stats = "".join(
392 (
393 _stat(r.held, "held"),
394 _stat(r.broke, "broke"),
395 _stat(r.reverted, "reverted"),
396 _stat(r.unverified, "unverified"),
397 _stat(rate, "defect rate"),
398 )
399 )
400 if r.determined == 0:
401 note = (
402 '<p class="note">No post-merge outcomes determined yet '
403 f"(last {r.window_days}d). A merge is only a *success* once it "
404 "holds; until then it&rsquo;s just merged.</p>"
405 )
406 else:
407 note = _why(
408 '<p class="note">Did the merge <b>hold</b>? &mdash; the outcome '
409 "leg, the only bearing that watches what actually shipped. "
410 f"<b>{r.determined}</b> of the recent merges were classifiable; "
411 "the defect rate is a <b>floor</b>, not the truth: it sees the "
412 "branch&rsquo;s CI and git-reverts, but a <i>manual</i> or "
413 "<i>bundled</i> revert (most of them) is invisible. "
414 '<span class="mut">&lsquo;unverified&rsquo; means no branch '
415 "oracle &mdash; never counted as held.</span></p>"
416 )
417 return (
418 "<section><h2>Reliability &middot; did the merge hold?</h2>"
419 f'<div class="stats">{stats}</div>{note}</section>'
420 )
421 
422 
423def _probes(model: DashboardModel) -> str:
424 p = model.probes
425 stats = "".join(
426 (
427 _stat(p.caught, "caught"),
428 _stat(p.escaped, "escaped"),
429 _stat(p.pending, "pending"),
430 )
431 )
432 if p.total == 0:
433 note = (
434 '<p class="note">No adversarial probes yet. A <b>canary</b> '
435 "&mdash; a deliberately-bad bump &mdash; tests whether the "
436 "guardrail still bites; it needs no volume to be informative, so "
437 "it carries weight while the rate-based bearings are thin "
438 "(&sect;2.11).</p>"
439 )
440 elif p.escaped:
441 note = (
442 f'<p class="note"><span class="bad">&#9888; <b>{p.escaped}</b> '
443 "canary(s) <b>escaped</b> &mdash; a known-bad bump landed, so the "
444 "guardrail has a hole.</span> This is the alarm the probe exists "
445 "to raise.</p>"
446 )
447 else:
448 note = _why(
449 '<p class="note">Every canary was <b>caught</b> &mdash; the '
450 "guardrail refused the planted bad bumps. Probes are scored on a "
451 "strict bar (a canary must never merge) and kept <b>out</b> of the "
452 "track record and defect rate, so a synthetic failure never "
453 "pollutes the real bearings.</p>"
454 )
455 return (
456 "<section><h2>Adversarial probes &middot; does the guardrail bite?</h2>"
457 f'<div class="stats">{stats}</div>{note}</section>'
458 )
459 
460 
461def _judgment(model: DashboardModel) -> str:
462 j = model.judgment
463 stats = "".join(
464 (
465 _stat(j.clean, "clean"),
466 _stat(j.risky, "risky"),
467 _stat(j.unknown, "unknown"),
468 _stat(j.none, "no verdict"),
469 )
470 )
471 if j.clean_but_failed or j.flagged_but_passed:
472 note = (
473 f'<p class="note">Calibration: <b>{j.clean_but_failed}</b> '
474 "&lsquo;clean&rsquo; bumps whose CI failed, "
475 f"<b>{j.flagged_but_passed}</b> flagged bumps whose CI passed.</p>"
476 )
477 else:
478 note = (
479 '<p class="note">The model&rsquo;s only job is the changelog '
480 "verdict; the spine proposes the bump either way.</p>"
481 )
482 return (
483 "<section><h2>Model judgment &middot; the one model call</h2>"
484 f'<div class="stats">{stats}</div>{_why(note)}</section>'
485 )
486 
487 
488def _shadow_badge(row: BumpRow) -> str:
489 """Whether this open PR meets its class's auto-merge grant.
490 
491 These PRs are still open, so on an allowlisted repo a met grant would
492 already have merged: a green 'would auto-merge' here means the grant is met
493 but the repo is not allowlisted (or a deeper check held it), so it is still
494 yours. Otherwise the first blocker, muted — the held reason to fix.
495 """
496 if row.would_auto_merge:
497 return '<span class="ok">would auto-merge</span>'
498 reason = row.held_reason or "held"
499 return f'<span class="mut">held &middot; {escape(reason)}</span>'
500 
501 
502def _gate(model: DashboardModel) -> str:
503 now = model.generated_at
504 if not model.gate:
505 body = '<p class="note">Queue empty &mdash; nothing awaiting you.</p>'
506 else:
507 rows = "".join(
508 "<tr>"
509 f'<td class="mono">{escape(row.package)}</td>'
510 f"<td>{escape(_ago(row.opened_at, now))}</td>"
511 f"<td>{_shadow_badge(row)}</td>"
512 f"<td>{_pr_link(row)}</td>"
513 "</tr>"
514 for row in model.gate
515 )
516 note = _why(
517 '<p class="note">These PRs are open and <b>yours</b>. A green '
518 "&lsquo;would auto-merge&rsquo; means this PR met its "
519 "class&rsquo;s grant &mdash; on an allowlisted repo the loop would "
520 "have merged it already, so its presence here means the repo is "
521 "not allowlisted (or a deeper check held it). The verdict rests on "
522 "the single CI "
523 "oracle and a merge-rate record, which (esp. for a security patch) "
524 "is not the same as a confirmed-good or security-reviewed outcome. "
525 "You own every open merge.</p>"
526 )
527 body = (
528 "<table><thead><tr><th>package</th><th>waiting</th>"
529 "<th>shadow gate</th><th>pr</th>"
530 f"</tr></thead><tbody>{rows}</tbody></table>{note}"
531 )
532 return (
533 "<section><h2>Approval gate &middot; what a human owns</h2>"
534 f"{body}</section>"
535 )
536 
537 
538def _bumps(model: DashboardModel) -> str:
539 now = model.generated_at
540 if not model.bumps:
541 body = '<p class="note">No bumps proposed yet.</p>'
542 else:
543 rows = "".join(
544 "<tr>"
545 f'<td class="mono">{escape(row.package)}</td>'
546 f'<td class="mono mut">{escape(row.from_version or "?")} &rarr; '
547 f"{escape(row.to_version)}</td>"
548 f"<td>{_tag(row.verdict, _VERDICT_CLASS)}</td>"
549 f"<td>{_tag(row.ci, _CI_CLASS)}</td>"
550 f"<td>{_state_tag(row.state)}</td>"
551 f"<td>{_tag(row.post_merge, _POST_MERGE_CLASS)}</td>"
552 f'<td class="mut">{escape(_ago(row.opened_at, now))}</td>'
553 f"<td>{_pr_link(row)}</td>"
554 "</tr>"
555 for row in model.bumps
556 )
557 body = (
558 "<table><thead><tr><th>package</th><th>bump</th><th>verdict</th>"
559 "<th>ci</th><th>state</th><th>held?</th><th>opened</th><th>pr</th>"
560 "</tr></thead>"
561 f"<tbody>{rows}</tbody></table>"
562 )
563 return f"<section><h2>Bumps &middot; the detail</h2>{body}</section>"
564 
565 
566def _failures(model: DashboardModel) -> str:
567 if not model.failures:
568 return ""
569 now = model.generated_at
570 rows = "".join(
571 "<tr>"
572 f'<td class="mono">{escape(_short_id(f.workflow_id))}</td>'
573 f"<td>{_state_tag(f.kind)}</td>"
574 f'<td class="mut">{escape(f.reason or "")}</td>'
575 f'<td class="mut">{escape(_ago(f.when, now))}</td>'
576 "</tr>"
577 for f in model.failures
578 )
579 return (
580 "<section><h2>Failures &middot; where the loop did not close</h2>"
581 "<table><thead><tr><th>bump</th><th>kind</th><th>reason</th>"
582 f"<th>when</th></tr></thead><tbody>{rows}</tbody></table></section>"
583 )
584 
585 
586def _telem_glance(model: DashboardModel) -> str:
587 """The telemetry group's at-a-glance (or an ``unavailable`` marker)."""
588 t = model.telemetry
589 if not t.available:
590 return '<span class="mut">unavailable</span>'
591 return _glance(t.total_spans, f"spans / {t.window_days}d")
592 
593 
594def _telemetry(model: DashboardModel) -> str:
595 """The telemetry body — header lives in the shared loop group above it."""
596 t: RunTelemetry = model.telemetry
597 if not t.available:
598 return (
599 "<section>"
600 '<p class="note">Unavailable (not configured or unreachable). '
601 "GitHub + Temporal carry the dashboard regardless.</p></section>"
602 )
603 now = model.generated_at
604 if t.activities:
605 rows = "".join(
606 "<tr>"
607 f'<td class="mono">{escape(a.name)}</td>'
608 f"<td>{a.count}</td>"
609 f'<td class="mut">{a.avg_ms:.0f} ms</td>'
610 f'<td class="mut">{a.max_ms:.0f} ms</td>'
611 "</tr>"
612 for a in t.activities
613 )
614 table = (
615 "<table><thead><tr><th>activity</th><th>runs</th><th>avg</th>"
616 f"<th>max</th></tr></thead><tbody>{rows}</tbody></table>"
617 )
618 else:
619 table = '<p class="note">No froot spans in the window.</p>'
620 summary = (
621 f'<p class="note">{t.total_spans} spans &middot; '
622 f"{t.error_spans} errored &middot; last activity "
623 f"{escape(_ago(t.last_activity, now))} &middot; "
624 f"{t.window_days}-day window.</p>"
625 )
626 return f"<section>{summary}{table}</section>"
627 
628 
629def _review_heartbeat(model: DashboardModel) -> str:
630 now = model.generated_at
631 interval = model.review_interval_seconds
632 
633 def line(loop: ReviewLoop) -> str:
634 if loop.live:
635 dot, tail = "ok", ""
636 if loop.last_tick is not None:
637 nxt = _aware(loop.last_tick) + timedelta(seconds=interval)
638 last = _ago(loop.last_tick, now)
639 tail = (
640 f' <span class="mut">&middot; last {last}'
641 f" &middot; next {_until(nxt, now)}</span>"
642 )
643 else:
644 dot = "bad" if loop.status in ("terminated", "none") else "warn"
645 tail = f' <span class="mut">&middot; {escape(loop.status)}</span>'
646 return (
647 f'<div class="row">{_dot(dot)}'
648 f'<span class="mono">{escape(loop.repo)}</span>{tail}</div>'
649 )
650 
651 if not model.review_loops:
652 body = (
653 '<p class="note">No determinism-review loops running '
654 "(the transitive ring watches the @workflow.defn repos).</p>"
655 )
656 else:
657 body = "".join(line(loop) for loop in model.review_loops)
658 return f"<section><h2>Is it alive?</h2>{body}</section>"
659 
660 
661def _review_record(model: DashboardModel) -> str:
662 r = model.review_record
663 stats = "".join(
664 (
665 _stat(r.reviewed, "reviewed"),
666 _stat(r.flagged, "flagged"),
667 _stat(r.clean, "clean"),
668 _stat(r.hazards, "hazards"),
669 _stat(r.repos_covered, "repos covered"),
670 )
671 )
672 note = (
673 '<p class="note">The transitive ring: it chases first-party helper '
674 "calls out of each workflow to catch a hazard the lexical CI kernel "
675 "can&rsquo;t see. <b>Advisory</b> &mdash; the blocking gate stays the "
676 "kernel&rsquo;s CI check. The hazard-resolved rate (was a flag gone on "
677 "a later commit?) is a later loop; it needs accumulated history.</p>"
678 )
679 return (
680 "<section><h2>Track record &middot; the reputation</h2>"
681 f'<div class="stats">{stats}</div>{_why(note)}</section>'
682 )
683 
684 
685def _reviews(model: DashboardModel) -> str:
686 now = model.generated_at
687 if not model.reviews:
688 body = '<p class="note">No PRs reviewed yet.</p>'
689 else:
690 rows = "".join(
691 "<tr>"
692 f'<td class="mono">{escape(row.repo)}</td>'
693 f"<td>{_review_pr_link(row)}</td>"
694 f'<td class="mono mut">{escape((row.head_sha or "")[:7]) or ""}'
695 "</td>"
696 f"<td>{_findings_cell(row)}</td>"
697 f'<td class="mut">{escape(_ago(row.reviewed_at, now))}</td>'
698 "</tr>"
699 for row in model.reviews
700 )
701 body = (
702 "<table><thead><tr><th>repo</th><th>pr</th><th>head</th>"
703 "<th>findings</th><th>reviewed</th></tr></thead>"
704 f"<tbody>{rows}</tbody></table>"
705 )
706 return f"<section><h2>Reviews &middot; the detail</h2>{body}</section>"
707 
708 
709def _footer() -> str:
710 return (
711 "<footer>"
712 "<b>Authority envelope.</b> froot holds <b>write authority</b> "
713 "everywhere (it opens PRs) and conditional <b>merge authority</b> "
714 "only on an allowlisted repo where the class has earned its gate "
715 "&mdash; the allowlist is empty by default, so commit authority is "
716 "none until a steward opts a repo in. Trust, when granted, is earned, "
717 "narrow to a single loop on a single repo (a version bump is not a "
718 "security patch; they never share a record), conditional on its "
719 "environment (judge "
720 '<span class="mono">gemma4:26b</span>, lockfile-only regen), revocable '
721 "(the allowlist), and time-expiring. A met gate auto-merges only "
722 "after passing an independent deep review; everywhere not allowlisted "
723 "the same verdict is the advisory <b>shadow gate</b> a steward "
724 "watches.<br>"
725 "Everything above is derived on this request from GitHub (outcomes) + "
726 "Temporal (runs) + ClickHouse (telemetry). froot keeps no database; "
⋯ 157 lines hidden (lines 727–883)
727 "reload to recompute."
728 "</footer>"
729 )
730 
731 
732def _pr_link(row: BumpRow) -> str:
733 if row.pr_url is None or row.pr_number is None:
734 return '<span class="mut">—</span>'
735 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
736 
737 
738def _review_pr_link(row: ReviewRow) -> str:
739 if row.pr_url is None or row.pr_number is None:
740 return '<span class="mut">—</span>'
741 return f'<a href="{escape(row.pr_url, quote=True)}">#{row.pr_number}</a>'
742 
743 
744def _findings_cell(row: ReviewRow) -> str:
745 """A review's findings: 'clean', or the hazard count + rules + comment."""
746 if row.findings == 0:
747 return '<span class="ok">clean</span>'
748 rules = (
749 f' <span class="mono mut">{escape(", ".join(row.rules))}</span>'
750 if row.rules
751 else ""
752 )
753 comment = (
754 f' <a href="{escape(row.comment_url, quote=True)}">comment</a>'
755 if row.comment_url
756 else ""
757 )
758 noun = "hazard" if row.findings == 1 else "hazards"
759 return f'<span class="bad">{row.findings} {noun}</span>{rules}{comment}'
760 
761 
762def _state_tag(state: str) -> str:
763 cls = {
764 "merged": "ok",
765 "open": "warn",
766 "closed": "mut",
767 "terminated": "bad",
768 "failed": "bad",
769 "timed_out": "bad",
770 "canceled": "warn",
771 }.get(state, "mut")
772 return f'<span class="{cls}">{escape(state)}</span>'
773 
774 
775def _short_id(workflow_id: str) -> str:
776 """Drop the ``froot-bump-`` prefix for a readable failures row."""
777 return workflow_id.removeprefix("froot-bump-")
778 
779 
780def _dep_glance(model: DashboardModel) -> str:
781 """The dependency-patch loop's at-a-glance, shown in its header."""
782 t = model.track_record
783 live = sum(1 for loop in model.scan_loops if loop.live)
784 total = len(model.scan_loops)
785 rate = "" if t.merge_rate is None else f"{t.merge_rate * 100:.0f}%"
786 items = [
787 _glance(f"{live}/{total}", "live"),
788 _glance(t.opened, "proposed"),
789 _glance(rate, "merged"),
790 ]
791 if model.class_gates:
792 earned = sum(1 for g in model.class_gates if g.earned)
793 items.append(_glance(f"{earned}/{len(model.class_gates)}", "earned"))
794 if t.open_now:
795 items.append(_glance(t.open_now, "awaiting you"))
796 return "".join(items)
797 
798 
799def _det_glance(model: DashboardModel) -> str:
800 """The determinism-review loop's at-a-glance, shown in its header."""
801 r = model.review_record
802 return "".join(
803 (
804 _glance(r.repos_covered, "repos"),
805 _glance(r.reviewed, "reviewed"),
806 _glance(r.hazards, "hazards"),
807 )
808 )
809 
810 
811def _loop(
812 title: str,
813 sub: str,
814 glance: str,
815 sections: tuple[str, ...],
816 *,
817 shared: bool = False,
818) -> str:
819 """A collapsible group: a prominent header + at-a-glance, collapsed.
820 
821 The header (with its at-a-glance) is the ``<summary>``, so a collapsed group
822 still shows the headline; expanding reveals the subsections. ``shared`` is a
823 non-loop infra group (telemetry) with a lighter, distinct header.
824 """
825 cls = "loop shared" if shared else "loop"
826 head = (
827 '<summary class="loophead">'
828 f"<h2>{escape(title)}</h2>"
829 f'<span class="sub">{sub}</span>'
830 f'<span class="glance">{glance}</span>'
831 "</summary>"
832 )
833 return f'<details class="{cls}">{head}{"".join(sections)}</details>'
834 
835 
836def page(model: DashboardModel) -> str:
837 """Render the whole dashboard as one self-contained HTML document."""
838 parts = (
839 _header(model),
840 _loop(
841 "Dependency-patch",
842 "npm + uv &middot; scan &rarr; bump &rarr; CI &rarr; merge",
843 _dep_glance(model),
844 (
845 _heartbeat(model),
846 _track_record(model),
847 _class_gates(model),
848 _verification(model),
849 _reliability(model),
850 _probes(model),
851 _judgment(model),
852 _gate(model),
853 _bumps(model),
854 _failures(model),
855 ),
856 ),
857 _loop(
858 "Determinism review",
859 "the transitive ring &middot; advisory",
860 _det_glance(model),
861 (
862 _review_heartbeat(model),
863 _review_record(model),
864 _reviews(model),
865 ),
866 ),
867 _loop(
868 "Run telemetry",
869 "ClickHouse &middot; trace-derived, best-effort",
870 _telem_glance(model),
871 (_telemetry(model),),
872 shared=True,
873 ),
874 _footer(),
875 )
876 return (
877 '<!doctype html><html lang="en"><head><meta charset="utf-8">'
878 '<meta name="viewport" content="width=device-width,initial-scale=1">'
879 "<title>froot &middot; read-model</title>"
880 f"<style>{_CSS}</style></head><body><main>"
881 + "".join(parts)
882 + "</main></body></html>"
883 )

Full suite green: 351 tests, mypy clean, ruff format + lint clean, both determinism gates green (no hazard reaches a workflow). New discriminating tests cover the acting flip (state machine + workflow + e2e), the gate self-test (escapes under loosened config), the deep-review veto (eligible but held → not merged), and fail-closed degradation.

tests/test_bump_workflow.py · 225 lines
tests/test_bump_workflow.py225 lines · Python
⋯ 171 lines hidden (lines 1–171)
1"""Integration tests for the bump workflow on a time-skipping test server.
2 
3The activities are mocked (by matching activity name), so this exercises the
4real spine: the driver loop, the effect interpretation, and the durable CI wait
5(whose ``workflow.sleep`` the time-skipping server fast-forwards).
6"""
7 
8from __future__ import annotations
9 
10from collections.abc import Callable
11from typing import Any
12 
13from temporalio import activity
14from temporalio.client import Client
15from temporalio.testing import WorkflowEnvironment
16from temporalio.worker import Worker
17 
18from froot.domain.changelog import ChangelogVerdict, CleanVerdict, RiskyVerdict
19from froot.domain.ci import (
20 CIFailed,
21 CIPassed,
22 CIPending,
23 CIStatus,
24 CITimedOut,
26from froot.domain.outcome import LoopOutcome
27from froot.domain.pull_request import PullRequestRef
28from froot.workflow.bump_workflow import BumpWorkflow
29from froot.workflow.runtime import DATA_CONVERTER
30from froot.workflow.types import (
31 AutoMergeInput,
32 BumpParams,
33 CiCheckInput,
34 CloseInput,
35 GateReviewInput,
36 JudgeInput,
37 MergeInput,
38 OpenPrInput,
39 RecordInput,
41from tests.support import make_candidate, make_pr, make_repo
42 
43_TASK_QUEUE = "froot-test"
44# A scripted CI reply sequence the mock pops through (then falls back to green).
45_ci_replies: list[CIStatus] = []
46# PR numbers the close-on-red path closed, in order.
47_closed: list[int] = []
48# PR numbers the acting gate auto-merged, in order, and the eligibility the
49# mock gate reports (a test flips it to exercise the auto-merge path).
50_merged: list[int] = []
51_eligible: bool = False
52# The verdict the mock gate reviewer returns (clean = approve the merge; a test
53# flips it to a hold to exercise the deep-review veto).
54_gate_verdict: ChangelogVerdict = CleanVerdict(rationale="re-read clean")
55 
56 
57@activity.defn(name="judge_changelog")
58async def _mock_judge(params: JudgeInput) -> ChangelogVerdict:
59 return CleanVerdict(rationale="patch only")
60 
61 
62@activity.defn(name="open_pull_request")
63async def _mock_open_pr(params: OpenPrInput) -> PullRequestRef:
64 return make_pr(number=7)
65 
66 
67@activity.defn(name="check_ci")
68async def _mock_check_ci(params: CiCheckInput) -> CIStatus:
69 return _ci_replies.pop(0) if _ci_replies else CIPassed()
70 
71 
72@activity.defn(name="record_outcome")
73async def _mock_record(params: RecordInput) -> None:
74 return None
75 
76 
77@activity.defn(name="close_pull_request")
78async def _mock_close(params: CloseInput) -> None:
79 _closed.append(params.pr.number)
80 
81 
82@activity.defn(name="auto_merge_eligible")
83async def _mock_eligible(params: AutoMergeInput) -> bool:
84 return _eligible
85 
86 
87@activity.defn(name="gate_review")
88async def _mock_gate_review(params: GateReviewInput) -> ChangelogVerdict:
89 return _gate_verdict
90 
91 
92@activity.defn(name="merge_pull_request")
93async def _mock_merge(params: MergeInput) -> None:
94 _merged.append(params.pr.number)
95 
96 
97_MOCKS: list[Callable[..., Any]] = [
98 _mock_judge,
99 _mock_open_pr,
100 _mock_check_ci,
101 _mock_record,
102 _mock_close,
103 _mock_eligible,
104 _mock_gate_review,
105 _mock_merge,
107 
108 
109async def _pydantic_client(env: WorkflowEnvironment) -> Client:
110 config = env.client.config()
111 config["data_converter"] = DATA_CONVERTER
112 return Client(**config)
113 
114 
115async def _run_bump(*, close_on_red: bool = True) -> LoopOutcome:
116 async with await WorkflowEnvironment.start_time_skipping() as env:
117 client = await _pydantic_client(env)
118 async with Worker(
119 client,
120 task_queue=_TASK_QUEUE,
121 workflows=[BumpWorkflow],
122 activities=_MOCKS,
123 ):
124 return await client.execute_workflow(
125 BumpWorkflow.run,
126 BumpParams(
127 target=make_repo(),
128 candidate=make_candidate(),
129 close_on_red=close_on_red,
130 ),
131 id="bump-test",
132 task_queue=_TASK_QUEUE,
133 )
134 
135 
136async def test_happy_path_green():
137 _ci_replies.clear()
138 _closed.clear()
139 outcome = await _run_bump()
140 assert outcome.pr.number == 7
141 assert outcome.ci_passed
142 assert isinstance(outcome.ci, CIPassed)
143 assert _closed == [] # green PRs are left for the human, never closed
144 
145 
146async def test_ci_failed_closes_pr_and_records_failure():
147 _ci_replies.clear()
148 _closed.clear()
149 _ci_replies.append(CIFailed(failing=("build",)))
150 outcome = await _run_bump()
151 assert not outcome.ci_passed
152 assert isinstance(outcome.ci, CIFailed)
153 # Close-on-red (the default): the red PR is closed, and the outcome is
154 # still recorded (the loop reached its terminal state).
155 assert _closed == [7]
156 
157 
158async def test_ci_failed_left_open_when_close_on_red_off():
159 _ci_replies.clear()
160 _closed.clear()
161 _ci_replies.append(CIFailed(failing=("build",)))
162 outcome = await _run_bump(close_on_red=False)
163 assert isinstance(outcome.ci, CIFailed)
164 # close-on-red off: the red PR is recorded but left open for the human.
165 assert _closed == []
166 
167 
168async def test_ci_pending_then_pass_waits_durably():
169 _ci_replies.clear()
170 _ci_replies.extend([CIPending(), CIPending(), CIPassed()])
171 outcome = await _run_bump()
172 assert outcome.ci_passed
173 
174 
175async def test_ci_timeout_when_never_resolves():
176 _ci_replies.clear()
177 _ci_replies.extend([CIPending()] * 100)
178 outcome = await _run_bump()
179 assert isinstance(outcome.ci, CITimedOut)
180 
181 
182async def test_green_clean_auto_merges_when_eligible_and_review_approves():
183 # The acting gate, full path: a clean+green bump on an earned class is
184 # deep-reviewed and, on approval, auto-merged by the loop.
185 global _eligible
186 _ci_replies.clear()
187 _closed.clear()
188 _merged.clear()
189 _eligible = True
190 try:
191 outcome = await _run_bump()
192 finally:
193 _eligible = False
194 assert outcome.ci_passed
195 assert _merged == [7] # the loop merged it
196 assert _closed == []
197 
198 
199async def test_eligible_but_gate_review_holds_does_not_merge():
200 # The deep-review veto: the class is earned and CI is green, but the
201 # independent gate reviewer holds -> the loop records and leaves it open.
202 global _eligible, _gate_verdict
203 _ci_replies.clear()
204 _merged.clear()
205 _closed.clear()
206 _eligible = True
207 _gate_verdict = RiskyVerdict(rationale="a hidden behavior change")
208 try:
209 outcome = await _run_bump()
210 finally:
211 _eligible = False
⋯ 14 lines hidden (lines 212–225)
212 _gate_verdict = CleanVerdict(rationale="re-read clean")
213 assert outcome.ci_passed
214 assert _merged == [] # the review vetoed the merge
215 assert _closed == [] # green: left open for the human, not closed
216 
217 
218async def test_green_clean_not_merged_when_class_not_eligible():
219 # The default: no grant -> propose-only, the loop never merges (and never
220 # even reaches the deep review).
221 _ci_replies.clear()
222 _merged.clear()
223 outcome = await _run_bump()
224 assert outcome.ci_passed
225 assert _merged == []