alissa-tools-github-revloop 0.16.10__tar.gz → 0.16.12__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 (32) hide show
  1. {alissa_tools_github_revloop-0.16.10/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.12}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/alissa.py +177 -25
  3. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/loop.py +203 -12
  4. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/state.py +101 -0
  5. alissa_tools_github_revloop-0.16.12/src/main/alissa/tools/github/revloop/version +1 -0
  6. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_revloop-0.16.10/src/main/alissa/tools/github/revloop/version +0 -1
  8. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/LICENSE +0 -0
  9. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/MANIFEST.in +0 -0
  10. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/NOTICE +0 -0
  11. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/README.md +0 -0
  12. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/requirements.txt +0 -0
  13. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/setup.cfg +0 -0
  14. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/setup.py +0 -0
  15. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  16. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  17. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/config.py +0 -0
  18. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  19. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  20. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  21. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/version.py +0 -0
  22. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  23. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  24. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  25. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  26. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  27. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  28. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  29. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  30. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  31. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  32. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.12}/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.16.10
3
+ Version: 0.16.12
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
  import logging
7
7
  import re
8
8
  from dataclasses import dataclass
9
+ from datetime import datetime
9
10
  from pathlib import Path
10
11
 
11
12
  from .proc import CommandError, run, run_json
@@ -153,29 +154,139 @@ def _title_pattern(owner: str, repo: str, number: int) -> re.Pattern[str]:
153
154
  )
154
155
 
155
156
 
157
+ def is_review_task_for(owner: str, repo: str, number: int, task: "Task") -> bool:
158
+ """Whether `task` is THE open CR2 review task for this PR.
159
+
160
+ The one predicate, shared by the search (`find_review_task`, over a whole
161
+ task list) and by the cache check (loop._review_task, over a single task
162
+ read back by ref). They must agree: a cached ref that the search would not
163
+ have returned is a mapping the daemon has to drop, and a divergence here
164
+ would either pin a wrong task forever or re-fetch the corpus every pass
165
+ while disagreeing with itself.
166
+ """
167
+ return bool(_title_pattern(owner, repo, number).match(task.title)) and task.is_open
168
+
169
+
170
+ def _task_from_row(row: object) -> "Task | None":
171
+ """One task out of a CLI payload row, or None when it carries no usable ref.
172
+
173
+ Shared by the list reader and the single-task reader so both agree on which
174
+ field is the resolvable ref.
175
+ """
176
+ if not isinstance(row, dict):
177
+ return None
178
+ # `taskNumber` is the ref the API resolves; `taskSeq` is a display
179
+ # ordinal and 404s as `TASK-<seq>`.
180
+ number = row.get("taskNumber")
181
+ if number is None:
182
+ return None
183
+ title = row.get("title")
184
+ status = row.get("status")
185
+ return Task(
186
+ ref=f"TASK-{number}",
187
+ title=title if isinstance(title, str) else "",
188
+ status=status if isinstance(status, str) else "",
189
+ )
190
+
191
+
192
+ @dataclass(frozen=True)
193
+ class TaskDetail:
194
+ """One task as `alissa task get` sees it: the task itself, plus everything
195
+ the decide path reads off its CR6 verdict evidence.
196
+
197
+ All three travel together because they come out of ONE payload. The decide
198
+ path needs every one of them (is this still the PR's open review task? how
199
+ many rounds has it recorded? what did the newest round decide?), and
200
+ `_count_verdicts` and `_newest_verdict` are deliberately written as mirrors
201
+ over the same evidence array -- so reading them apart would mean fetching
202
+ and re-parsing the same task two and three times per PR per poll, which is
203
+ the exact cost this whole path exists to stop paying.
204
+
205
+ `verdict` is None when no envelope on the task parses -- the normal round-1
206
+ case, indistinguishable here from "no verdict of record yet", which is what
207
+ the caller wants it to mean anyway.
208
+ """
209
+
210
+ task: Task
211
+ verdicts: int
212
+ verdict: "str | None"
213
+
214
+
156
215
  class Alissa:
157
216
  def list_tasks(self) -> list[Task]:
217
+ """EVERY non-terminal task owned by this actor -- the expensive call.
218
+
219
+ `alissa task list` (CLI 0.1.0) exposes no server-side narrowing at all:
220
+ its only flags are `--json` and `--include-terminal`. Omitting the
221
+ latter is therefore the whole of the available filtering, and it is
222
+ already the default here -- validated and cancelled tasks never come
223
+ back. What remains is the actor's live corpus (hundreds of tasks,
224
+ ~250 KB), so the daemon's job is to call this RARELY rather than to
225
+ call it narrowly: see loop._review_task (persisted PR -> task mapping)
226
+ and loop._pass_task_list (at most one fetch per poll pass).
227
+ """
158
228
  data = run_json(["alissa", "task", "list", "--json"], timeout=90) or []
159
229
  tasks = []
160
- for row in data:
161
- # `taskNumber` is the ref the API resolves; `taskSeq` is a display
162
- # ordinal and 404s as `TASK-<seq>`.
163
- number = row.get("taskNumber")
164
- if number is None:
165
- continue
166
- tasks.append(
167
- Task(
168
- ref=f"TASK-{number}",
169
- title=row.get("title", ""),
170
- status=row.get("status", ""),
171
- )
172
- )
230
+ for row in data if isinstance(data, list) else []:
231
+ task = _task_from_row(row)
232
+ if task is not None:
233
+ tasks.append(task)
173
234
  return tasks
174
235
 
175
- def find_review_task(self, owner: str, repo: str, number: int) -> Task | None:
176
- """CR2: exactly one review task per PR. Reuse it across rounds (CR7)."""
177
- pattern = _title_pattern(owner, repo, number)
178
- matches = [t for t in self.list_tasks() if pattern.match(t.title) and t.is_open]
236
+ def get_task(self, ref: str) -> "TaskDetail | None":
237
+ """Read ONE task by ref: title, status and verdict count in one call.
238
+
239
+ This is what makes a cached PR -> review-task mapping usable. Resolving
240
+ the task by ref costs a single-task fetch; resolving it by searching
241
+ titles costs the actor's entire corpus, which is the read this whole
242
+ path exists to stop paying every poll.
243
+
244
+ None means "could not be read" and NOTHING more -- a deleted task and a
245
+ transient CLI failure are indistinguishable from here, so a caller must
246
+ not treat None as proof that a cached mapping is wrong (see
247
+ loop._review_task, which keeps the row and falls back to the search).
248
+ Never raises: the daemon polls forever and this runs inside every pass.
249
+ """
250
+ try:
251
+ data = run_json(["alissa", "task", "get", ref, "--json"], timeout=90)
252
+ except CommandError as exc:
253
+ log.warning("could not read task %s: %s", ref, exc)
254
+ return None
255
+ except Exception: # pragma: no cover - defence in depth
256
+ log.exception("unexpected failure reading task %s", ref)
257
+ return None
258
+
259
+ try:
260
+ task = _task_from_row(data)
261
+ if task is None:
262
+ return None
263
+ return TaskDetail(
264
+ task=task,
265
+ verdicts=self._count_verdicts(data),
266
+ verdict=self._newest_verdict(data),
267
+ )
268
+ except Exception: # pragma: no cover - defence in depth
269
+ log.exception("could not parse task payload for %s", ref)
270
+ return None
271
+
272
+ def find_review_task(
273
+ self,
274
+ owner: str,
275
+ repo: str,
276
+ number: int,
277
+ *,
278
+ tasks: "list[Task] | None" = None,
279
+ ) -> Task | None:
280
+ """CR2: exactly one review task per PR. Reuse it across rounds (CR7).
281
+
282
+ `tasks` supplies a corpus the caller already fetched, so several PRs
283
+ missing the cache in the SAME poll pass share one list call instead of
284
+ issuing an identical one each (the observed 2-4 same-second bursts).
285
+ Omitted -- the console-script path, and any caller with no pass to
286
+ scope to -- fetches its own.
287
+ """
288
+ pool = self.list_tasks() if tasks is None else tasks
289
+ matches = [t for t in pool if is_review_task_for(owner, repo, number, t)]
179
290
 
180
291
  if not matches:
181
292
  return None
@@ -218,6 +329,44 @@ class Alissa:
218
329
  log.exception("could not parse verdict evidence for %s", task_ref)
219
330
  return None
220
331
 
332
+ @staticmethod
333
+ def _created_key(value: object) -> tuple[int, float]:
334
+ """One evidence item's `createdAt`, as a sortable stamp.
335
+
336
+ `alissa task get --json` dates evidence with epoch MILLISECONDS as an
337
+ int; the API's other surfaces (and hand-written fixtures) use an ISO-8601
338
+ string. Both are normalised here to one float, because the previous key
339
+ -- `created if isinstance(created, str) else ""` -- collapsed every real
340
+ item to the empty string, leaving `max` to keep the FIRST element of an
341
+ all-equal set. Evidence comes back oldest-first, so on live data the
342
+ OLDEST verdict won: a PR whose round 1 was request_changes and round 2
343
+ approve never converged through the envelope branch (TASK-194837655).
344
+
345
+ Normalising rather than widening the isinstance is deliberate: a task
346
+ carrying both shapes would produce `(str, ...)` and `(int, ...)` keys
347
+ that raise TypeError the moment sorting compared them.
348
+
349
+ Returns `(has_stamp, seconds)`. An absent or unparseable stamp is
350
+ `(0, 0.0)` and sorts FIRST, preserving the old rule that a dated
351
+ envelope always beats one that lost its timestamp.
352
+ """
353
+ if isinstance(value, bool): # bool is an int; never a timestamp
354
+ return (0, 0.0)
355
+ if isinstance(value, (int, float)):
356
+ seconds = float(value)
357
+ # Milliseconds, by magnitude: 1e11 seconds is the year 5138, while
358
+ # 1e11 milliseconds is 1973 -- so anything above it is ms, and a
359
+ # task holding both units still orders correctly.
360
+ if abs(seconds) > 1e11:
361
+ seconds /= 1000.0
362
+ return (1, seconds)
363
+ if isinstance(value, str) and value:
364
+ try:
365
+ return (1, datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())
366
+ except ValueError:
367
+ return (0, 0.0)
368
+ return (0, 0.0)
369
+
221
370
  @staticmethod
222
371
  def _newest_verdict(payload: object) -> str | None:
223
372
  """Pick the newest parseable verdict out of a task's evidence array.
@@ -231,8 +380,8 @@ class Alissa:
231
380
  if not isinstance(evidence, list):
232
381
  return None
233
382
 
234
- found: list[tuple[str, str]] = []
235
- for item in evidence:
383
+ found: list[tuple[tuple[int, float], int, str]] = []
384
+ for index, item in enumerate(evidence):
236
385
  if not isinstance(item, dict):
237
386
  continue
238
387
  title = item.get("title")
@@ -242,18 +391,21 @@ class Alissa:
242
391
  continue
243
392
  match = _VERDICT_RE.search(blob)
244
393
  if match:
245
- created = item.get("createdAt")
246
394
  found.append(
247
- (created if isinstance(created, str) else "", match.group(1).lower())
395
+ (Alissa._created_key(item.get("createdAt")),
396
+ index,
397
+ match.group(1).lower())
248
398
  )
249
399
  break
250
400
 
251
401
  if not found:
252
402
  return None
253
- # ISO-8601 timestamps sort lexicographically. Undated evidence sorts
254
- # first (empty string), so a dated envelope always wins over one that
255
- # lost its timestamp.
256
- return max(found, key=lambda pair: pair[0])[1]
403
+ # Newest stamp wins; undated evidence sorts first, so a dated envelope
404
+ # always beats one that lost its timestamp (see _created_key). The
405
+ # append INDEX breaks ties: evidence comes back oldest-first, so two
406
+ # envelopes sharing a stamp resolve to the later-recorded one rather
407
+ # than to whichever `max` happened to reach first.
408
+ return max(found, key=lambda item: (item[0], item[1]))[2]
257
409
 
258
410
  def count_verdicts(self, task_ref: str) -> int:
259
411
  """How many CR6 verdict envelopes are on the review task.
@@ -27,6 +27,8 @@ from .alissa import (
27
27
  ManagedSession,
28
28
  SessionRef,
29
29
  Task,
30
+ TaskDetail,
31
+ is_review_task_for,
30
32
  session_repo_slug,
31
33
  )
32
34
  from .config import (
@@ -871,6 +873,56 @@ class Decision:
871
873
  reenqueued: bool = False
872
874
 
873
875
 
876
+ @dataclass(frozen=True)
877
+ class ResolvedTask:
878
+ """One PR's review task as the decide path resolved it THIS pass.
879
+
880
+ Carries what the resolution happened to learn, and -- the part that matters
881
+ -- whether it learned it. The two resolution paths read different things:
882
+
883
+ * the CACHED path fetches the task by ref to check the mapping still holds,
884
+ so the whole evidence array comes with it and both the round count and the
885
+ newest verdict are free;
886
+ * the SEARCH path matches TITLES out of the task corpus, so it reads the
887
+ matched task afterwards to learn both -- and only when THAT read fails
888
+ does it fall back to a bare `count_verdicts` and know no verdict.
889
+
890
+ Hence `verdict_read`, rather than letting `verdict=None` stand for both "no
891
+ envelope parses" and "nobody looked". Convergence treats a None verdict as
892
+ "not approved"; conflating the two would silently refuse to close a round
893
+ that had in fact approved, every time the mapping was resolved by search.
894
+ """
895
+
896
+ task: "Task | None"
897
+ verdicts: int = 0
898
+ verdict: "str | None" = None
899
+ verdict_read: bool = False
900
+
901
+ @classmethod
902
+ def from_detail(cls, detail: TaskDetail) -> "ResolvedTask":
903
+ """One `alissa task get` payload answered all three questions.
904
+
905
+ The `verdict_read=True` invariant lives here rather than at the call
906
+ sites: it is a property of having read a task detail, and every path
907
+ that reads one gets it the same way.
908
+ """
909
+ return cls(
910
+ task=detail.task,
911
+ verdicts=detail.verdicts,
912
+ verdict=detail.verdict,
913
+ verdict_read=True,
914
+ )
915
+
916
+ def newest_verdict(self, alissa: Alissa) -> "str | None":
917
+ """The newest CR6 verdict envelope, fetching it only if this resolution
918
+ did not already have it in hand."""
919
+ if self.task is None:
920
+ return None
921
+ if self.verdict_read:
922
+ return self.verdict
923
+ return alissa.latest_verdict(self.task.ref)
924
+
925
+
874
926
  @dataclass(frozen=True)
875
927
  class ChecksGate:
876
928
  """What the head's CI rollup does to a round's APPROVE verdict.
@@ -955,6 +1007,16 @@ class ReviewWatcher:
955
1007
  # rollup (two API calls) on every poll, forever, for every PR with an
956
1008
  # owed approve. In-memory for the same reason _dry_run_drift is.
957
1009
  self._dry_run_rollups: dict[tuple[str, int, int, str], str] = {}
1010
+ # The task corpus THIS poll pass already fetched, or None until some
1011
+ # PR in it misses the review-task cache. `alissa task list` returns
1012
+ # every non-terminal task this actor owns (hundreds of rows, ~250 KB)
1013
+ # and the list that answers one PR's search answers all of them, so a
1014
+ # pass pays for it at most once instead of once per missing PR -- the
1015
+ # same-second bursts of 2-4 identical fetches the census caught.
1016
+ # In memory and reset per pass on purpose: this is a within-pass
1017
+ # de-duplication, and a corpus that outlived its pass would answer the
1018
+ # next one from titles and statuses that have since moved.
1019
+ self._pass_tasks: list[Task] | None = None
958
1020
  # Consecutive passes refused by the ledger gate in poll_once, and when
959
1021
  # the refusal began -- the same streak-limit-then-escalate shape the
960
1022
  # poll firewall uses, for the same reason: a read-only volume refuses
@@ -964,6 +1026,116 @@ class ReviewWatcher:
964
1026
  # that cannot be written.
965
1027
  self._ledger_streak = Streak()
966
1028
 
1029
+ # -- the PR -> review-task mapping ---------------------------------------
1030
+
1031
+ def _pass_task_list(self) -> list[Task]:
1032
+ """The actor's task corpus, fetched AT MOST ONCE per poll pass.
1033
+
1034
+ The fallback behind the review-task cache. Every PR that misses the
1035
+ cache in a given pass needs the same corpus, so the first miss pays for
1036
+ it and the rest read the memo -- see `_pass_tasks` for why it never
1037
+ outlives the pass.
1038
+
1039
+ A fetch that raises propagates, exactly as the unmemoized call did: the
1040
+ pass's caller turns it into a SKIPPED decision for that one PR and the
1041
+ memo stays empty, so the next PR retries rather than inheriting a
1042
+ failure it never saw.
1043
+ """
1044
+ if self._pass_tasks is None:
1045
+ self._pass_tasks = self.alissa.list_tasks()
1046
+ return self._pass_tasks
1047
+
1048
+ def _review_task(self, pr: PullRequest) -> "ResolvedTask":
1049
+ """This PR's open CR2 review task and what its evidence says, cheaply.
1050
+
1051
+ The decide path needs the review task on every pass of every open
1052
+ round, and the only way to FIND one is to search the actor's whole
1053
+ non-terminal task corpus by title -- a ~250 KB read that the daemon was
1054
+ paying once per candidate PR per poll, forever (issue #66). CR2 gives
1055
+ one review task per PR and CR7 reuses it across rounds, so the answer
1056
+ does not move once known: it is resolved once, persisted, and from then
1057
+ on read back by ref (one small task fetch, which the round count needed
1058
+ anyway).
1059
+
1060
+ Three outcomes, in the order they are tried:
1061
+
1062
+ * a cached ref that still reads as this PR's open review task -- the
1063
+ steady state, and no search at all;
1064
+ * a cached ref that is READABLE and no longer matches (validated,
1065
+ cancelled, retitled, or plain wrong) -- dropped, then searched for;
1066
+ * no cached ref, or one that could not be read -- searched for. An
1067
+ unreadable task is NOT a disproof, so the row survives a transient
1068
+ CLI failure and the pass just degrades to the old behaviour.
1069
+
1070
+ Fail-open is the whole contract: every degradation here lands on "do
1071
+ what the daemon did before the cache existed", and none of them can
1072
+ answer "no review task" unless a successful search actually said so.
1073
+
1074
+ TWO consequences of resolving from cache, both accepted rather than
1075
+ overlooked (PR #68 round 1):
1076
+
1077
+ * A cached hit does NOT re-check CR2 uniqueness. The duplicate-task
1078
+ alarm lives in `find_review_task`, which only runs on a miss, so a
1079
+ second open review task created for this PR AFTER the mapping was
1080
+ cached goes unreported and the daemon keeps counting envelopes on the
1081
+ one it pinned. Detecting it needs the corpus, and not fetching the
1082
+ corpus is the point -- relocating the check is TASK-317167904.
1083
+ * VALIDATING the review task drops the mapping, because
1084
+ `is_review_task_for` requires an open task and the search cannot find
1085
+ a terminal one either, so `evaluate` falls back to the GitHub review
1086
+ count -- the fallback `_completed_rounds` deliberately avoids. That is
1087
+ pre-existing and unchanged, but this path now HAS a durable ref that
1088
+ could survive validation; it is not used because a terminal task can
1089
+ never become open again, so a retained ref would have no disproof and
1090
+ its row would be immortal. Settling that is TASK-1897198077.
1091
+ """
1092
+ cached = self.state.review_task(pr.full_name, pr.number)
1093
+ if cached is not None:
1094
+ detail = self.alissa.get_task(cached)
1095
+ if detail is not None:
1096
+ if is_review_task_for(pr.owner, pr.repo, pr.number, detail.task):
1097
+ return ResolvedTask.from_detail(detail)
1098
+ log.info(
1099
+ "%s: cached review task %s no longer matches (title=%r "
1100
+ "status=%s) — re-resolving",
1101
+ pr.slug, cached, detail.task.title, detail.task.status,
1102
+ )
1103
+ self.state.forget_review_task(pr.full_name, pr.number)
1104
+ else:
1105
+ log.warning(
1106
+ "%s: could not read cached review task %s — falling back "
1107
+ "to the task search for this pass (the mapping is kept: "
1108
+ "an unreadable task is not a wrong one)",
1109
+ pr.slug, cached,
1110
+ )
1111
+
1112
+ task = self.alissa.find_review_task(
1113
+ pr.owner, pr.repo, pr.number, tasks=self._pass_task_list()
1114
+ )
1115
+ if task is None:
1116
+ # A search that completed and found nothing IS a disproof: there is
1117
+ # no open review task for this PR, whatever the cache said.
1118
+ if cached is not None:
1119
+ self.state.forget_review_task(pr.full_name, pr.number)
1120
+ return ResolvedTask(task=None)
1121
+
1122
+ self.state.record_review_task(pr.full_name, pr.number, task.ref)
1123
+ # The search matched a TITLE out of the corpus; the round count and the
1124
+ # verdict both live in the task's evidence, and ONE read returns both.
1125
+ # Reading it here rather than paying `count_verdicts` now and
1126
+ # `latest_verdict` again from convergence is the same saving the cached
1127
+ # path makes -- and it matters most in the degraded state this whole
1128
+ # change serves: an unreadable ledger sends EVERY PR down this path on
1129
+ # EVERY pass (PR #69 round 1).
1130
+ detail = self.alissa.get_task(task.ref)
1131
+ if detail is not None:
1132
+ return ResolvedTask.from_detail(detail)
1133
+ # That read failed, so the verdict is genuinely unknown -- left UNREAD
1134
+ # rather than defaulted to None, which convergence would take as "no
1135
+ # approve" and lose a closed round. Falls back to exactly the two-read
1136
+ # behaviour this path had before.
1137
+ return ResolvedTask(task=task, verdicts=self.alissa.count_verdicts(task.ref))
1138
+
967
1139
  # -- per-PR decision ---------------------------------------------------
968
1140
 
969
1141
  def evaluate(self, owner: str, repo: str, number: int) -> Decision:
@@ -993,11 +1165,10 @@ class ReviewWatcher:
993
1165
  # Fall back to the substantive-review count only before the review task
994
1166
  # exists (round 1). Looked up here (not in _spawn) because both the count
995
1167
  # and convergence need it.
996
- task = self.alissa.find_review_task(owner, repo, number)
1168
+ resolved = self._review_task(pr)
1169
+ task = resolved.task
997
1170
  native = countable_rounds(my_reviews)
998
- completed = (
999
- self.alissa.count_verdicts(task.ref) if task is not None else native
1000
- )
1171
+ completed = resolved.verdicts if task is not None else native
1001
1172
 
1002
1173
  # A round is not over until its verdict exists as a native review by
1003
1174
  # the reviewer identity (issue #51). An envelope ahead of the native
@@ -1027,7 +1198,7 @@ class ReviewWatcher:
1027
1198
  # anything else the round is still open and nothing may follow it.
1028
1199
  return self._close_round_natively(pr, task, round_=completed)
1029
1200
 
1030
- converged = self._convergence_reason(my_reviews, task, pr.head_sha)
1201
+ converged = self._convergence_reason(my_reviews, resolved, pr.head_sha)
1031
1202
  if converged is not None:
1032
1203
  # THE terminal branch: a verdict of record exists at the current
1033
1204
  # head, so no round k+1 can be owed from here -- every path that
@@ -1885,7 +2056,7 @@ class ReviewWatcher:
1885
2056
  )
1886
2057
 
1887
2058
  def _convergence_reason(
1888
- self, my_reviews: list[Review], task: Task | None, head_sha: str
2059
+ self, my_reviews: list[Review], resolved: "ResolvedTask", head_sha: str
1889
2060
  ) -> str | None:
1890
2061
  """Why the loop is done, or None if it is not.
1891
2062
 
@@ -1946,9 +2117,14 @@ class ReviewWatcher:
1946
2117
  return None
1947
2118
 
1948
2119
  # Only checkable once a review task exists; before that there is
1949
- # nowhere for a verdict to have been recorded.
1950
- if task is not None and self.alissa.latest_verdict(task.ref) == VERDICT_APPROVE:
1951
- return f"newest verdict envelope on {task.ref} reads approve"
2120
+ # nowhere for a verdict to have been recorded. On the cached path the
2121
+ # envelope was already read back with the task, so this costs nothing;
2122
+ # on the search path it is the one fetch that still has to happen.
2123
+ if (
2124
+ resolved.task is not None
2125
+ and resolved.newest_verdict(self.alissa) == VERDICT_APPROVE
2126
+ ):
2127
+ return f"newest verdict envelope on {resolved.task.ref} reads approve"
1952
2128
 
1953
2129
  return None
1954
2130
 
@@ -2923,6 +3099,12 @@ class ReviewWatcher:
2923
3099
  # -- polling -----------------------------------------------------------
2924
3100
 
2925
3101
  def poll_once(self) -> list[tuple[str, Decision]]:
3102
+ # A new pass sees a new corpus. Cleared FIRST, before any early return
3103
+ # can skip it: a memo left over from the previous pass would be handed
3104
+ # to this one's title searches as if it were current, and it is the one
3105
+ # piece of per-pass state whose staleness would be invisible.
3106
+ self._pass_tasks = None
3107
+
2926
3108
  # THE LEDGER GATE (issue #62, PR #63 round-1 blocker). Nothing below
2927
3109
  # may run when the ledger cannot record what it does.
2928
3110
  #
@@ -2950,9 +3132,18 @@ class ReviewWatcher:
2950
3132
  # DRY-RUN IS EXEMPT, and vacuously so: it already suppresses every side
2951
3133
  # effect AND every correctness write (`_spawn` skips record_spawn, the
2952
3134
  # reaper logs instead of killing, the drift/cap-out/deferral paths
2953
- # return before both their comment and their record). Its only ledger
2954
- # write is the snapshot, which this module classifies as best-effort
2955
- # telemetry and which _write_snapshot writes in dry-run deliberately.
3135
+ # return before both their comment and their record). The ledger writes
3136
+ # it may still take are the snapshot and the review-task cache
3137
+ # (`_review_task` runs in dry-run and both records and forgets
3138
+ # mappings) -- both classified by this module as best-effort telemetry,
3139
+ # both absorbed by _write_telemetry, and neither a decision the daemon
3140
+ # has to remember. Writing the cache in dry-run is deliberate: a
3141
+ # dry-run pass that learns a mapping hands it to the next production
3142
+ # pass, and suppressing it would make the two disagree about ledger
3143
+ # contents for no correctness reason. The cost on a read-only volume is
3144
+ # a reconnect attempt on the first failure of the streak plus
3145
+ # streak-limited warnings, which is the same best-effort behaviour the
3146
+ # snapshot has always had there.
2956
3147
  # So the gate would protect nothing there and cost the operator the one
2957
3148
  # tool that answers "what would you do right now" -- asked, precisely,
2958
3149
  # during the substrate incident this whole change is about.
@@ -9,6 +9,12 @@ was already escalated, and to count the operator re-entry acks that raise a
9
9
  single PR's effective cap. The ledger tolerates sessions dying or being
10
10
  killed behind its back: a reap record is bookkeeping, never a precondition.
11
11
 
12
+ The `review_tasks` table is a cache, not a ledger: it remembers which CR2
13
+ review task each PR resolved to so the decide path can read that one task by
14
+ ref instead of searching the actor's whole task corpus every poll. Every row is
15
+ re-checked on use and dropped when it stops matching, and losing the table
16
+ costs nothing but the search it was avoiding.
17
+
12
18
  The `poll_snapshots` table is a different animal from the ledger above: it
13
19
  records what each poll pass OBSERVED, not what the daemon must remember to
14
20
  avoid double-work. One row per pass carries the timing, the candidate count,
@@ -143,6 +149,28 @@ CREATE TABLE IF NOT EXISTS verdict_posts (
143
149
  PRIMARY KEY (repo, number, round)
144
150
  );
145
151
 
152
+ -- The PR -> CR2 review-task mapping, remembered so the decide path does not
153
+ -- have to search for it. CR2 guarantees one review task per PR and CR7 reuses
154
+ -- it across every round, so the mapping is stable for the PR's whole life --
155
+ -- which is exactly what makes it cacheable. A row here is a HINT, never a
156
+ -- fact: it is re-checked by ref on use and dropped when it stops matching, and
157
+ -- losing the whole table only costs one title search per PR (see
158
+ -- loop._review_task). That is why this is best-effort like the snapshots
159
+ -- above, not a correctness write.
160
+ --
161
+ -- Rows are never pruned, unlike poll_snapshots and like grants: one row per PR
162
+ -- ever sighted (a few hundred, for this daemon's repo set, forever), and a
163
+ -- stale row costs exactly one disproved read the first time that PR is seen
164
+ -- again. `resolved_at` is retained for inspection -- when was this mapping last
165
+ -- confirmed by a search -- and nothing in the daemon reads it.
166
+ CREATE TABLE IF NOT EXISTS review_tasks (
167
+ repo TEXT NOT NULL,
168
+ number INTEGER NOT NULL,
169
+ task_ref TEXT NOT NULL,
170
+ resolved_at INTEGER NOT NULL,
171
+ PRIMARY KEY (repo, number)
172
+ );
173
+
146
174
  CREATE TABLE IF NOT EXISTS poll_snapshots (
147
175
  id INTEGER PRIMARY KEY AUTOINCREMENT,
148
176
  ts INTEGER NOT NULL,
@@ -852,6 +880,79 @@ class State:
852
880
 
853
881
  # -- poll snapshots (the console sidecar's exhaust buffer) -------------
854
882
 
883
+ def review_task(self, repo: str, number: int) -> "str | None":
884
+ """The review-task ref last resolved for this PR, or None.
885
+
886
+ None is the FIRST-SIGHTING answer and also the answer after a database
887
+ that could not be written: both mean "search for it", which is what the
888
+ daemon did unconditionally before this table existed. There is nothing
889
+ a caller may conclude from None beyond that.
890
+
891
+ The only READ in this class that absorbs a database error, and for the
892
+ same reason its writes do: this table is an optimization, so a ledger
893
+ that cannot answer must cost the daemon a task search, never a review.
894
+ Everything else here is ledger state whose loss the caller has to see.
895
+ """
896
+ try:
897
+ row = self._db.execute(
898
+ "SELECT task_ref FROM review_tasks WHERE repo=? AND number=?",
899
+ (repo, number),
900
+ ).fetchone()
901
+ except sqlite3.DatabaseError as exc:
902
+ log.warning(
903
+ "state: review-task cache unreadable (%s: %s) — this pass "
904
+ "resolves %s#%d by searching, as it did before the cache",
905
+ type(exc).__name__, exc, repo, number,
906
+ )
907
+ return None
908
+ return None if row is None else str(row["task_ref"])
909
+
910
+ def record_review_task(self, repo: str, number: int, task_ref: str) -> bool:
911
+ """Remember which review task a PR resolved to. Best-effort.
912
+
913
+ REPLACE, not IGNORE: re-resolving is how a mapping is corrected, so the
914
+ newest answer has to win. `resolved_at` records when the mapping was
915
+ last confirmed by a search; it is kept for inspection (the age of a
916
+ mapping is the first thing worth knowing if one is ever suspected) and
917
+ no query, log line or console view reads it.
918
+
919
+ A database error here is absorbed exactly like a snapshot's: the cache
920
+ is an optimization, and a pass that cannot persist it still decides the
921
+ round correctly -- it just pays the search again next time.
922
+ """
923
+ return self._write_telemetry(
924
+ lambda: self._replace_review_task(repo, number, task_ref),
925
+ "review-task cache write",
926
+ )
927
+
928
+ def _replace_review_task(self, repo: str, number: int, task_ref: str) -> None:
929
+ self._db.execute(
930
+ "INSERT OR REPLACE INTO review_tasks "
931
+ "(repo, number, task_ref, resolved_at) VALUES (?,?,?,?)",
932
+ (repo, number, task_ref, int(time.time())),
933
+ )
934
+ self._db.commit()
935
+
936
+ def forget_review_task(self, repo: str, number: int) -> bool:
937
+ """Drop a mapping that has been DISPROVED. Best-effort.
938
+
939
+ Only ever called on a positive disproof -- the task was read and is no
940
+ longer this PR's open review task, or a fresh search found none. Never
941
+ on a failed read: an unreadable task is not a wrong mapping, and
942
+ forgetting one on a transient CLI error would throw away a good cache
943
+ entry every time the API hiccups.
944
+ """
945
+ return self._write_telemetry(
946
+ lambda: self._delete_review_task(repo, number),
947
+ "review-task cache invalidation",
948
+ )
949
+
950
+ def _delete_review_task(self, repo: str, number: int) -> None:
951
+ self._db.execute(
952
+ "DELETE FROM review_tasks WHERE repo=? AND number=?", (repo, number)
953
+ )
954
+ self._db.commit()
955
+
855
956
  def record_snapshot(
856
957
  self,
857
958
  *,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.16.10
3
+ Version: 0.16.12
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera