alissa-tools-github-revloop 0.12.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alissa/tools/github/revloop/__init__.py +8 -0
- alissa/tools/github/revloop/__main__.py +181 -0
- alissa/tools/github/revloop/alissa.py +315 -0
- alissa/tools/github/revloop/config.py +212 -0
- alissa/tools/github/revloop/ghclient.py +263 -0
- alissa/tools/github/revloop/loop.py +826 -0
- alissa/tools/github/revloop/proc.py +56 -0
- alissa/tools/github/revloop/prreview.py +228 -0
- alissa/tools/github/revloop/state.py +218 -0
- alissa/tools/github/revloop/version +1 -0
- alissa/tools/github/revloop/version.py +46 -0
- alissa_tools_github_revloop-0.12.0.dist-info/METADATA +61 -0
- alissa_tools_github_revloop-0.12.0.dist-info/RECORD +16 -0
- alissa_tools_github_revloop-0.12.0.dist-info/WHEEL +5 -0
- alissa_tools_github_revloop-0.12.0.dist-info/entry_points.txt +3 -0
- alissa_tools_github_revloop-0.12.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
"""The watcher loop.
|
|
2
|
+
|
|
3
|
+
One pass = poll GitHub for pending review requests, decide per PR whether a
|
|
4
|
+
fresh reviewer round is owed, and enqueue it. Rounds are derived from GitHub
|
|
5
|
+
(one *substantive* submitted review per round -- empty-bodied records are
|
|
6
|
+
inline-comment artifacts, not rounds), not from local bookkeeping. Convergence
|
|
7
|
+
comes from either the GitHub review state or the CR6 verdict envelope on the
|
|
8
|
+
Alissa review task.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .alissa import VERDICT_APPROVE, Alissa, Task
|
|
22
|
+
from .config import HUB_ADD, ON_MISSING_SKIP, Config
|
|
23
|
+
from .ghclient import GitHub, PullRequest, RateLimited, Review
|
|
24
|
+
from .proc import CommandError
|
|
25
|
+
from .state import State
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# A reviewer session that has not submitted after this long is presumed dead
|
|
30
|
+
# (skill failure mode: "reviewer session stalls"). The round is re-enqueued --
|
|
31
|
+
# but only with a second signal agreeing: the timer alone cannot tell a dead
|
|
32
|
+
# session from a slow one, and a timer-only re-enqueue double-spends the round
|
|
33
|
+
# (two sessions review it, both submit -- observed live twice: double round-2
|
|
34
|
+
# approves on devloop's PR #11, double approves on this repo's PR #19). See
|
|
35
|
+
# _defer_stale_round for the liveness signal.
|
|
36
|
+
STALE_ROUND_SECONDS = 90 * 60
|
|
37
|
+
|
|
38
|
+
# The floor under the liveness deferral: a live session defers the stale
|
|
39
|
+
# respawn indefinitely -- correct for a genuinely slow round, silent forever
|
|
40
|
+
# for a session that is wedged but still registers tmux activity. Once the
|
|
41
|
+
# newest spawn's age reaches this multiple of STALE_ROUND_SECONDS, the loop
|
|
42
|
+
# posts one "stalled" operator comment per deferral episode (stalled_kind)
|
|
43
|
+
# and keeps deferring. 2 means the deferral itself has lasted a full extra
|
|
44
|
+
# stale window beyond the point the timer first fired -- long enough that a
|
|
45
|
+
# healthy round has almost always submitted by then, early enough that a
|
|
46
|
+
# wedged one surfaces the same day.
|
|
47
|
+
STALLED_DEFER_MULTIPLE = 2
|
|
48
|
+
|
|
49
|
+
# The sweep only reaps a session that has been idle AND quiet this long. The
|
|
50
|
+
# GitHub review count increments the moment a review is submitted, but the
|
|
51
|
+
# reviewer still has close-out work after that (CR6 envelope, task status) --
|
|
52
|
+
# and a claude session parked at its prompt between turns reports "idle", so
|
|
53
|
+
# idleness alone cannot distinguish "between turns" from "done". Recent tmux
|
|
54
|
+
# activity can.
|
|
55
|
+
REAP_QUIET_SECONDS = 5 * 60
|
|
56
|
+
|
|
57
|
+
# The closing contract is spelled out in both directives (not just the skill)
|
|
58
|
+
# because it is the reviewer's most-skipped step: on re-review, sessions produce
|
|
59
|
+
# findings but never register the review on the PR, or stop without a verdict.
|
|
60
|
+
_CLOSE_THE_ROUND = (
|
|
61
|
+
"CLOSE THE ROUND — both are mandatory or the round does not count: "
|
|
62
|
+
"(1) SUBMIT your review so it lands as one registered review record ON the "
|
|
63
|
+
"PR (gh pr review / the reviews API) and confirm it with "
|
|
64
|
+
"`gh api repos/<org>/<repo>/pulls/<n>/reviews` — findings left only in your "
|
|
65
|
+
"session do not exist; (2) end with a decisive verdict — approve OR "
|
|
66
|
+
"request_changes, never neither, never comment-only. You are read-only: "
|
|
67
|
+
"never commit or fix, even a one-character typo — a needed fix IS "
|
|
68
|
+
"request_changes. "
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Reviewers are one-shot per round (CR3), so a finished session should not linger
|
|
72
|
+
# holding a worker slot. The daemon reaps it as a backstop, but the fast path is
|
|
73
|
+
# the reviewer releasing its own slot as its very last action. {session} is the
|
|
74
|
+
# reviewer's own managed session name, injected at spawn.
|
|
75
|
+
_RELEASE_SLOT = (
|
|
76
|
+
"FINALLY, and only once the round is fully closed above (review registered "
|
|
77
|
+
"AND verdict recorded), release your worker slot as your last action: run "
|
|
78
|
+
"`alissa tmux kill {session}`. Do nothing after it."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
ROUND_1_DIRECTIVE = (
|
|
82
|
+
"You are a PR REVIEWER, not an implementer. {assignment} "
|
|
83
|
+
"Load the alissa-code-review skill and follow procedures/review-a-pr.md: "
|
|
84
|
+
"hydrate the task and the PR it names, review per the rubric, post "
|
|
85
|
+
"severity-tagged comments via gh pr review, record the verdict evidence, "
|
|
86
|
+
"move the task to pending_validation. "
|
|
87
|
+
+ _CLOSE_THE_ROUND +
|
|
88
|
+
"NEVER push commits, merge, or change PR state. "
|
|
89
|
+
"Do NOT create further ali-* sessions. "
|
|
90
|
+
+ _RELEASE_SLOT
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
ROUND_K_DIRECTIVE = (
|
|
94
|
+
"You are a PR REVIEWER, not an implementer — round {round} of a review loop "
|
|
95
|
+
"(cap {cap}). {assignment} "
|
|
96
|
+
"Load the alissa-code-review skill and follow procedures/review-a-pr.md "
|
|
97
|
+
"including its round-k section: verify the triage of every prior finding, "
|
|
98
|
+
"verify the fixes, sweep the new diff with the full rubric, record a "
|
|
99
|
+
"round-{round} verdict envelope, move the task to pending_validation. "
|
|
100
|
+
+ _CLOSE_THE_ROUND +
|
|
101
|
+
"NEVER push commits, merge, or change PR state. "
|
|
102
|
+
"Do NOT create further ali-* sessions. "
|
|
103
|
+
+ _RELEASE_SLOT
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
ESCALATION_COMMENT = (
|
|
107
|
+
"**Review loop cap-out (CR9)** — {rounds} rounds ran on this PR without "
|
|
108
|
+
"converging on `approve`. Per the alissa-code-review skill the loop does not "
|
|
109
|
+
"run past the cap and never silently merges; this needs an operator decision "
|
|
110
|
+
"(merge with a recorded waiver, direct specific fixes and re-enter with a "
|
|
111
|
+
"fresh cap, or park it).\n\n"
|
|
112
|
+
"Last verdict: `{last_state}` at `{sha}`."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
STALLED_COMMENT = (
|
|
116
|
+
"**Review round stalled?** — round {round} has been in flight {minutes} min "
|
|
117
|
+
"(stale window: {stale} min), but its reviewer session `{session}` still "
|
|
118
|
+
"shows signs of life, so the daemon keeps deferring the respawn — "
|
|
119
|
+
"respawning over a live session double-spends the round: two reviewers "
|
|
120
|
+
"work it, both submit. Is that session actually making progress? Operator "
|
|
121
|
+
"options: inspect it (`alissa tmux ls`) and, if it is wedged, kill it "
|
|
122
|
+
"(`alissa tmux kill {session}`) so the respawn proceeds next poll, or "
|
|
123
|
+
"finish the round by hand."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# The hidden marker that identifies THE activity comment on a PR. Find-or-create
|
|
127
|
+
# keys on it (plus own authorship -- anyone can paste the marker into their own
|
|
128
|
+
# comment, and a spoofed marker must never be PATCHed), so every append lands in
|
|
129
|
+
# the same single comment however many rounds run.
|
|
130
|
+
ACTIVITY_MARKER = "<!-- alissa-revloop:activity -->"
|
|
131
|
+
|
|
132
|
+
ACTIVITY_HEADER = (
|
|
133
|
+
ACTIVITY_MARKER + "\n"
|
|
134
|
+
"**Review-loop activity** — mechanical spawn/round log; the daemon appends "
|
|
135
|
+
"a line each time it queues (or defers) a reviewer round on this PR."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# The ping-ledger kind prefix for the stalled-deferral operator ping. Unlike
|
|
139
|
+
# the cap-out escalation (terminal per head), a stall can recur, so the kind
|
|
140
|
+
# is narrowed per episode -- see stalled_kind.
|
|
141
|
+
ESCALATION_STALLED = "stalled"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def deferral_activity_kind(session: str) -> str:
|
|
145
|
+
"""The ping-ledger kind that dedupes ONE deferral episode's activity line.
|
|
146
|
+
|
|
147
|
+
A deferral is re-decided every poll, so appending per decision would grow
|
|
148
|
+
the activity comment by one identical line a minute for as long as the
|
|
149
|
+
session holds out. One line per episode carries the same information --
|
|
150
|
+
"the daemon is deferring behind this session" -- and the session name is
|
|
151
|
+
the episode identity (nonce-unique per spawn), exactly as stalled_kind
|
|
152
|
+
reasons for the operator ping. Recorded only after the append lands, so a
|
|
153
|
+
transient failure retries next poll.
|
|
154
|
+
"""
|
|
155
|
+
return f"activity-deferred:{session}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def stalled_kind(session: str) -> str:
|
|
159
|
+
"""The ping-ledger kind that dedupes ONE deferral episode's operator ping.
|
|
160
|
+
|
|
161
|
+
Devloop's stalled_kind reasoning, transposed: a stall can recur -- every
|
|
162
|
+
spawn of every round can wedge mid-flight, and episode k's ping must not
|
|
163
|
+
silence episode k+1's. Keyed on the bare kind (or even on the round), the
|
|
164
|
+
re-enqueue of a round that wedges AGAIN would defer silently forever. The
|
|
165
|
+
session name already IS the episode identity -- nonce-unique per spawn
|
|
166
|
+
(see session_name) -- so it folds into the key. Delivery contract: the
|
|
167
|
+
ledger row lands only AFTER the comment posts (see _escalate_stalled), so
|
|
168
|
+
a transient comment failure retries next poll and the ping lands exactly
|
|
169
|
+
once per episode.
|
|
170
|
+
"""
|
|
171
|
+
return f"{ESCALATION_STALLED}:{session}"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Action(str, Enum):
|
|
175
|
+
SPAWNED = "spawned"
|
|
176
|
+
IN_FLIGHT = "in-flight"
|
|
177
|
+
CONVERGED = "converged"
|
|
178
|
+
CAPPED = "capped"
|
|
179
|
+
ESCALATED = "escalated"
|
|
180
|
+
SKIPPED = "skipped"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class Decision:
|
|
185
|
+
action: Action
|
|
186
|
+
reason: str = ""
|
|
187
|
+
round: int | None = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def session_name(pr: PullRequest, round_: int) -> str:
|
|
191
|
+
"""A tmux-safe reviewer session name, unique per spawn.
|
|
192
|
+
|
|
193
|
+
The `review-<repo>-pr<n>-r<round>` prefix stays human-readable, but a short
|
|
194
|
+
random nonce is appended so a re-used or miscounted round number can never
|
|
195
|
+
collide with a still-live session (a collision wedges the worker -- the
|
|
196
|
+
original 'stuck' failure). Safe to be non-deterministic: the generated name
|
|
197
|
+
is recorded in the spawn ledger and is what gets reaped / self-killed, so the
|
|
198
|
+
daemon never re-derives it.
|
|
199
|
+
"""
|
|
200
|
+
repo = re.sub(r"[^A-Za-z0-9-]", "-", pr.repo).strip("-").lower()
|
|
201
|
+
return f"review-{repo}-pr{pr.number}-r{round_}-{secrets.token_hex(3)}"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class ReviewWatcher:
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
config: Config,
|
|
208
|
+
github: GitHub | None = None,
|
|
209
|
+
alissa: Alissa | None = None,
|
|
210
|
+
state: State | None = None,
|
|
211
|
+
):
|
|
212
|
+
self.config = config
|
|
213
|
+
self.github = github or GitHub(config.reviewer_login)
|
|
214
|
+
self.alissa = alissa or Alissa()
|
|
215
|
+
self.state = state or State(config.state_db)
|
|
216
|
+
|
|
217
|
+
# -- per-PR decision ---------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def evaluate(self, owner: str, repo: str, number: int) -> Decision:
|
|
220
|
+
pr = self.github.pull_request(owner, repo, number)
|
|
221
|
+
|
|
222
|
+
# CR1: draft PRs are never reviewed. The search already filters these;
|
|
223
|
+
# this catches a flip back to draft between search and fetch.
|
|
224
|
+
if pr.draft:
|
|
225
|
+
return Decision(Action.SKIPPED, "PR is a draft (CR1)")
|
|
226
|
+
|
|
227
|
+
if pr.author == self.github.login:
|
|
228
|
+
# GitHub rejects a self review-request, so this should be
|
|
229
|
+
# unreachable -- but a shared bot identity would land here.
|
|
230
|
+
return Decision(
|
|
231
|
+
Action.SKIPPED,
|
|
232
|
+
f"PR author is the reviewer identity ({pr.author}); "
|
|
233
|
+
"GitHub forbids self-review",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
my_reviews = self.github.my_reviews(owner, repo, number)
|
|
237
|
+
|
|
238
|
+
# The review task (CR2) is the round record: one verdict envelope per
|
|
239
|
+
# round (CR7), so counting envelopes is the authoritative "rounds
|
|
240
|
+
# completed" -- immune to the GitHub heuristics that drift. A round whose
|
|
241
|
+
# review has an empty body undercounts (round_ repeats -> the session name
|
|
242
|
+
# collides -> the worker wedges); two reviews in one cycle overcount.
|
|
243
|
+
# Fall back to the substantive-review count only before the review task
|
|
244
|
+
# exists (round 1). Looked up here (not in _spawn) because both the count
|
|
245
|
+
# and convergence need it.
|
|
246
|
+
task = self.alissa.find_review_task(owner, repo, number)
|
|
247
|
+
completed = (
|
|
248
|
+
self.alissa.count_verdicts(task.ref) if task is not None
|
|
249
|
+
else len(my_reviews)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
converged = self._convergence_reason(my_reviews, task, pr.head_sha)
|
|
253
|
+
if converged is not None:
|
|
254
|
+
return Decision(Action.CONVERGED, converged, completed)
|
|
255
|
+
|
|
256
|
+
# CR9: never queue round cap+1.
|
|
257
|
+
if completed >= self.config.round_cap:
|
|
258
|
+
if self.state.escalated(pr.full_name, number, pr.head_sha):
|
|
259
|
+
return Decision(Action.CAPPED, "already escalated", completed)
|
|
260
|
+
self._escalate(pr, my_reviews[-1].state if my_reviews else "none", completed)
|
|
261
|
+
return Decision(Action.ESCALATED, f"{completed} rounds, no approve", completed)
|
|
262
|
+
|
|
263
|
+
round_ = completed + 1
|
|
264
|
+
|
|
265
|
+
age = self.state.spawn_age(pr.full_name, number, round_)
|
|
266
|
+
if age is not None and age < STALE_ROUND_SECONDS:
|
|
267
|
+
return Decision(Action.IN_FLIGHT, f"round {round_} enqueued {int(age)}s ago", round_)
|
|
268
|
+
if age is not None:
|
|
269
|
+
deferred = self._defer_stale_round(pr, round_, age)
|
|
270
|
+
if deferred is not None:
|
|
271
|
+
return deferred
|
|
272
|
+
log.warning(
|
|
273
|
+
"%s round %d has been in flight %.0f min with no submitted review "
|
|
274
|
+
"and its session is gone or finished — re-enqueuing (reviewer "
|
|
275
|
+
"session presumed dead)",
|
|
276
|
+
pr.slug,
|
|
277
|
+
round_,
|
|
278
|
+
age / 60,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return self._spawn(pr, round_, task, reenqueued=age is not None)
|
|
282
|
+
|
|
283
|
+
def _defer_stale_round(self, pr: PullRequest, round_: int, age: float) -> Decision | None:
|
|
284
|
+
"""The liveness signal under the stale timer: a deferral, or None to
|
|
285
|
+
respawn.
|
|
286
|
+
|
|
287
|
+
Staleness needs TWO signals, not one: the ledger timer says the
|
|
288
|
+
newest spawn is old, but elapsed time alone cannot tell a dead
|
|
289
|
+
session from a slow one -- a thorough round can outlast
|
|
290
|
+
STALE_ROUND_SECONDS, and a timer-only re-enqueue respawns a reviewer
|
|
291
|
+
over the still-working first one; two sessions review the same
|
|
292
|
+
round and both submit. So before respawning, consult external
|
|
293
|
+
evidence of life: the round's recorded session in the live list
|
|
294
|
+
(the reap sweep's own probe). Busy, or idle without a real quiet
|
|
295
|
+
period (mid-close-out between turns; see REAP_QUIET_SECONDS) -> the
|
|
296
|
+
round is alive, defer with a reason. Gone, or idle-finished -> dead,
|
|
297
|
+
respawn (the sweep separately handles any corpse). An unprobeable
|
|
298
|
+
live list defers too: respawning on missing evidence is exactly the
|
|
299
|
+
double-spend, and the probe retries next poll.
|
|
300
|
+
|
|
301
|
+
The deferral is floored, not unbounded: past STALLED_DEFER_MULTIPLE
|
|
302
|
+
stale windows with the session still alive, one operator ping per
|
|
303
|
+
deferral episode (stalled_kind) -- then keep deferring. This method
|
|
304
|
+
never respawns over a live session; only the operator killing the
|
|
305
|
+
session (or it finishing/dying) unblocks the respawn.
|
|
306
|
+
"""
|
|
307
|
+
row = self.state.get_spawn(pr.full_name, pr.number, round_)
|
|
308
|
+
if row is None: # age came from this row; belt and braces
|
|
309
|
+
return None
|
|
310
|
+
session = row["session"]
|
|
311
|
+
try:
|
|
312
|
+
live = {s.name: s for s in self.alissa.list_review_sessions()}
|
|
313
|
+
except CommandError as exc:
|
|
314
|
+
log.warning(
|
|
315
|
+
"%s round %d is stale but the session list is unavailable (%s) "
|
|
316
|
+
"— deferring the respawn rather than risking a double-spawned "
|
|
317
|
+
"round; the probe retries next poll",
|
|
318
|
+
pr.slug,
|
|
319
|
+
round_,
|
|
320
|
+
exc,
|
|
321
|
+
)
|
|
322
|
+
return Decision(
|
|
323
|
+
Action.IN_FLIGHT,
|
|
324
|
+
f"round {round_} is stale but liveness is unprobeable — deferring",
|
|
325
|
+
round_,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
ses = live.get(session)
|
|
329
|
+
if ses is None:
|
|
330
|
+
return None # session gone -> presumed dead -> respawn
|
|
331
|
+
if ses.is_idle and time.time() - ses.last_activity >= REAP_QUIET_SECONDS:
|
|
332
|
+
return None # idle-finished: it died without submitting -> respawn
|
|
333
|
+
|
|
334
|
+
if (
|
|
335
|
+
age >= STALLED_DEFER_MULTIPLE * STALE_ROUND_SECONDS
|
|
336
|
+
and not self.state.pinged(pr.full_name, pr.number, stalled_kind(session))
|
|
337
|
+
):
|
|
338
|
+
self._escalate_stalled(pr, round_, session, age)
|
|
339
|
+
|
|
340
|
+
life = "active" if ses.is_idle else "busy"
|
|
341
|
+
if not self.state.pinged(pr.full_name, pr.number, deferral_activity_kind(session)):
|
|
342
|
+
appended = self._append_activity(
|
|
343
|
+
pr,
|
|
344
|
+
self._activity_line(
|
|
345
|
+
session, round_, f"deferred — session `{session}` still {life}"
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
if appended:
|
|
349
|
+
self.state.record_ping(
|
|
350
|
+
pr.full_name, pr.number, deferral_activity_kind(session)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return Decision(
|
|
354
|
+
Action.IN_FLIGHT,
|
|
355
|
+
f"round {round_} is stale ({int(age / 60)} min) but session "
|
|
356
|
+
f"{session} is still {life} — not "
|
|
357
|
+
f"respawning over a live reviewer",
|
|
358
|
+
round_,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def _convergence_reason(
|
|
362
|
+
self, my_reviews: list[Review], task: Task | None, head_sha: str
|
|
363
|
+
) -> str | None:
|
|
364
|
+
"""Why the loop is done, or None if it is not.
|
|
365
|
+
|
|
366
|
+
Two independent signals, because neither alone is sufficient:
|
|
367
|
+
|
|
368
|
+
* The GitHub review state. Authoritative when it says APPROVED, but
|
|
369
|
+
reviewers work in comment mode, which can only ever produce
|
|
370
|
+
COMMENTED -- #210 has zero APPROVED records across its whole history.
|
|
371
|
+
On its own this made convergence unreachable: every PR, however
|
|
372
|
+
clean, ran to the round cap and escalated.
|
|
373
|
+
* The CR6 verdict envelope on the Alissa review task. The review skill
|
|
374
|
+
declares this the verdict of record, and unlike the GitHub state it
|
|
375
|
+
can actually express approval, so it is the signal that closes the
|
|
376
|
+
loop in practice.
|
|
377
|
+
|
|
378
|
+
BOTH are bound to the current head. An approval means "this code is
|
|
379
|
+
good", so once the implementer pushes past the reviewed commit it is
|
|
380
|
+
about old code and the next round is owed. Without this bind a stale
|
|
381
|
+
approve latches the loop shut forever -- #227: round 1 approved
|
|
382
|
+
`fa304de`, the implementer pushed `fd500fc` and re-requested (and even
|
|
383
|
+
dismissed the approve), yet the envelope still read approve and no round
|
|
384
|
+
2 was ever queued.
|
|
385
|
+
"""
|
|
386
|
+
if not my_reviews:
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
# New commits since the newest review -> its verdict is about old code.
|
|
390
|
+
# A falsy commit_id (older records lack one) can't be checked, so it
|
|
391
|
+
# falls through rather than blocking convergence.
|
|
392
|
+
newest = my_reviews[-1]
|
|
393
|
+
if newest.commit_id and newest.commit_id != head_sha:
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
if newest.state == "APPROVED":
|
|
397
|
+
return "last GitHub review state is APPROVED"
|
|
398
|
+
|
|
399
|
+
# Only checkable once a review task exists; before that there is
|
|
400
|
+
# nowhere for a verdict to have been recorded.
|
|
401
|
+
if task is not None and self.alissa.latest_verdict(task.ref) == VERDICT_APPROVE:
|
|
402
|
+
return f"newest verdict envelope on {task.ref} reads approve"
|
|
403
|
+
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
# -- reap sweep --------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
def sweep_sessions(self) -> None:
|
|
409
|
+
"""Kill the managed session of every finished round. Runs every poll.
|
|
410
|
+
|
|
411
|
+
The predecessor of this sweep ran inside evaluate(), which is fed by
|
|
412
|
+
the review-requested:@me search -- and submitting a review CLEARS the
|
|
413
|
+
request, so a finished round's PR vanished from the search at exactly
|
|
414
|
+
the moment its session became reapable; terminal (approved) rounds
|
|
415
|
+
were never reaped and idle reviewer sessions accumulated in the
|
|
416
|
+
worker. The sweep instead starts from the live session list, which
|
|
417
|
+
cannot lose a finished session, and works back to the round via the
|
|
418
|
+
spawn ledger. It must stay search-independent: never move it (back)
|
|
419
|
+
into the evaluate() path.
|
|
420
|
+
|
|
421
|
+
Every-poll cost, honestly: one `alissa tmux ls` when no review-*
|
|
422
|
+
session is live; otherwise one PR fetch per distinct PR with a live
|
|
423
|
+
idle quiet session, plus -- per distinct (PR, task ref) among its
|
|
424
|
+
rows -- exactly one of `alissa task get <ref>` (the row carries a
|
|
425
|
+
task ref) or the reviews fetch (it does not). The ledger ref is used
|
|
426
|
+
deliberately instead of
|
|
427
|
+
find_review_task: that would fetch the actor's ENTIRE task list per
|
|
428
|
+
PR, and its open-status filter would drop a human-validated review
|
|
429
|
+
task back onto the racier GitHub-count fallback. Only individual
|
|
430
|
+
sessions are ever killed (`alissa tmux kill <name>`) -- never the
|
|
431
|
+
server. Best-effort throughout: an undecidable session is spared and
|
|
432
|
+
looked at again next poll.
|
|
433
|
+
"""
|
|
434
|
+
try:
|
|
435
|
+
sessions = self.alissa.list_review_sessions()
|
|
436
|
+
except CommandError as exc:
|
|
437
|
+
log.warning("reap sweep skipped: could not list sessions: %s", exc)
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
# Per-sweep memos. The PR fetch is keyed per distinct PR; the round
|
|
441
|
+
# count additionally keys on the task ref, because two spawns of one
|
|
442
|
+
# PR can disagree on it (a round-1 row recorded before the review
|
|
443
|
+
# task existed carries None). None = undecidable this pass.
|
|
444
|
+
prs: dict[tuple[str, int], PullRequest | None] = {}
|
|
445
|
+
completed_cache: dict[tuple[str, int, str | None], float | None] = {}
|
|
446
|
+
|
|
447
|
+
for ses in sessions:
|
|
448
|
+
if not ses.is_idle:
|
|
449
|
+
# A busy session is still doing something (reviewing, or
|
|
450
|
+
# closing out its round) -- never yank the slot from under it.
|
|
451
|
+
continue
|
|
452
|
+
if time.time() - ses.last_activity < REAP_QUIET_SECONDS:
|
|
453
|
+
# Idle but recently active: likely mid-close-out (the review
|
|
454
|
+
# is submitted before the envelope and task move land). Wait
|
|
455
|
+
# for a real quiet period; see REAP_QUIET_SECONDS.
|
|
456
|
+
continue
|
|
457
|
+
row = self.state.find_spawn_by_session(ses.name)
|
|
458
|
+
if row is None:
|
|
459
|
+
# Not in our ledger: another workspace's daemon (or a human)
|
|
460
|
+
# owns it. Not ours to judge.
|
|
461
|
+
continue
|
|
462
|
+
pr_key = (row["repo"], row["number"])
|
|
463
|
+
if pr_key not in prs:
|
|
464
|
+
prs[pr_key] = self._sweep_pr(row["repo"], row["number"])
|
|
465
|
+
pr = prs[pr_key]
|
|
466
|
+
if pr is None:
|
|
467
|
+
continue # fetch failed -- spare everything on this PR
|
|
468
|
+
key = (row["repo"], row["number"], row["task_ref"])
|
|
469
|
+
if key not in completed_cache:
|
|
470
|
+
completed_cache[key] = self._completed_rounds(pr, row["task_ref"])
|
|
471
|
+
completed = completed_cache[key]
|
|
472
|
+
if completed is None or row["round"] > completed:
|
|
473
|
+
continue # undecidable, or the round is still in flight
|
|
474
|
+
if self.config.dry_run:
|
|
475
|
+
log.info(
|
|
476
|
+
"[dry-run] would reap finished reviewer session %s (round %d done)",
|
|
477
|
+
ses.name, row["round"],
|
|
478
|
+
)
|
|
479
|
+
continue
|
|
480
|
+
try:
|
|
481
|
+
self.alissa.kill_session(ses.name)
|
|
482
|
+
except Exception: # pragma: no cover - defence in depth
|
|
483
|
+
log.exception("failed to reap session %s", ses.name)
|
|
484
|
+
continue
|
|
485
|
+
# Bookkeeping only -- deliberately never consulted before a kill.
|
|
486
|
+
# The live list is the authority; gating on the reaps table would
|
|
487
|
+
# spare any session killed behind the ledger's back.
|
|
488
|
+
self.state.record_reap(ses.name)
|
|
489
|
+
log.info(
|
|
490
|
+
"reaped finished reviewer session %s (round %d done)",
|
|
491
|
+
ses.name, row["round"],
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def _sweep_pr(self, repo_slug: str, number: int) -> PullRequest | None:
|
|
495
|
+
"""One PR fetch for the sweep; the caller memoizes per distinct PR.
|
|
496
|
+
|
|
497
|
+
None = the fetch failed -- every session on that PR is spared this
|
|
498
|
+
pass and looked at again next poll. RateLimited propagates so
|
|
499
|
+
run_forever backs off instead of hammering the API once per session.
|
|
500
|
+
"""
|
|
501
|
+
owner, _, repo = repo_slug.partition("/")
|
|
502
|
+
try:
|
|
503
|
+
return self.github.pull_request(owner, repo, number)
|
|
504
|
+
except RateLimited:
|
|
505
|
+
raise
|
|
506
|
+
except CommandError as exc:
|
|
507
|
+
log.warning("reap sweep: could not fetch %s#%d: %s", repo_slug, number, exc)
|
|
508
|
+
return None
|
|
509
|
+
|
|
510
|
+
def _completed_rounds(self, pr: PullRequest, task_ref: str | None) -> float | None:
|
|
511
|
+
"""How many rounds of this PR are over, judged from GitHub/task state.
|
|
512
|
+
|
|
513
|
+
A closed or merged PR terminates every round, so it reports infinity.
|
|
514
|
+
Otherwise rounds completed = verdict envelopes on the review task (the
|
|
515
|
+
authoritative round record), addressed by the task ref the ledger
|
|
516
|
+
captured at spawn time -- NOT find_review_task, which would fetch the
|
|
517
|
+
whole task list and whose open-status filter loses validated tasks.
|
|
518
|
+
The substantive-review count is the fallback only for spawns recorded
|
|
519
|
+
before any review task existed. None means "could not tell" -- the
|
|
520
|
+
sweep spares the session and retries next poll.
|
|
521
|
+
"""
|
|
522
|
+
if pr.is_terminal:
|
|
523
|
+
return float("inf")
|
|
524
|
+
if task_ref:
|
|
525
|
+
# count_verdicts never raises; unreadable evidence degrades to 0,
|
|
526
|
+
# which spares the session (round >= 1 > 0).
|
|
527
|
+
return self.alissa.count_verdicts(task_ref)
|
|
528
|
+
try:
|
|
529
|
+
return len(self.github.my_reviews(pr.owner, pr.repo, pr.number))
|
|
530
|
+
except RateLimited:
|
|
531
|
+
raise
|
|
532
|
+
except CommandError as exc:
|
|
533
|
+
log.warning("reap sweep: could not count reviews on %s: %s", pr.slug, exc)
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
# -- actions -----------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
def _spawn(
|
|
539
|
+
self, pr: PullRequest, round_: int, task: Task | None, *, reenqueued: bool = False
|
|
540
|
+
) -> Decision:
|
|
541
|
+
if task is None:
|
|
542
|
+
if self.config.on_missing_review_task == ON_MISSING_SKIP:
|
|
543
|
+
return Decision(
|
|
544
|
+
Action.SKIPPED, "no open Alissa review task (CR2)", round_
|
|
545
|
+
)
|
|
546
|
+
log.warning(
|
|
547
|
+
"%s has no open Alissa review task (CR2) — spawning against the PR "
|
|
548
|
+
"URL; the reviewer must create or locate one before recording a verdict",
|
|
549
|
+
pr.slug,
|
|
550
|
+
)
|
|
551
|
+
assignment = (
|
|
552
|
+
f"Review the GitHub PR {pr.url} . There is no Alissa review task for "
|
|
553
|
+
f"it yet — locate the origin task from the PR and create the downstream "
|
|
554
|
+
f"review task per CR2 before recording your verdict."
|
|
555
|
+
)
|
|
556
|
+
else:
|
|
557
|
+
assignment = f"You've been assigned Alissa review task {task.ref}."
|
|
558
|
+
|
|
559
|
+
name = session_name(pr, round_)
|
|
560
|
+
template = ROUND_1_DIRECTIVE if round_ == 1 else ROUND_K_DIRECTIVE
|
|
561
|
+
directive = template.format(
|
|
562
|
+
assignment=assignment, round=round_, cap=self.config.round_cap, session=name
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
hub, problem = self._ensure_hub(pr)
|
|
566
|
+
if problem is not None:
|
|
567
|
+
return Decision(Action.SKIPPED, problem, round_)
|
|
568
|
+
|
|
569
|
+
self.alissa.enqueue_reviewer(
|
|
570
|
+
session=name,
|
|
571
|
+
directive=directive,
|
|
572
|
+
cwd=hub,
|
|
573
|
+
agent=self.config.agent_profile,
|
|
574
|
+
task_ref=task.ref if task else None,
|
|
575
|
+
dry_run=self.config.dry_run,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
if not self.config.dry_run:
|
|
579
|
+
self.state.record_spawn(
|
|
580
|
+
repo=pr.full_name,
|
|
581
|
+
number=pr.number,
|
|
582
|
+
round_=round_,
|
|
583
|
+
head_sha=pr.head_sha,
|
|
584
|
+
session=name,
|
|
585
|
+
task_ref=task.ref if task else None,
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
# AFTER the enqueue on purpose: the activity comment is telemetry and
|
|
589
|
+
# must never gate the spawn it reports on.
|
|
590
|
+
context = (
|
|
591
|
+
"re-enqueued — previous session presumed dead" if reenqueued else "spawned"
|
|
592
|
+
)
|
|
593
|
+
self._append_activity(pr, self._activity_line(name, round_, context))
|
|
594
|
+
|
|
595
|
+
return Decision(
|
|
596
|
+
Action.SPAWNED,
|
|
597
|
+
f"session {name} → {task.ref if task else 'no task'}",
|
|
598
|
+
round_,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
def _ensure_hub(self, pr: PullRequest) -> tuple[Path, str | None]:
|
|
602
|
+
"""Resolve the reviewer's cwd, hub-ifying the repo first if configured.
|
|
603
|
+
|
|
604
|
+
Returns (hub, problem). `problem` is non-None when the round cannot run.
|
|
605
|
+
"""
|
|
606
|
+
hub = self.config.hub_for(pr.owner, pr.repo)
|
|
607
|
+
if hub.is_dir():
|
|
608
|
+
return hub, None
|
|
609
|
+
|
|
610
|
+
if self.config.on_missing_hub != HUB_ADD:
|
|
611
|
+
return hub, (
|
|
612
|
+
f"no worktree hub at {hub} — add the repo with "
|
|
613
|
+
f"`alissa code workspace add {pr.full_name}`, or set "
|
|
614
|
+
f"on_missing_hub='add' (requires a repos allowlist)"
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
# Guarded twice: config.load() rejects 'add' without an allowlist, and
|
|
618
|
+
# poll_once() only reaches here for watched repos. Belt and braces --
|
|
619
|
+
# this path clones code onto the machine and opens it as an agent cwd.
|
|
620
|
+
if not self.config.watches(pr.full_name):
|
|
621
|
+
return hub, f"{pr.full_name} is not in the repos allowlist"
|
|
622
|
+
|
|
623
|
+
if not self.config.manifest_path.is_file():
|
|
624
|
+
return hub, (
|
|
625
|
+
f"{self.config.workspace_root} is not an Alissa Code Workspace "
|
|
626
|
+
f"(no alissa-workspace.yaml) — run `alissa code workspace init`"
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
try:
|
|
630
|
+
self.alissa.add_repo_to_workspace(
|
|
631
|
+
pr.owner,
|
|
632
|
+
pr.repo,
|
|
633
|
+
self.config.workspace_root,
|
|
634
|
+
dry_run=self.config.dry_run,
|
|
635
|
+
)
|
|
636
|
+
except CommandError as exc:
|
|
637
|
+
return hub, f"could not hub-ify {pr.full_name}: {exc}"
|
|
638
|
+
|
|
639
|
+
if self.config.dry_run:
|
|
640
|
+
return hub, None
|
|
641
|
+
if not hub.is_dir():
|
|
642
|
+
return hub, (
|
|
643
|
+
f"`alissa code workspace add {pr.full_name}` reported success but "
|
|
644
|
+
f"{hub} still does not exist — check hub_template against the "
|
|
645
|
+
f"manifest's `dir:` override"
|
|
646
|
+
)
|
|
647
|
+
return hub, None
|
|
648
|
+
|
|
649
|
+
def preflight(self) -> list[str]:
|
|
650
|
+
"""Startup checks. Returns warnings; raises on anything fatal."""
|
|
651
|
+
warnings: list[str] = []
|
|
652
|
+
|
|
653
|
+
# Fatal: a mismatched identity silently breaks round counting.
|
|
654
|
+
login = self.github.verify_identity()
|
|
655
|
+
log.info("reviewing as GitHub user %s (from the gh token)", login)
|
|
656
|
+
|
|
657
|
+
if not self.config.workspace_root.is_dir():
|
|
658
|
+
warnings.append(f"workspace_root {self.config.workspace_root} does not exist")
|
|
659
|
+
elif not self.config.manifest_path.is_file():
|
|
660
|
+
warnings.append(
|
|
661
|
+
f"{self.config.workspace_root} has no alissa-workspace.yaml — it is "
|
|
662
|
+
f"not an Alissa Code Workspace yet (`alissa code workspace init`)"
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
if not self.config.dry_run and not self.alissa.worker_running():
|
|
666
|
+
warnings.append(
|
|
667
|
+
"`alissa worker` does not appear to be running — queued reviewer "
|
|
668
|
+
"sessions will not spawn until it is (`alissa worker start`)"
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
return warnings
|
|
672
|
+
|
|
673
|
+
def _activity_line(self, session: str, round_: int, context: str) -> str:
|
|
674
|
+
ts = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
|
|
675
|
+
return f"- {ts} — `{session}` — round {round_} of {self.config.round_cap} — {context}"
|
|
676
|
+
|
|
677
|
+
def _append_activity(self, pr: PullRequest, line: str) -> bool:
|
|
678
|
+
"""Append one line to THE activity comment on the PR; True if it landed.
|
|
679
|
+
|
|
680
|
+
Find-or-create: the PR's issue comments are filtered to OWN authorship
|
|
681
|
+
AND the hidden marker, and the line is PATCH-appended to the first
|
|
682
|
+
match; with no match, one marker-carrying comment is created. A marker
|
|
683
|
+
pasted by anyone else fails the author filter and is never touched.
|
|
684
|
+
|
|
685
|
+
Best-effort by contract: this is telemetry about a spawn that has
|
|
686
|
+
already happened, so no failure here may surface to the caller. The
|
|
687
|
+
except is deliberately broad -- _api turns rate-limit errors into
|
|
688
|
+
RateLimited (not CommandError), and letting that fly out of evaluate()
|
|
689
|
+
would fail the whole poll pass over a log line.
|
|
690
|
+
"""
|
|
691
|
+
if self.config.dry_run:
|
|
692
|
+
log.info("[dry-run] would append activity line on %s: %s", pr.slug, line)
|
|
693
|
+
return False
|
|
694
|
+
try:
|
|
695
|
+
mine = [
|
|
696
|
+
c
|
|
697
|
+
for c in self.github.issue_comments(pr.owner, pr.repo, pr.number)
|
|
698
|
+
if c.author == self.github.login and ACTIVITY_MARKER in c.body
|
|
699
|
+
]
|
|
700
|
+
if mine:
|
|
701
|
+
self.github.update_comment(
|
|
702
|
+
pr.owner, pr.repo, mine[0].id, mine[0].body + "\n" + line
|
|
703
|
+
)
|
|
704
|
+
else:
|
|
705
|
+
self.github.comment(
|
|
706
|
+
pr.owner, pr.repo, pr.number, ACTIVITY_HEADER + "\n" + line
|
|
707
|
+
)
|
|
708
|
+
except Exception as exc:
|
|
709
|
+
log.warning("could not append activity line on %s: %s", pr.slug, exc)
|
|
710
|
+
return False
|
|
711
|
+
return True
|
|
712
|
+
|
|
713
|
+
def _escalate_stalled(
|
|
714
|
+
self, pr: PullRequest, round_: int, session: str, age: float
|
|
715
|
+
) -> None:
|
|
716
|
+
"""Operator ping when the liveness deferral itself runs long: the
|
|
717
|
+
session showing life is the only thing holding the respawn back, so
|
|
718
|
+
a human must check whether it is progressing or wedged. Posted once
|
|
719
|
+
per deferral EPISODE (the ping ledger row is keyed
|
|
720
|
+
stalled_kind(session); a re-enqueued round that stalls again is a
|
|
721
|
+
new session, so it pings again), and the row is recorded only AFTER
|
|
722
|
+
the comment posts: this ping is the operator's only signal for the
|
|
723
|
+
episode, so a transient comment failure must retry next poll --
|
|
724
|
+
exactly-once delivered, unlike the cap-out page (a terminal state,
|
|
725
|
+
recorded despite failure). The decision stays a deferral either way
|
|
726
|
+
-- this comments, it never respawns."""
|
|
727
|
+
body = STALLED_COMMENT.format(
|
|
728
|
+
round=round_,
|
|
729
|
+
minutes=int(age / 60),
|
|
730
|
+
stale=STALE_ROUND_SECONDS // 60,
|
|
731
|
+
session=session,
|
|
732
|
+
)
|
|
733
|
+
log.warning(
|
|
734
|
+
"STALLED %s round %d has been deferred %.0f min behind live session "
|
|
735
|
+
"%s — escalating to operator (once per episode)",
|
|
736
|
+
pr.slug,
|
|
737
|
+
round_,
|
|
738
|
+
age / 60,
|
|
739
|
+
session,
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
if self.config.dry_run:
|
|
743
|
+
log.info("[dry-run] would comment on %s:\n%s", pr.slug, body)
|
|
744
|
+
return
|
|
745
|
+
|
|
746
|
+
try:
|
|
747
|
+
self.github.comment(pr.owner, pr.repo, pr.number, body)
|
|
748
|
+
except CommandError as exc:
|
|
749
|
+
log.error(
|
|
750
|
+
"could not post the stalled-round comment on %s: %s — not "
|
|
751
|
+
"recording the episode; the ping retries next poll",
|
|
752
|
+
pr.slug,
|
|
753
|
+
exc,
|
|
754
|
+
)
|
|
755
|
+
return
|
|
756
|
+
self.state.record_ping(pr.full_name, pr.number, stalled_kind(session))
|
|
757
|
+
|
|
758
|
+
def _escalate(self, pr: PullRequest, last_state: str, rounds: int) -> None:
|
|
759
|
+
body = ESCALATION_COMMENT.format(
|
|
760
|
+
rounds=rounds, last_state=last_state.lower(), sha=pr.head_sha[:8]
|
|
761
|
+
)
|
|
762
|
+
log.error("CAP-OUT %s after %d rounds — escalating to operator", pr.slug, rounds)
|
|
763
|
+
|
|
764
|
+
if self.config.dry_run:
|
|
765
|
+
log.info("[dry-run] would comment on %s:\n%s", pr.slug, body)
|
|
766
|
+
return
|
|
767
|
+
|
|
768
|
+
try:
|
|
769
|
+
self.github.comment(pr.owner, pr.repo, pr.number, body)
|
|
770
|
+
except CommandError as exc:
|
|
771
|
+
log.error("could not post escalation comment on %s: %s", pr.slug, exc)
|
|
772
|
+
self.state.record_escalation(pr.full_name, pr.number, pr.head_sha)
|
|
773
|
+
|
|
774
|
+
# -- polling -----------------------------------------------------------
|
|
775
|
+
|
|
776
|
+
def poll_once(self) -> list[tuple[str, Decision]]:
|
|
777
|
+
# Sweep BEFORE evaluating: a full worker is exactly when a fresh spawn
|
|
778
|
+
# needs the slot a finished session is squatting on. Deliberately not
|
|
779
|
+
# inside the per-request loop below — the sweep must reach sessions
|
|
780
|
+
# whose PR no longer appears in the search at all.
|
|
781
|
+
self.sweep_sessions()
|
|
782
|
+
|
|
783
|
+
requests = self.github.review_requests(self.config.repos)
|
|
784
|
+
log.info("%d PR(s) with a review pending from %s", len(requests), self.github.login)
|
|
785
|
+
|
|
786
|
+
results = []
|
|
787
|
+
for owner, repo, number in requests:
|
|
788
|
+
slug = f"{owner}/{repo}#{number}"
|
|
789
|
+
if not self.config.watches(f"{owner}/{repo}"):
|
|
790
|
+
continue
|
|
791
|
+
try:
|
|
792
|
+
decision = self.evaluate(owner, repo, number)
|
|
793
|
+
except RateLimited:
|
|
794
|
+
raise
|
|
795
|
+
except CommandError as exc:
|
|
796
|
+
log.error("%s: %s", slug, exc)
|
|
797
|
+
decision = Decision(Action.SKIPPED, str(exc))
|
|
798
|
+
|
|
799
|
+
level = logging.INFO if decision.action != Action.SKIPPED else logging.DEBUG
|
|
800
|
+
log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
|
|
801
|
+
results.append((slug, decision))
|
|
802
|
+
return results
|
|
803
|
+
|
|
804
|
+
def run_forever(self) -> None:
|
|
805
|
+
# preflight() is the caller's responsibility -- the CLI runs it once for
|
|
806
|
+
# every mode, so calling it here too would double every check.
|
|
807
|
+
backoff = self.config.poll_interval
|
|
808
|
+
while True:
|
|
809
|
+
# The sleep lives INSIDE the KeyboardInterrupt guard: with a 60s
|
|
810
|
+
# poll interval (up to 900s backing off) the loop spends nearly
|
|
811
|
+
# all its wall-clock sleeping, so a real Ctrl-C almost always
|
|
812
|
+
# lands there and must hit the same clean-exit path.
|
|
813
|
+
try:
|
|
814
|
+
try:
|
|
815
|
+
self.poll_once()
|
|
816
|
+
backoff = self.config.poll_interval
|
|
817
|
+
except RateLimited as exc:
|
|
818
|
+
backoff = min(backoff * 2, 900)
|
|
819
|
+
log.warning("rate limited (%s) — backing off %ds", exc, backoff)
|
|
820
|
+
except CommandError as exc:
|
|
821
|
+
backoff = min(backoff * 2, 900)
|
|
822
|
+
log.error("poll failed: %s — retrying in %ds", exc, backoff)
|
|
823
|
+
time.sleep(backoff)
|
|
824
|
+
except KeyboardInterrupt:
|
|
825
|
+
log.info("stopping")
|
|
826
|
+
return
|