alissa-tools-github-reviewloop 0.8.0__tar.gz → 0.9.0__tar.gz
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_reviewloop-0.8.0/src/main/alissa_tools_github_reviewloop.egg-info → alissa_tools_github_reviewloop-0.9.0}/PKG-INFO +1 -1
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/loop.py +163 -2
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/state.py +34 -0
- alissa_tools_github_reviewloop-0.9.0/src/main/alissa/tools/github/reviewloop/version +1 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0/src/main/alissa_tools_github_reviewloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_reviewloop-0.8.0/src/main/alissa/tools/github/reviewloop/version +0 -1
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/MANIFEST.in +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/README.md +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/requirements.txt +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/setup.cfg +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/setup.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/__init__.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/__main__.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/alissa.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/config.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/ghclient.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/proc.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/prreview.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/version.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa_tools_github_reviewloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa_tools_github_reviewloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa_tools_github_reviewloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa_tools_github_reviewloop.egg-info/top_level.txt +0 -0
|
@@ -27,9 +27,25 @@ from .state import State
|
|
|
27
27
|
log = logging.getLogger(__name__)
|
|
28
28
|
|
|
29
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
|
|
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.
|
|
31
36
|
STALE_ROUND_SECONDS = 90 * 60
|
|
32
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
|
+
|
|
33
49
|
# The sweep only reaps a session that has been idle AND quiet this long. The
|
|
34
50
|
# GitHub review count increments the moment a review is submitted, but the
|
|
35
51
|
# reviewer still has close-out work after that (CR6 envelope, task status) --
|
|
@@ -96,6 +112,38 @@ ESCALATION_COMMENT = (
|
|
|
96
112
|
"Last verdict: `{last_state}` at `{sha}`."
|
|
97
113
|
)
|
|
98
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 ping-ledger kind prefix for the stalled-deferral operator ping. Unlike
|
|
127
|
+
# the cap-out escalation (terminal per head), a stall can recur, so the kind
|
|
128
|
+
# is narrowed per episode -- see stalled_kind.
|
|
129
|
+
ESCALATION_STALLED = "stalled"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def stalled_kind(session: str) -> str:
|
|
133
|
+
"""The ping-ledger kind that dedupes ONE deferral episode's operator ping.
|
|
134
|
+
|
|
135
|
+
Devloop's stalled_kind reasoning, transposed: a stall can recur -- every
|
|
136
|
+
spawn of every round can wedge mid-flight, and episode k's ping must not
|
|
137
|
+
silence episode k+1's. Keyed on the bare kind (or even on the round), the
|
|
138
|
+
re-enqueue of a round that wedges AGAIN would defer silently forever. The
|
|
139
|
+
session name already IS the episode identity -- nonce-unique per spawn
|
|
140
|
+
(see session_name) -- so it folds into the key. Delivery contract: the
|
|
141
|
+
ledger row lands only AFTER the comment posts (see _escalate_stalled), so
|
|
142
|
+
a transient comment failure retries next poll and the ping lands exactly
|
|
143
|
+
once per episode.
|
|
144
|
+
"""
|
|
145
|
+
return f"{ESCALATION_STALLED}:{session}"
|
|
146
|
+
|
|
99
147
|
|
|
100
148
|
class Action(str, Enum):
|
|
101
149
|
SPAWNED = "spawned"
|
|
@@ -192,9 +240,13 @@ class ReviewWatcher:
|
|
|
192
240
|
if age is not None and age < STALE_ROUND_SECONDS:
|
|
193
241
|
return Decision(Action.IN_FLIGHT, f"round {round_} enqueued {int(age)}s ago", round_)
|
|
194
242
|
if age is not None:
|
|
243
|
+
deferred = self._defer_stale_round(pr, round_, age)
|
|
244
|
+
if deferred is not None:
|
|
245
|
+
return deferred
|
|
195
246
|
log.warning(
|
|
196
247
|
"%s round %d has been in flight %.0f min with no submitted review "
|
|
197
|
-
"— re-enqueuing (reviewer
|
|
248
|
+
"and its session is gone or finished — re-enqueuing (reviewer "
|
|
249
|
+
"session presumed dead)",
|
|
198
250
|
pr.slug,
|
|
199
251
|
round_,
|
|
200
252
|
age / 60,
|
|
@@ -202,6 +254,70 @@ class ReviewWatcher:
|
|
|
202
254
|
|
|
203
255
|
return self._spawn(pr, round_, task)
|
|
204
256
|
|
|
257
|
+
def _defer_stale_round(self, pr: PullRequest, round_: int, age: float) -> Decision | None:
|
|
258
|
+
"""The liveness signal under the stale timer: a deferral, or None to
|
|
259
|
+
respawn.
|
|
260
|
+
|
|
261
|
+
Staleness needs TWO signals, not one: the ledger timer says the
|
|
262
|
+
newest spawn is old, but elapsed time alone cannot tell a dead
|
|
263
|
+
session from a slow one -- a thorough round can outlast
|
|
264
|
+
STALE_ROUND_SECONDS, and a timer-only re-enqueue respawns a reviewer
|
|
265
|
+
over the still-working first one; two sessions review the same
|
|
266
|
+
round and both submit. So before respawning, consult external
|
|
267
|
+
evidence of life: the round's recorded session in the live list
|
|
268
|
+
(the reap sweep's own probe). Busy, or idle without a real quiet
|
|
269
|
+
period (mid-close-out between turns; see REAP_QUIET_SECONDS) -> the
|
|
270
|
+
round is alive, defer with a reason. Gone, or idle-finished -> dead,
|
|
271
|
+
respawn (the sweep separately handles any corpse). An unprobeable
|
|
272
|
+
live list defers too: respawning on missing evidence is exactly the
|
|
273
|
+
double-spend, and the probe retries next poll.
|
|
274
|
+
|
|
275
|
+
The deferral is floored, not unbounded: past STALLED_DEFER_MULTIPLE
|
|
276
|
+
stale windows with the session still alive, one operator ping per
|
|
277
|
+
deferral episode (stalled_kind) -- then keep deferring. This method
|
|
278
|
+
never respawns over a live session; only the operator killing the
|
|
279
|
+
session (or it finishing/dying) unblocks the respawn.
|
|
280
|
+
"""
|
|
281
|
+
row = self.state.get_spawn(pr.full_name, pr.number, round_)
|
|
282
|
+
if row is None: # age came from this row; belt and braces
|
|
283
|
+
return None
|
|
284
|
+
session = row["session"]
|
|
285
|
+
try:
|
|
286
|
+
live = {s.name: s for s in self.alissa.list_review_sessions()}
|
|
287
|
+
except CommandError as exc:
|
|
288
|
+
log.warning(
|
|
289
|
+
"%s round %d is stale but the session list is unavailable (%s) "
|
|
290
|
+
"— deferring the respawn rather than risking a double-spawned "
|
|
291
|
+
"round; the probe retries next poll",
|
|
292
|
+
pr.slug,
|
|
293
|
+
round_,
|
|
294
|
+
exc,
|
|
295
|
+
)
|
|
296
|
+
return Decision(
|
|
297
|
+
Action.IN_FLIGHT,
|
|
298
|
+
f"round {round_} is stale but liveness is unprobeable — deferring",
|
|
299
|
+
round_,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
ses = live.get(session)
|
|
303
|
+
if ses is None:
|
|
304
|
+
return None # session gone -> presumed dead -> respawn
|
|
305
|
+
if ses.is_idle and time.time() - ses.last_activity >= REAP_QUIET_SECONDS:
|
|
306
|
+
return None # idle-finished: it died without submitting -> respawn
|
|
307
|
+
|
|
308
|
+
if (
|
|
309
|
+
age >= STALLED_DEFER_MULTIPLE * STALE_ROUND_SECONDS
|
|
310
|
+
and not self.state.pinged(pr.full_name, pr.number, stalled_kind(session))
|
|
311
|
+
):
|
|
312
|
+
self._escalate_stalled(pr, round_, session, age)
|
|
313
|
+
return Decision(
|
|
314
|
+
Action.IN_FLIGHT,
|
|
315
|
+
f"round {round_} is stale ({int(age / 60)} min) but session "
|
|
316
|
+
f"{session} is still {'active' if ses.is_idle else 'busy'} — not "
|
|
317
|
+
f"respawning over a live reviewer",
|
|
318
|
+
round_,
|
|
319
|
+
)
|
|
320
|
+
|
|
205
321
|
def _convergence_reason(
|
|
206
322
|
self, my_reviews: list[Review], task: Task | None, head_sha: str
|
|
207
323
|
) -> str | None:
|
|
@@ -505,6 +621,51 @@ class ReviewWatcher:
|
|
|
505
621
|
|
|
506
622
|
return warnings
|
|
507
623
|
|
|
624
|
+
def _escalate_stalled(
|
|
625
|
+
self, pr: PullRequest, round_: int, session: str, age: float
|
|
626
|
+
) -> None:
|
|
627
|
+
"""Operator ping when the liveness deferral itself runs long: the
|
|
628
|
+
session showing life is the only thing holding the respawn back, so
|
|
629
|
+
a human must check whether it is progressing or wedged. Posted once
|
|
630
|
+
per deferral EPISODE (the ping ledger row is keyed
|
|
631
|
+
stalled_kind(session); a re-enqueued round that stalls again is a
|
|
632
|
+
new session, so it pings again), and the row is recorded only AFTER
|
|
633
|
+
the comment posts: this ping is the operator's only signal for the
|
|
634
|
+
episode, so a transient comment failure must retry next poll --
|
|
635
|
+
exactly-once delivered, unlike the cap-out page (a terminal state,
|
|
636
|
+
recorded despite failure). The decision stays a deferral either way
|
|
637
|
+
-- this comments, it never respawns."""
|
|
638
|
+
body = STALLED_COMMENT.format(
|
|
639
|
+
round=round_,
|
|
640
|
+
minutes=int(age / 60),
|
|
641
|
+
stale=STALE_ROUND_SECONDS // 60,
|
|
642
|
+
session=session,
|
|
643
|
+
)
|
|
644
|
+
log.warning(
|
|
645
|
+
"STALLED %s round %d has been deferred %.0f min behind live session "
|
|
646
|
+
"%s — escalating to operator (once per episode)",
|
|
647
|
+
pr.slug,
|
|
648
|
+
round_,
|
|
649
|
+
age / 60,
|
|
650
|
+
session,
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
if self.config.dry_run:
|
|
654
|
+
log.info("[dry-run] would comment on %s:\n%s", pr.slug, body)
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
try:
|
|
658
|
+
self.github.comment(pr.owner, pr.repo, pr.number, body)
|
|
659
|
+
except CommandError as exc:
|
|
660
|
+
log.error(
|
|
661
|
+
"could not post the stalled-round comment on %s: %s — not "
|
|
662
|
+
"recording the episode; the ping retries next poll",
|
|
663
|
+
pr.slug,
|
|
664
|
+
exc,
|
|
665
|
+
)
|
|
666
|
+
return
|
|
667
|
+
self.state.record_ping(pr.full_name, pr.number, stalled_kind(session))
|
|
668
|
+
|
|
508
669
|
def _escalate(self, pr: PullRequest, last_state: str, rounds: int) -> None:
|
|
509
670
|
body = ESCALATION_COMMENT.format(
|
|
510
671
|
rounds=rounds, last_state=last_state.lower(), sha=pr.head_sha[:8]
|
|
@@ -44,6 +44,14 @@ CREATE TABLE IF NOT EXISTS reaps (
|
|
|
44
44
|
session TEXT NOT NULL PRIMARY KEY,
|
|
45
45
|
reaped_at INTEGER NOT NULL
|
|
46
46
|
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS pings (
|
|
49
|
+
repo TEXT NOT NULL,
|
|
50
|
+
number INTEGER NOT NULL,
|
|
51
|
+
kind TEXT NOT NULL,
|
|
52
|
+
pinged_at INTEGER NOT NULL,
|
|
53
|
+
PRIMARY KEY (repo, number, kind)
|
|
54
|
+
);
|
|
47
55
|
"""
|
|
48
56
|
|
|
49
57
|
|
|
@@ -168,6 +176,32 @@ class State:
|
|
|
168
176
|
)
|
|
169
177
|
self._db.commit()
|
|
170
178
|
|
|
179
|
+
def pinged(self, repo: str, number: int, kind: str) -> bool:
|
|
180
|
+
"""Whether this KIND of operator ping already went out for the PR.
|
|
181
|
+
|
|
182
|
+
Kind is free-form TEXT (devloop's escalation-kind pattern): a caller
|
|
183
|
+
narrows a kind's dedupe scope by folding identity into the string --
|
|
184
|
+
e.g. one stalled ping per deferral episode, "stalled:<session>" (see
|
|
185
|
+
loop.stalled_kind). Kept apart from `escalations`, whose key is
|
|
186
|
+
(repo, number, head_sha) and whose rows page terminal states.
|
|
187
|
+
"""
|
|
188
|
+
row = self._db.execute(
|
|
189
|
+
"SELECT 1 FROM pings WHERE repo=? AND number=? AND kind=?",
|
|
190
|
+
(repo, number, kind),
|
|
191
|
+
).fetchone()
|
|
192
|
+
return row is not None
|
|
193
|
+
|
|
194
|
+
def record_ping(self, repo: str, number: int, kind: str) -> None:
|
|
195
|
+
"""Idempotent per kind: OR IGNORE keeps the FIRST ping's timestamp,
|
|
196
|
+
so `pinged_at` is an audit field for when the episode was first
|
|
197
|
+
raised, not the most recent re-raise."""
|
|
198
|
+
self._db.execute(
|
|
199
|
+
"INSERT OR IGNORE INTO pings (repo, number, kind, pinged_at) "
|
|
200
|
+
"VALUES (?,?,?,?)",
|
|
201
|
+
(repo, number, kind, int(time.time())),
|
|
202
|
+
)
|
|
203
|
+
self._db.commit()
|
|
204
|
+
|
|
171
205
|
def escalated(self, repo: str, number: int, head_sha: str) -> bool:
|
|
172
206
|
row = self._db.execute(
|
|
173
207
|
"SELECT 1 FROM escalations WHERE repo=? AND number=? AND head_sha=?",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.9.0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.8.0
|
|
File without changes
|
|
File without changes
|
{alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/requirements.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|