alissa-tools-github-revloop 0.30.0__tar.gz → 0.30.1__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.
Files changed (36) hide show
  1. {alissa_tools_github_revloop-0.30.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.30.1}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/__main__.py +10 -0
  3. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/config.py +30 -0
  4. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/ghclient.py +82 -0
  5. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/loop.py +244 -0
  6. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/state.py +71 -0
  7. alissa_tools_github_revloop-0.30.1/src/main/alissa/tools/github/revloop/version +1 -0
  8. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  9. alissa_tools_github_revloop-0.30.0/src/main/alissa/tools/github/revloop/version +0 -1
  10. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/LICENSE +0 -0
  11. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/MANIFEST.in +0 -0
  12. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/NOTICE +0 -0
  13. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/README.md +0 -0
  14. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/requirements.txt +0 -0
  15. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/setup.cfg +0 -0
  16. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/setup.py +0 -0
  17. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  18. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  19. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  20. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  21. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  22. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  23. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  24. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  25. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/version.py +0 -0
  26. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  27. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  28. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  29. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  30. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  31. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  32. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  33. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  34. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  35. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  36. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.30.0
3
+ Version: 0.30.1
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -201,6 +201,15 @@ def build_parser() -> argparse.ArgumentParser:
201
201
  "evidence; 0 queues immediately and relies on the directive alone",
202
202
  )
203
203
 
204
+ over.add_argument(
205
+ "--verdict-cooldown-s",
206
+ type=int,
207
+ metavar="SECONDS",
208
+ help="how long after a verdict lands on a head no round may be queued "
209
+ "on that same head, whatever the PR's requested_reviewers snapshot "
210
+ "says; 0 leaves the review_requested timeline check to decide alone",
211
+ )
212
+
204
213
  over.add_argument(
205
214
  "--review-task-miss-ttl-polls",
206
215
  type=int,
@@ -321,6 +330,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
321
330
  "max_concurrent_sessions": args.max_concurrent_sessions,
322
331
  "checks_wait_seconds": args.checks_wait_seconds,
323
332
  "checks_spawn_wait_seconds": args.checks_spawn_wait_seconds,
333
+ "verdict_cooldown_s": args.verdict_cooldown_s,
324
334
  "review_task_miss_ttl_polls": args.review_task_miss_ttl_polls,
325
335
  "task_list_self_scope": args.task_list_self_scope,
326
336
  "task_list_bow_id": args.task_list_bow_id,
@@ -190,6 +190,7 @@ CONFIG_KEYS = (
190
190
  "max_concurrent_sessions",
191
191
  "checks_wait_seconds",
192
192
  "checks_spawn_wait_seconds",
193
+ "verdict_cooldown_s",
193
194
  "review_task_miss_ttl_polls",
194
195
  "task_list_self_scope",
195
196
  "task_list_bow_id",
@@ -539,6 +540,24 @@ DEFAULT_CHECKS_WAIT_SECONDS = 30 * 60
539
540
  # still-running rollup, which is the directive-only posture.
540
541
  DEFAULT_CHECKS_SPAWN_WAIT_SECONDS = 15 * 60
541
542
 
543
+ # How long, after a verdict lands on a head, NO round may be queued on that same
544
+ # head -- whatever the PR's `requested_reviewers` snapshot says (issue #128).
545
+ #
546
+ # GitHub consumes a review request the moment the requested identity submits a
547
+ # review, but not atomically with the daemon's reads: on studio #1243 a
548
+ # `request_changes` verdict landed at 14:35:32 and the poll ten seconds later
549
+ # still saw the reviewer in `requested_reviewers`, queued round 2 on the SAME
550
+ # head, and that phantom round bounced on the CR8 triage gate -- which made the
551
+ # devloop spawn a second fix session on one branch. The cooldown is the blunt
552
+ # guard against that propagation lag: two polls' worth at the default cadence,
553
+ # long enough for the request the verdict consumed to be gone from every read.
554
+ # The precise guard is the timeline check in loop._admit_round, which admits a
555
+ # same-head round only on a `review_requested` event NEWER than the verdict;
556
+ # the cooldown spares that check a GitHub call in exactly the window where the
557
+ # stale snapshot is most likely. 0 disables the cooldown and leaves the timeline
558
+ # check to decide alone.
559
+ DEFAULT_VERDICT_COOLDOWN_SECONDS = 120
560
+
542
561
  # How many polls a PR with NO review task may be taken on trust before the
543
562
  # daemon searches the task corpus for one again (issue #87).
544
563
  #
@@ -778,6 +797,12 @@ class Config:
778
797
  # timer to configure.
779
798
  checks_spawn_wait_seconds: int = DEFAULT_CHECKS_SPAWN_WAIT_SECONDS
780
799
 
800
+ # How long after a verdict lands on a head no round may be queued on that
801
+ # same head, regardless of the PR's `requested_reviewers` snapshot; see
802
+ # DEFAULT_VERDICT_COOLDOWN_SECONDS. 0 is legal and means "no cooldown": the
803
+ # timeline check alone decides whether a same-head round is admitted.
804
+ verdict_cooldown_s: int = DEFAULT_VERDICT_COOLDOWN_SECONDS
805
+
781
806
  # How many polls a PR with no review task is taken on trust before the
782
807
  # corpus is searched again; see DEFAULT_REVIEW_TASK_MISS_TTL_POLLS. Floor 1
783
808
  # -- there is no value that turns the negative cache off.
@@ -1145,6 +1170,10 @@ class Config:
1145
1170
  f"checks_spawn_wait_seconds must be >= 0, got {spawn_wait}"
1146
1171
  )
1147
1172
 
1173
+ cooldown = int(raw.get("verdict_cooldown_s", cls.verdict_cooldown_s))
1174
+ if cooldown < 0:
1175
+ raise ValueError(f"verdict_cooldown_s must be >= 0, got {cooldown}")
1176
+
1148
1177
  miss_ttl = int(
1149
1178
  raw.get("review_task_miss_ttl_polls", cls.review_task_miss_ttl_polls)
1150
1179
  )
@@ -1213,6 +1242,7 @@ class Config:
1213
1242
  max_concurrent_sessions=max_sessions,
1214
1243
  checks_wait_seconds=checks_wait,
1215
1244
  checks_spawn_wait_seconds=spawn_wait,
1245
+ verdict_cooldown_s=cooldown,
1216
1246
  review_task_miss_ttl_polls=miss_ttl,
1217
1247
  task_list_self_scope=bool(raw.get("task_list_self_scope", False)),
1218
1248
  task_list_bow_id=bow_id,
@@ -41,6 +41,20 @@ SUBMITTED_STATES = {"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"}
41
41
  # concatenates one JSON document per page and would not parse.
42
42
  PER_PAGE = 100
43
43
  COMMENT_PAGE_LIMIT = 20
44
+ # The issue timeline grows with every label, push, comment and request over a
45
+ # PR's life; the round-admission gate reads it for the newest
46
+ # `review_requested` event and the bound keeps a long-lived PR from turning one
47
+ # poll into an unbounded walk. The endpoint pages oldest-first with no
48
+ # direction parameter, so past the bound it is the NEWEST events that go
49
+ # unread, and the newest is exactly what the gate is asking about. So a walk
50
+ # that hits the bound does NOT answer with the newest request it happened to
51
+ # see (an old one, which would refuse every same-head round on that PR
52
+ # forever): it raises TimelineTruncated, and the gate treats "could not see
53
+ # the newest request" the same way whether it arrives as silence or as an
54
+ # exception -- see loop._fresh_request_at. Whether the bound was actually
55
+ # exceeded is settled by ONE probe past it, so exactly 2,000 events is a
56
+ # complete read and not a false alarm (PR #129 round 1).
57
+ TIMELINE_PAGE_LIMIT = 20
44
58
 
45
59
  # The compare endpoint's HARD cap on the `files` array, and it is a cap, not a
46
60
  # page: `files` is not paginated at all. Measured against api.github.com on
@@ -435,6 +449,13 @@ class RateLimited(RuntimeError):
435
449
  pass
436
450
 
437
451
 
452
+ class TimelineTruncated(RuntimeError):
453
+ """The issue timeline has more events than TIMELINE_PAGE_LIMIT pages hold,
454
+ so the newest `review_requested` may lie past what was read. Raised
455
+ INSTEAD of a stale answer: a caller that compares request timestamps must
456
+ not mistake "the newest within the bound" for "the newest"."""
457
+
458
+
438
459
  class IdentityMismatch(RuntimeError):
439
460
  """Configured reviewer identity disagrees with the gh token."""
440
461
 
@@ -1187,6 +1208,67 @@ class GitHub:
1187
1208
  env=self._env(),
1188
1209
  )
1189
1210
 
1211
+ def review_requested_at(
1212
+ self, owner: str, repo: str, number: int, login: str
1213
+ ) -> str | None:
1214
+ """When `login` was most recently asked to review the PR, as the
1215
+ timeline's `created_at` (ISO-8601), or None if it never was.
1216
+
1217
+ Read from `GET /repos/{owner}/{repo}/issues/{n}/timeline`, keeping only
1218
+ `review_requested` events whose `requested_reviewer` is `login` (a team
1219
+ request carries `requested_team` instead and is nobody's round). The
1220
+ PR's own `requested_reviewers` array is NOT this: GitHub removes the
1221
+ request there when the review lands, with a propagation lag the daemon
1222
+ can read through, whereas the timeline event is immutable history --
1223
+ the request's timestamp can be compared with the verdict's (issue
1224
+ #128). Pages oldest-first like issue_comments, bounded by
1225
+ TIMELINE_PAGE_LIMIT -- and past the bound it raises TimelineTruncated
1226
+ rather than answering with an older request than the true newest
1227
+ (the reasoning is at the constant). The bound is checked with one
1228
+ probe past the last page, so a timeline of exactly
1229
+ TIMELINE_PAGE_LIMIT * PER_PAGE events reads as complete.
1230
+ """
1231
+ wanted = login.casefold()
1232
+ newest: str | None = None
1233
+ for page in range(1, TIMELINE_PAGE_LIMIT + 2):
1234
+ data = self._timeline_page(owner, repo, number, page)
1235
+ if page > TIMELINE_PAGE_LIMIT:
1236
+ # The probe: anything here means the bound was exceeded.
1237
+ if data:
1238
+ raise TimelineTruncated(
1239
+ f"{owner}/{repo}#{number} has more than "
1240
+ f"{TIMELINE_PAGE_LIMIT * PER_PAGE} timeline events — the "
1241
+ f"newest review request to {login} may lie past the "
1242
+ f"{TIMELINE_PAGE_LIMIT * PER_PAGE} that were read"
1243
+ )
1244
+ break
1245
+ for event in data:
1246
+ if (event or {}).get("event") != "review_requested":
1247
+ continue
1248
+ who = ((event.get("requested_reviewer") or {}).get("login") or "")
1249
+ if who.casefold() != wanted:
1250
+ continue
1251
+ at = event.get("created_at") or ""
1252
+ if at and (newest is None or at > newest):
1253
+ newest = at
1254
+ if len(data) < PER_PAGE:
1255
+ break
1256
+ return newest
1257
+
1258
+ def _timeline_page(self, owner: str, repo: str, number: int, page: int) -> list:
1259
+ return (
1260
+ self._api(
1261
+ "-X",
1262
+ "GET",
1263
+ f"repos/{owner}/{repo}/issues/{number}/timeline",
1264
+ "-f",
1265
+ f"per_page={PER_PAGE}",
1266
+ "-f",
1267
+ f"page={page}",
1268
+ )
1269
+ or []
1270
+ )
1271
+
1190
1272
  def issue_comments(self, owner: str, repo: str, number: int) -> list[IssueComment]:
1191
1273
  """Every issue comment on the PR, oldest first -- see COMMENT_PAGE_LIMIT
1192
1274
  for why this pages instead of reading the first 100 and hoping."""
@@ -16,6 +16,7 @@ import secrets
16
16
  import sqlite3
17
17
  import time
18
18
  from collections import Counter
19
+ from datetime import datetime, timezone
19
20
  from dataclasses import dataclass, field, replace
20
21
  from enum import Enum
21
22
  from pathlib import Path
@@ -55,6 +56,7 @@ from .ghclient import (
55
56
  PullRequest,
56
57
  RateLimited,
57
58
  Review,
59
+ TimelineTruncated,
58
60
  TruncatedListing,
59
61
  countable_rounds,
60
62
  verdict_marker,
@@ -211,6 +213,18 @@ DEFERRAL_CLEARED = (
211
213
  )
212
214
 
213
215
 
216
+ @dataclass(frozen=True)
217
+ class VerdictOfRecord:
218
+ """The newest verdict on a PR's current head, as the round-admission gate
219
+ needs it (issue #128): `at` is the newer of GitHub's `submitted_at` and
220
+ the ledger's local stamp -- the cooldown's clock -- and `github_at` is
221
+ GitHub's stamp alone (None while GitHub's reviews list has not yet shown
222
+ the verdict), the only one comparable with a timeline `created_at`."""
223
+
224
+ at: float
225
+ github_at: float | None
226
+
227
+
214
228
  @dataclass(frozen=True)
215
229
  class Waiting:
216
230
  """One round's place in the spawn queue, from its FIRST deferral.
@@ -1251,6 +1265,26 @@ def _now() -> str:
1251
1265
  return time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
1252
1266
 
1253
1267
 
1268
+ def _epoch(stamp: str) -> float | None:
1269
+ """A GitHub ISO-8601 timestamp (`2026-09-15T14:35:32Z`) as epoch seconds,
1270
+ or None for an empty or unparseable one. A naive stamp is read as UTC,
1271
+ which is what GitHub emits."""
1272
+ if not stamp:
1273
+ return None
1274
+ try:
1275
+ parsed = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
1276
+ except ValueError:
1277
+ return None
1278
+ if parsed.tzinfo is None:
1279
+ parsed = parsed.replace(tzinfo=timezone.utc)
1280
+ return parsed.timestamp()
1281
+
1282
+
1283
+ def _stamp(epoch: float) -> str:
1284
+ """The inverse of _epoch, for log lines: UTC, seconds, `Z`."""
1285
+ return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1286
+
1287
+
1254
1288
  def grant_activity_kind(comment_id: int) -> str:
1255
1289
  """The ping-ledger kind that dedupes ONE grant's activity line.
1256
1290
 
@@ -1705,6 +1739,18 @@ class ReviewWatcher:
1705
1739
  # has listed this pass (the preflight refresh, or a sweep whose list
1706
1740
  # failed), and the refresh lists for itself.
1707
1741
  self._pass_roster: list[ManagedSession] | None = None
1742
+ # (repo full name, PR number) -> when the reviewer login was most
1743
+ # recently asked to review that PR, per the issue timeline (an ISO
1744
+ # stamp, or None for never) -- the round-admission gate's one GitHub
1745
+ # read, memoised so a PR costs at most one timeline walk per pass
1746
+ # (issue #128). Cleared with the other per-pass memos in poll_once.
1747
+ self._pass_requests: dict[tuple[str, int], str | None] = {}
1748
+ # (repo full name, PR number) -> (head, verdict epoch) of the last
1749
+ # stale request the gate announced at INFO for that PR, so the line
1750
+ # goes out once per ignored request and repeats at debug while the
1751
+ # same request keeps being seen. In-memory on purpose: a restart
1752
+ # costs one repeated line, not a ledger write per poll.
1753
+ self._ignored_requests: dict[tuple[str, int], tuple[str, float]] = {}
1708
1754
  # (repo full name, PR number) -> where that round sits in the spawn
1709
1755
  # queue, from the first pass that deferred it. Cross-pass and
1710
1756
  # in-memory: it is a FAIRNESS ORDER, not a decision the daemon must
@@ -2170,6 +2216,17 @@ class ReviewWatcher:
2170
2216
  if deferred is not None:
2171
2217
  return deferred
2172
2218
 
2219
+ # THE ROUND-ADMISSION GATE (issue #128): a round on a head that already
2220
+ # carries a verdict needs FRESH evidence of a request -- a
2221
+ # `review_requested` event newer than that verdict -- and never starts
2222
+ # inside the post-verdict cooldown at all. Below the in-flight and
2223
+ # stale-round reads (a running round costs no timeline call) and above
2224
+ # every other refusal, because a round this gate turns away must
2225
+ # consume neither a rollup nor a place in the slot queue.
2226
+ refused = self._admit_round(pr, my_reviews, round_)
2227
+ if refused is not None:
2228
+ return refused
2229
+
2173
2230
  # THE SPAWN GATE, and it sits here -- past every branch that decides
2174
2231
  # WHETHER a round is owed, immediately before the one that acts.
2175
2232
  # Upstream of it the loop is only reading; downstream it starts an
@@ -2307,6 +2364,186 @@ class ReviewWatcher:
2307
2364
  round_,
2308
2365
  )
2309
2366
 
2367
+ # -- round admission (issue #128) ----------------------------------------
2368
+
2369
+ def _admit_round(
2370
+ self, pr: PullRequest, my_reviews: list[Review], round_: int
2371
+ ) -> Decision | None:
2372
+ """Refuse a round the PR's review-request snapshot alone would start,
2373
+ or None to carry on.
2374
+
2375
+ The search set is `review-requested:@me`, and the loop's edge trigger
2376
+ has always been GitHub consuming that request when the requested
2377
+ identity submits a review. That consumption is not atomic with the
2378
+ daemon's reads: on studio #1243 round 1 posted `request_changes` at
2379
+ 14:35:32 on head 0d9d66b7, the poll at 14:35:42 still found the PR in
2380
+ the search with the reviewer in `requested_reviewers`, and queued round
2381
+ 2 on the same head -- a round nobody asked for, which then bounced on
2382
+ the CR8 triage gate and made the devloop spawn a second fix session on
2383
+ one branch. `requested_reviewers` is therefore a HINT here, never the
2384
+ verdict. A round on head H is admitted only when
2385
+
2386
+ (a) no verdict of record exists for (PR, H) -- a push re-arms exactly
2387
+ as before, since the newest review then judges an older commit; or
2388
+ (b) the issue timeline shows a `review_requested` event for the
2389
+ reviewer login NEWER than the last verdict on H.
2390
+
2391
+ And inside `verdict_cooldown_s` of that verdict nothing is admitted on
2392
+ H at all, whatever the timeline says: the cooldown alone would have
2393
+ prevented #1243, and (b) is what makes the gate correct rather than
2394
+ merely rare. The verdict's timestamp is the newer of GitHub's own
2395
+ review record and the ledger's (the daemon stamps its native posts the
2396
+ moment they land, before GitHub's reviews list catches up).
2397
+
2398
+ A refused round is a SKIPPED decision and holds no queue place, like
2399
+ the other pre-start refusals. Each ignored request is announced once at
2400
+ INFO -- "stale request" must be tellable from "idle" in the log -- and
2401
+ repeated at debug while the same request keeps being seen.
2402
+
2403
+ Two clocks, kept apart (PR #129 round 1): the cooldown is a duration
2404
+ on THIS host's wall clock, measured from the newer of GitHub's stamp
2405
+ and the ledger's local one; the comparison in (b) is GitHub's
2406
+ `created_at` against GitHub's `submitted_at`, both GitHub's clock, so
2407
+ a host running ahead of GitHub cannot make its own native post look
2408
+ newer than a genuine re-request that followed it. The ledger's local
2409
+ stamp enters (b) only while GitHub's reviews list has not yet shown
2410
+ the verdict at all -- the window the ledger exists for.
2411
+ """
2412
+ verdict = self._last_verdict_at(pr, my_reviews)
2413
+ if verdict is None:
2414
+ return None
2415
+ verdict_at = verdict.at
2416
+
2417
+ key = (pr.full_name, pr.number)
2418
+ since = time.time() - verdict_at
2419
+ cooldown = self.config.verdict_cooldown_s
2420
+ # A verdict stamped in the FUTURE (clock skew between GitHub and this
2421
+ # host) is not inside the window measured from it; the timeline check
2422
+ # below still decides, so nothing is lost by not guessing.
2423
+ if 0 <= since < cooldown:
2424
+ self._waiting.pop(key, None)
2425
+ log.info(
2426
+ "%s round %d: the last verdict on head %s landed %.0fs ago (%s) "
2427
+ "— inside the %ds post-verdict cooldown, not queuing whatever "
2428
+ "the review-request snapshot says",
2429
+ pr.slug, round_, pr.head_sha[:8], since, _stamp(verdict_at), cooldown,
2430
+ )
2431
+ return Decision(
2432
+ Action.SKIPPED,
2433
+ f"head {pr.head_sha[:8]} had a verdict {int(since)}s ago — inside "
2434
+ f"the {cooldown}s post-verdict cooldown",
2435
+ round_,
2436
+ )
2437
+
2438
+ requested = self._fresh_request_at(pr)
2439
+ if requested is None:
2440
+ # The newest request could not be seen -- the timeline was
2441
+ # unreadable or longer than the bound (logged there). The snapshot
2442
+ # is all that is left, and past the cooldown it is the pre-#128
2443
+ # trigger: a wedged loop on every read failure is the worse trade.
2444
+ return None
2445
+ request_at = _epoch(requested)
2446
+ verdict_at = verdict.github_at if verdict.github_at is not None else verdict.at
2447
+ if request_at is not None and request_at > verdict_at:
2448
+ return None
2449
+
2450
+ self._waiting.pop(key, None)
2451
+ seen = (
2452
+ f"request seen at {requested}"
2453
+ if request_at is not None
2454
+ else f"no review_requested event for {self.github.login} on the timeline"
2455
+ )
2456
+ message = (
2457
+ "%s round %d: %s is older than the last verdict at %s on head %s "
2458
+ "— waiting for a fresh re-request"
2459
+ )
2460
+ args = (pr.slug, round_, seen, _stamp(verdict_at), pr.head_sha[:8])
2461
+ if self._ignored_requests.get(key) == (pr.head_sha, verdict_at):
2462
+ log.debug(message, *args)
2463
+ else:
2464
+ self._ignored_requests[key] = (pr.head_sha, verdict_at)
2465
+ log.info(message, *args)
2466
+ return Decision(
2467
+ Action.SKIPPED,
2468
+ f"{seen} is older than the last verdict on head {pr.head_sha[:8]} "
2469
+ f"— waiting for a fresh re-request",
2470
+ round_,
2471
+ )
2472
+
2473
+ def _last_verdict_at(
2474
+ self, pr: PullRequest, my_reviews: list[Review]
2475
+ ) -> VerdictOfRecord | None:
2476
+ """The newest verdict of record on the CURRENT head, or None when no
2477
+ reviewer-identity review judges this head.
2478
+
2479
+ Two sources, the newer wins for the cooldown. GitHub's reviews list is
2480
+ the authority for a verdict the reviewer SESSION posted (it never
2481
+ passes through the daemon), and the newest review is the one that
2482
+ matters: judging an older commit, it is not a verdict on this head at
2483
+ all. The ledger covers the daemon's own native posts during the window
2484
+ before GitHub's list shows them. A GitHub-observed verdict is written
2485
+ back to the ledger as telemetry, so the ledger converges on GitHub's
2486
+ truth and a row lost either way costs nothing. GitHub's own stamp is
2487
+ carried out separately because it is the only one in the timeline's
2488
+ clock domain -- see `_admit_round`.
2489
+ """
2490
+ newest = my_reviews[-1] if my_reviews else None
2491
+ seen: float | None = None
2492
+ if newest is not None and newest.commit_id and newest.commit_id == pr.head_sha:
2493
+ seen = _epoch(newest.submitted_at)
2494
+ if seen is not None:
2495
+ self.state.note_observed_verdict(
2496
+ pr.full_name, pr.number, pr.head_sha, int(seen), newest.url
2497
+ )
2498
+ ledger = self.state.last_verdict_at(pr.full_name, pr.number, pr.head_sha)
2499
+ candidates = [at for at in (seen, ledger) if at is not None]
2500
+ if not candidates:
2501
+ return None
2502
+ return VerdictOfRecord(at=max(candidates), github_at=seen)
2503
+
2504
+ def _fresh_request_at(self, pr: PullRequest) -> str | None:
2505
+ """When this login was most recently asked to review the PR, from the
2506
+ issue timeline, memoised for the pass: one timeline walk per candidate
2507
+ PR per poll, however many times the PR is evaluated. None when the
2508
+ read failed (a RateLimited still propagates to run_forever's backoff,
2509
+ exactly as every other read path lets it)."""
2510
+ key = (pr.full_name, pr.number)
2511
+ if key in self._pass_requests:
2512
+ return self._pass_requests[key]
2513
+ try:
2514
+ requested = self.github.review_requested_at(
2515
+ pr.owner, pr.repo, pr.number, self.github.login
2516
+ )
2517
+ except RateLimited:
2518
+ raise
2519
+ except TimelineTruncated as exc:
2520
+ # Same epistemic state as an unreadable timeline -- the newest
2521
+ # request cannot be seen -- so the same answer: fail OPEN. The
2522
+ # alternative, comparing against the newest request WITHIN the
2523
+ # bound, refuses every same-head round on a long-timelined PR
2524
+ # forever with nothing but a warning (PR #129 round 1, major).
2525
+ log.warning(
2526
+ "%s: %s — admitting the round on the review-request snapshot "
2527
+ "alone, which is what the post-verdict cooldown guards",
2528
+ pr.slug, exc,
2529
+ )
2530
+ requested = None
2531
+ except Exception as exc:
2532
+ log.warning(
2533
+ "%s: could not read the issue timeline for review requests to "
2534
+ "%s (%s) — admitting the round on the review-request snapshot "
2535
+ "alone, which is what the post-verdict cooldown guards",
2536
+ pr.slug, self.github.login, exc,
2537
+ )
2538
+ requested = None
2539
+ else:
2540
+ # A timeline with no request at all is a real answer -- the
2541
+ # snapshot said "requested" and history says nobody did -- and it
2542
+ # is memoised as the empty string so the miss is not re-read.
2543
+ requested = requested or ""
2544
+ self._pass_requests[key] = requested
2545
+ return requested
2546
+
2310
2547
  def _refused_before_start(
2311
2548
  self, pr: PullRequest, round_: int, task: Task | None
2312
2549
  ) -> Decision | None:
@@ -3069,6 +3306,12 @@ class ReviewWatcher:
3069
3306
  self.state.record_verdict_post(
3070
3307
  pr.full_name, pr.number, round_, url, verdict=verdict
3071
3308
  )
3309
+ # The round-admission gate's cooldown is measured from HERE for the
3310
+ # daemon's own posts, so it holds even while GitHub's reviews list
3311
+ # still lags the POST it just accepted (issue #128).
3312
+ self.state.record_verdict(
3313
+ pr.full_name, pr.number, judged, int(time.time()), url or ""
3314
+ )
3072
3315
  log.info(
3073
3316
  "%s round %d closed: native %s review submitted as %s (%s)%s",
3074
3317
  pr.slug, round_, event, self.github.login, url or "no url", gate_note,
@@ -4920,6 +5163,7 @@ class ReviewWatcher:
4920
5163
  self._session_census = None
4921
5164
  self._census_probed = self._census_warned = False
4922
5165
  self._pass_roster = None
5166
+ self._pass_requests = {}
4923
5167
 
4924
5168
  # THE LEDGER GATE (issue #62, PR #63 round-1 blocker). Nothing below
4925
5169
  # may run when the ledger cannot record what it does.
@@ -95,6 +95,26 @@ CREATE TABLE IF NOT EXISTS grants (
95
95
  PRIMARY KEY (repo, number, comment_id)
96
96
  );
97
97
 
98
+ -- Every verdict of record the daemon knows about, keyed by the head it
99
+ -- judged (issue #128): the round-admission gate refuses to queue a round on a
100
+ -- head inside `verdict_cooldown_s` of the newest verdict on it, and past the
101
+ -- cooldown admits one only on a `review_requested` event NEWER than that
102
+ -- verdict. Two writers: loop._close_round_natively stamps its own post the
103
+ -- moment it lands (so the cooldown holds even while GitHub's reviews list
104
+ -- still lags the POST), and loop.evaluate stamps every reviewer-identity
105
+ -- review it observes on the current head (a session's own `gh pr review`
106
+ -- never passes through the daemon). Rows are never pruned -- one per verdict
107
+ -- ever landed -- and a lost row costs nothing: GitHub's reviews list is read
108
+ -- alongside it and the gate takes the newer of the two.
109
+ CREATE TABLE IF NOT EXISTS verdicts (
110
+ repo TEXT NOT NULL,
111
+ number INTEGER NOT NULL,
112
+ head_sha TEXT NOT NULL,
113
+ posted_at INTEGER NOT NULL,
114
+ review_url TEXT,
115
+ PRIMARY KEY (repo, number, head_sha, posted_at)
116
+ );
117
+
98
118
  CREATE TABLE IF NOT EXISTS pings (
99
119
  repo TEXT NOT NULL,
100
120
  number INTEGER NOT NULL,
@@ -728,6 +748,57 @@ class State:
728
748
  )
729
749
  self._db.commit()
730
750
 
751
+ def record_verdict(
752
+ self, repo: str, number: int, head_sha: str, posted_at: int,
753
+ review_url: str = "",
754
+ ) -> None:
755
+ """Remember that a verdict of record landed on `head_sha` at
756
+ `posted_at` (epoch seconds). Idempotent per (PR, head, timestamp), so
757
+ the same GitHub review observed on every poll writes one row."""
758
+ self._db.execute(
759
+ "INSERT OR IGNORE INTO verdicts "
760
+ "(repo, number, head_sha, posted_at, review_url) VALUES (?,?,?,?,?)",
761
+ (repo, number, head_sha, int(posted_at), review_url),
762
+ )
763
+ self._db.commit()
764
+
765
+ def note_observed_verdict(
766
+ self, repo: str, number: int, head_sha: str, posted_at: int,
767
+ review_url: str = "",
768
+ ) -> bool:
769
+ """TELEMETRY write-back of a verdict the loop saw on GitHub, so the
770
+ ledger converges on GitHub's truth (issue #128). Absorbed like every
771
+ other telemetry write: GitHub already holds the record, so a row the
772
+ ledger cannot take costs nothing. `record_verdict` itself stays the
773
+ hard write the daemon's own native post makes -- that row IS the
774
+ record until GitHub's reviews list catches up. True on success."""
775
+ return self._write_telemetry(
776
+ lambda: self.record_verdict(repo, number, head_sha, posted_at, review_url),
777
+ f"recording the verdict on {repo}#{number} at {head_sha[:8]}",
778
+ )
779
+
780
+ def last_verdict_at(self, repo: str, number: int, head_sha: str) -> int | None:
781
+ """When the newest verdict of record on this head landed (epoch
782
+ seconds), or None when the ledger knows of none on it."""
783
+ row = self._db.execute(
784
+ "SELECT MAX(posted_at) AS at FROM verdicts "
785
+ "WHERE repo=? AND number=? AND head_sha=?",
786
+ (repo, number, head_sha),
787
+ ).fetchone()
788
+ return int(row["at"]) if row and row["at"] is not None else None
789
+
790
+ def read_verdicts(self, limit: int | None = None) -> list[dict]:
791
+ """Verdict-of-record rows, newest first. For inspection; nothing in
792
+ the loop reads it. Mind that a `read_only=True` opener skips SCHEMA,
793
+ so against a ledger whose daemon has not yet restarted onto a version
794
+ with the `verdicts` table this raises `no such table`, not `[]` --
795
+ whoever wires it into the console first should guard for that."""
796
+ return self._read_rows(
797
+ "SELECT repo, number, head_sha, posted_at, review_url FROM verdicts "
798
+ "ORDER BY posted_at DESC, number DESC",
799
+ limit,
800
+ )
801
+
731
802
  def escalated(self, repo: str, number: int, head_sha: str) -> bool:
732
803
  """Whether this head has been paged at all. Not the whole dedupe story
733
804
  once re-entry grants exist -- a grant consumed without an approve is a
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.30.0
3
+ Version: 0.30.1
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera