alissa-tools-github-revloop 0.17.0__tar.gz → 0.18.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.
Files changed (32) hide show
  1. {alissa_tools_github_revloop-0.17.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.18.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/__main__.py +36 -0
  3. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/alissa.py +215 -12
  4. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/config.py +54 -0
  5. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/loop.py +97 -15
  6. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/prreview.py +7 -1
  7. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/state.py +166 -0
  8. alissa_tools_github_revloop-0.18.0/src/main/alissa/tools/github/revloop/version +1 -0
  9. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  10. alissa_tools_github_revloop-0.17.0/src/main/alissa/tools/github/revloop/version +0 -1
  11. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/LICENSE +0 -0
  12. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/MANIFEST.in +0 -0
  13. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/NOTICE +0 -0
  14. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/README.md +0 -0
  15. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/requirements.txt +0 -0
  16. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/setup.cfg +0 -0
  17. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/setup.py +0 -0
  18. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  19. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  20. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  21. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  22. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  23. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  24. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  25. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  26. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  27. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  28. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  29. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  30. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  31. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  32. {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.18.0}/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.17.0
3
+ Version: 0.18.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -146,6 +146,32 @@ def build_parser() -> argparse.ArgumentParser:
146
146
  "evidence; 0 queues immediately and relies on the directive alone",
147
147
  )
148
148
 
149
+ over.add_argument(
150
+ "--review-task-miss-ttl-polls",
151
+ type=int,
152
+ metavar="N",
153
+ help="how many polls a PR with NO review task is taken on trust before "
154
+ "the task corpus is searched for one again; must be >= 1",
155
+ )
156
+
157
+ scope = over.add_mutually_exclusive_group()
158
+ scope.add_argument(
159
+ "--task-list-self-scope",
160
+ dest="task_list_self_scope",
161
+ action="store_true",
162
+ default=None,
163
+ help="narrow `alissa task list` to this actor's own rows (--self), "
164
+ "dropping the sponsor's corpus. Only for deployments where EVERY "
165
+ "review task is created by this daemon's own sessions: a review task "
166
+ "the list cannot see is a round the daemon cannot count",
167
+ )
168
+ scope.add_argument(
169
+ "--no-task-list-self-scope",
170
+ dest="task_list_self_scope",
171
+ action="store_false",
172
+ help="list the sponsor-union corpus even if the config narrows it",
173
+ )
174
+
149
175
  dry = over.add_mutually_exclusive_group()
150
176
  dry.add_argument(
151
177
  "--dry-run",
@@ -183,6 +209,8 @@ def overrides_from(args: argparse.Namespace) -> dict:
183
209
  "max_concurrent_sessions": args.max_concurrent_sessions,
184
210
  "checks_wait_seconds": args.checks_wait_seconds,
185
211
  "checks_spawn_wait_seconds": args.checks_spawn_wait_seconds,
212
+ "review_task_miss_ttl_polls": args.review_task_miss_ttl_polls,
213
+ "task_list_self_scope": args.task_list_self_scope,
186
214
  "dry_run": args.dry_run,
187
215
  }
188
216
 
@@ -226,6 +254,14 @@ def main(argv: list[str] | None = None) -> int:
226
254
 
227
255
  if args.pr:
228
256
  owner, repo, number = parse_pr_ref(args.pr)
257
+ # This mode exists to tell "the search did not find it" apart from
258
+ # "the decision was no", so it must never be answered by the
259
+ # negative cache -- a suppressed pass would report "no review task"
260
+ # without looking, which is precisely the confusion the flag is for.
261
+ # Re-arming here (rather than plumbing a bypass through `evaluate`)
262
+ # keeps the poll path with exactly one way in, and costs the daemon
263
+ # one corpus fetch on a hand-run diagnostic.
264
+ watcher.state.forget_review_task_miss(f"{owner}/{repo}", number)
229
265
  decision = watcher.evaluate(owner, repo, number)
230
266
  print(f"\n{args.pr} → {decision.action.value}")
231
267
  print(f" round: {decision.round}")
@@ -16,6 +16,72 @@ log = logging.getLogger(__name__)
16
16
  # A review task is "open" while it can still receive a verdict.
17
17
  OPEN_STATUSES = {"committed", "in_progress", "pending_validation", "todo"}
18
18
 
19
+ # -- narrowing the `alissa task list` call (issue #87) ------------------------
20
+ #
21
+ # `list_tasks` is the widest query this daemon issues -- no query string at all,
22
+ # the actor's entire non-terminal corpus, sponsor-union scoped -- and it was the
23
+ # single largest contributor to the Alissa deployment's #1 Database-I/O offender
24
+ # over 2026-08-12..16. Everything below is applied ONLY when the installed CLI
25
+ # advertises it (see Alissa.probe_task_list): an issue's claim about a flag is
26
+ # not evidence, and this daemon turns a non-zero `alissa` exit into a SKIPPED
27
+ # decision, so sending a flag the CLI does not have costs a review.
28
+
29
+ # Statuses a LIVE review task can hold. Deliberately OPEN_STATUSES itself and
30
+ # not a hand-written list: `is_review_task_for` already rejects every other
31
+ # status client-side, so filtering server-side on exactly this set cannot change
32
+ # which task the daemon resolves -- it only stops shipping the rows over the
33
+ # wire. Any status added to `is_open` is added here by construction.
34
+ TASK_LIST_STATUS_FLAG = "--status"
35
+ TASK_LIST_STATUS_FILTER = ",".join(sorted(OPEN_STATUSES))
36
+
37
+ # `--self` drops the SPONSOR's corpus and keeps only the calling actor's rows.
38
+ #
39
+ # NOT enabled by default, and the reason is measured rather than cautious. On
40
+ # the live fleet corpus (2026-08-16, 932 non-terminal rows) `--self` removes 36
41
+ # rows, 4% of the payload -- and 3 of the 371 `Review PR ...` tasks in it are
42
+ # among the rows it removes: they are owned by another actor, not by the agent
43
+ # actor whose sessions write the other 368. Review tasks are therefore
44
+ # PREDOMINANTLY actor-owned but not exclusively so, and a review task this call
45
+ # cannot see is a round the daemon cannot count. 4% of the wire is not worth
46
+ # that, so the flag is opt-in per deployment (`task_list_self_scope`).
47
+ TASK_LIST_SELF_FLAG = "--self"
48
+
49
+ # A lean projection of each row. The daemon keeps only taskNumber/title/status
50
+ # (see `_task_from_row`), so a digest view is pure saving with no semantics --
51
+ # which is why it is adopted whenever it exists and has no knob. It ships in the
52
+ # studio repo separately; until then the probe simply does not find it.
53
+ TASK_LIST_VIEW_FLAG = "--view"
54
+ TASK_LIST_DIGEST_VIEW = "digest"
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class TaskListFlags:
59
+ """What the installed `alissa task list` advertises in its own help.
60
+
61
+ All-False is both the "old CLI" answer and the "the probe could not run"
62
+ answer, and they are deliberately the same value: each means "make the call
63
+ the daemon has always made".
64
+ """
65
+
66
+ status: bool = False
67
+ self_scope: bool = False
68
+ digest: bool = False
69
+
70
+
71
+ def _advertises(helptext: str, flag: str) -> bool:
72
+ """Whether `flag` appears as an OPTION in a CLI help listing.
73
+
74
+ Anchored to the start of a help line (allowing a short alias in front, as in
75
+ `-h, --help`) so a flag merely NAMED in some other option's prose -- "Pair
76
+ with --include-shared" is in this very help text -- is not read as an offer
77
+ of that flag. The trailing guard rejects a longer flag that merely starts
78
+ with this one (`--self-only` is not `--self`).
79
+ """
80
+ return re.search(
81
+ rf"(?m)^\s*(?:-\w,\s+)?{re.escape(flag)}(?![\w-])", helptext
82
+ ) is not None
83
+
84
+
19
85
  # CR6 verdict envelope outcomes.
20
86
  VERDICT_APPROVE = "approve"
21
87
  VERDICT_REQUEST_CHANGES = "request_changes"
@@ -213,19 +279,156 @@ class TaskDetail:
213
279
 
214
280
 
215
281
  class Alissa:
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).
282
+ def __init__(self, *, task_list_self_scope: bool = False) -> None:
283
+ """`task_list_self_scope` opts the list call into `--self`.
284
+
285
+ Default OFF, and that default is evidence, not caution -- see
286
+ TASK_LIST_SELF_FLAG. It is still a knob because ownership is a property
287
+ of a DEPLOYMENT (who creates its review tasks), not of this code, and an
288
+ operator who knows their review tasks are all actor-owned should be able
289
+ to say so.
290
+ """
291
+ self._task_list_self_scope = bool(task_list_self_scope)
292
+ # The probe's answer, memoized for the process; None = not probed yet.
293
+ # A probe that FAILS is deliberately not memoized (see probe_task_list).
294
+ self._task_list_flags: "TaskListFlags | None" = None
295
+ # Set when a narrowed call has been disproved at RUNTIME -- the CLI
296
+ # advertised a flag whose call then failed or came back empty. From then
297
+ # on this process makes the plain call, because a list that answers
298
+ # wrongly is worse than a list that is large: `find_review_task` reads an
299
+ # empty corpus as "this PR has no review task".
300
+ self._task_list_narrowing_disabled = False
301
+
302
+ # -- the `alissa task list` narrowing probe -----------------------------
303
+
304
+ def probe_task_list(self) -> "TaskListFlags":
305
+ """Which narrowing flags the INSTALLED `alissa task list` advertises.
306
+
307
+ Read off the CLI's own `--help`, which is local, tokenless and free.
308
+ The alternative -- send the flag and fall back when the call fails --
309
+ cannot tell an unknown flag from an auth hiccup, and this daemon turns a
310
+ non-zero `alissa` exit into a SKIPPED decision, so a mis-sent flag does
311
+ not cost a slower call, it costs a REVIEW.
312
+
313
+ Probed off the help OUTPUT rather than the exit status on purpose: this
314
+ CLI is commander-based and answers an unknown *subcommand* by printing
315
+ the parent help and exiting 0, so "it exited 0" reports every old CLI as
316
+ capable. (Flags are stricter than subcommands here, but the rule is the
317
+ same one and there is no reason to keep two.)
318
+
319
+ A probe that ANSWERS is memoized for the process -- the CLI cannot
320
+ change under a running daemon. A probe that FAILS is not: a transient
321
+ `alissa` failure then degrades one pass instead of pinning the daemon to
322
+ the widest call until someone restarts it.
323
+ """
324
+ if self._task_list_flags is not None:
325
+ return self._task_list_flags
326
+ try:
327
+ helptext = run(["alissa", "task", "list", "--help"], timeout=20)
328
+ except CommandError as exc:
329
+ log.warning(
330
+ "could not probe `alissa task list --help` (%s) — this pass "
331
+ "lists tasks unnarrowed, as the daemon always did", exc,
332
+ )
333
+ return TaskListFlags()
334
+ except Exception: # pragma: no cover - defence in depth
335
+ log.exception("unexpected failure probing `alissa task list --help`")
336
+ return TaskListFlags()
337
+
338
+ flags = TaskListFlags(
339
+ status=_advertises(helptext, TASK_LIST_STATUS_FLAG),
340
+ self_scope=_advertises(helptext, TASK_LIST_SELF_FLAG),
341
+ digest=_advertises(helptext, TASK_LIST_VIEW_FLAG),
342
+ )
343
+ self._task_list_flags = flags
344
+ return flags
345
+
346
+ def task_list_argv(self, *, narrow_status: bool = True) -> list[str]:
347
+ """The narrowest `alissa task list` this CLI actually supports.
348
+
349
+ Every addition is probe-gated, so an older CLI -- today's, which offers
350
+ none of them -- produces exactly the call the daemon has always made.
227
351
  """
228
- data = run_json(["alissa", "task", "list", "--json"], timeout=90) or []
352
+ argv = ["alissa", "task", "list", "--json"]
353
+ if self._task_list_narrowing_disabled:
354
+ return argv
355
+ flags = self.probe_task_list()
356
+ if flags.status and narrow_status:
357
+ argv += [TASK_LIST_STATUS_FLAG, TASK_LIST_STATUS_FILTER]
358
+ if flags.self_scope and self._task_list_self_scope:
359
+ argv.append(TASK_LIST_SELF_FLAG)
360
+ if flags.digest:
361
+ argv += [TASK_LIST_VIEW_FLAG, TASK_LIST_DIGEST_VIEW]
362
+ return argv
363
+
364
+ def list_tasks(self, *, narrow_status: bool = True) -> list[Task]:
365
+ """This actor's live task corpus -- the expensive call.
366
+
367
+ `alissa task list` (CLI 0.1.0) exposed no server-side narrowing at all:
368
+ its only flags were `--json` and `--include-terminal`, and omitting the
369
+ latter -- already the default -- was the whole of the available
370
+ filtering. Newer CLIs offer more, so the call is now assembled from a
371
+ boot-time probe of the installed CLI's help (`task_list_argv`): a status
372
+ filter covering exactly the statuses a live review task can hold, a lean
373
+ `--view digest`, and `--self` when the deployment says its review tasks
374
+ are actor-owned. None of it is required; an absent flag is simply not
375
+ sent.
376
+
377
+ Narrowing is still the SECOND line of defence, not the first. Even a
378
+ perfectly narrowed call is the actor's whole review-task corpus, so the
379
+ daemon's job remains to call this RARELY: see loop._review_task (the
380
+ persisted PR -> task mapping), loop._pass_task_list (at most one fetch
381
+ per poll pass) and the negative cache behind them (state's
382
+ `review_task_misses`, which bounds the ONE case where none of those
383
+ help -- a PR that has no review task at all).
384
+
385
+ `narrow_status=False` is for callers that must see review tasks the
386
+ daemon's own `is_open` predicate would reject (prreview reads a task's
387
+ verdict envelope after the round is over). It suppresses only the status
388
+ filter; every other narrowing still applies.
389
+
390
+ A narrowed call that FAILS, or that answers with an empty corpus, is
391
+ retried once unnarrowed and turns the narrowing off for the rest of the
392
+ process. Both are how a CLI that advertises a flag its API does not
393
+ serve would present, and either would otherwise read as "this actor has
394
+ no review tasks" -- which is a skipped review, not a slower one.
395
+ """
396
+ argv = self.task_list_argv(narrow_status=narrow_status)
397
+ plain = ["alissa", "task", "list", "--json"]
398
+ try:
399
+ data = run_json(argv, timeout=90) or []
400
+ except CommandError:
401
+ if argv == plain:
402
+ raise
403
+ log.warning(
404
+ "`%s` failed — retrying the plain task list and dropping the "
405
+ "narrowing for this process", " ".join(argv),
406
+ )
407
+ self._task_list_narrowing_disabled = True
408
+ data = run_json(plain, timeout=90) or []
409
+
410
+ tasks = self._tasks_from(data)
411
+ if tasks or argv == plain:
412
+ return tasks
413
+
414
+ # An empty answer from a narrowed call. A genuinely empty corpus is
415
+ # possible and costs one extra list; a filter the API does not serve
416
+ # would cost every review this actor owns.
417
+ log.warning(
418
+ "`%s` returned no tasks — retrying the plain task list to tell an "
419
+ "empty corpus from a filter this API does not serve", " ".join(argv),
420
+ )
421
+ tasks = self._tasks_from(run_json(plain, timeout=90) or [])
422
+ if tasks:
423
+ self._task_list_narrowing_disabled = True
424
+ log.warning(
425
+ "the plain task list returned %d task(s) — the narrowed call is "
426
+ "dropping rows, so this process stops narrowing", len(tasks),
427
+ )
428
+ return tasks
429
+
430
+ @staticmethod
431
+ def _tasks_from(data: object) -> list[Task]:
229
432
  tasks = []
230
433
  for row in data if isinstance(data, list) else []:
231
434
  task = _task_from_row(row)
@@ -132,6 +132,8 @@ CONFIG_KEYS = (
132
132
  "max_concurrent_sessions",
133
133
  "checks_wait_seconds",
134
134
  "checks_spawn_wait_seconds",
135
+ "review_task_miss_ttl_polls",
136
+ "task_list_self_scope",
135
137
  "dry_run",
136
138
  )
137
139
 
@@ -223,6 +225,26 @@ DEFAULT_CHECKS_WAIT_SECONDS = 30 * 60
223
225
  # still-running rollup, which is the directive-only posture.
224
226
  DEFAULT_CHECKS_SPAWN_WAIT_SECONDS = 15 * 60
225
227
 
228
+ # How many polls a PR with NO review task may be taken on trust before the
229
+ # daemon searches the task corpus for one again (issue #87).
230
+ #
231
+ # The review-task cache can only remember an answer that EXISTS, so a PR with no
232
+ # review task -- a third-party PR, one whose task was validated or retitled --
233
+ # missed it on every pass and paid the widest read this daemon makes for the
234
+ # same answer every time: at a 60s poll, 1,440 full-corpus reads a day from one
235
+ # unmapped PR, forever.
236
+ #
237
+ # 10 is the trade, and the thing being traded is LATENCY, not correctness: a
238
+ # review task created while the window is open is picked up on the next search
239
+ # rather than the next poll, so the cost of the default is up to ten minutes
240
+ # before that PR's round 1 is queued -- against a 99% cut in the reads. Tune it
241
+ # down on a deployment where review tasks appear after their PR does and the
242
+ # wait is felt; tune it up on one where they are created up front.
243
+ #
244
+ # There is no "off" value: the floor is 1 (suppress one poll), because 0 would
245
+ # not disable a cache, it would write rows nothing ever reads.
246
+ DEFAULT_REVIEW_TASK_MISS_TTL_POLLS = 10
247
+
226
248
 
227
249
  def default_state_path(workspace_root: Path) -> Path:
228
250
  return Path(workspace_root) / ".revloop" / "state.db"
@@ -291,6 +313,20 @@ class Config:
291
313
  # timer to configure.
292
314
  checks_spawn_wait_seconds: int = DEFAULT_CHECKS_SPAWN_WAIT_SECONDS
293
315
 
316
+ # How many polls a PR with no review task is taken on trust before the
317
+ # corpus is searched again; see DEFAULT_REVIEW_TASK_MISS_TTL_POLLS. Floor 1
318
+ # -- there is no value that turns the negative cache off.
319
+ review_task_miss_ttl_polls: int = DEFAULT_REVIEW_TASK_MISS_TTL_POLLS
320
+
321
+ # Whether `alissa task list` may be narrowed to THIS actor's own rows
322
+ # (`--self`), dropping the sponsor's corpus. Off by default because on the
323
+ # live fleet a small minority of review tasks are NOT owned by the actor the
324
+ # daemon runs as, and a review task it cannot see is a round it cannot count
325
+ # -- see alissa.TASK_LIST_SELF_FLAG for the measurement. Turn it on only
326
+ # where every review task is created by this daemon's own sessions. Ignored
327
+ # when the installed CLI does not advertise the flag.
328
+ task_list_self_scope: bool = False
329
+
294
330
  dry_run: bool = False
295
331
 
296
332
  def __post_init__(self) -> None:
@@ -443,6 +479,22 @@ class Config:
443
479
  f"checks_spawn_wait_seconds must be >= 0, got {spawn_wait}"
444
480
  )
445
481
 
482
+ miss_ttl = int(
483
+ raw.get("review_task_miss_ttl_polls", cls.review_task_miss_ttl_polls)
484
+ )
485
+ if miss_ttl < 1:
486
+ # Refused rather than clamped: 0 reads as "turn the negative cache
487
+ # off", but the cache is what bounds the widest read this daemon
488
+ # makes, and a 0 would keep writing ledger rows nothing consults. An
489
+ # operator who means "search every poll" has said something the
490
+ # daemon cannot do, and should hear that at load rather than
491
+ # discover it in the I/O bill.
492
+ raise ValueError(
493
+ f"review_task_miss_ttl_polls must be >= 1 (it is a number of "
494
+ f"polls to suppress, and there is no value that disables the "
495
+ f"negative cache), got {miss_ttl}"
496
+ )
497
+
446
498
  token_env = raw.get("reviewer_token_env")
447
499
  if token_env is not None:
448
500
  token_env = str(token_env).strip()
@@ -489,6 +541,8 @@ class Config:
489
541
  max_concurrent_sessions=max_sessions,
490
542
  checks_wait_seconds=checks_wait,
491
543
  checks_spawn_wait_seconds=spawn_wait,
544
+ review_task_miss_ttl_polls=miss_ttl,
545
+ task_list_self_scope=bool(raw.get("task_list_self_scope", False)),
492
546
  dry_run=bool(raw.get("dry_run", False)),
493
547
  )
494
548
 
@@ -621,9 +621,24 @@ MIN_SESSION_CHECKS_WAIT_SECONDS = 5 * 60
621
621
  # contexts x item, leaving the character cap as the backstop for a pathological
622
622
  # item rather than the thing that decides how many jobs a reviewer hears about.
623
623
  # 200 fits a GitHub Actions run URL (~105) plus a generous name and conclusion.
624
+ # The marker an item cut to fit carries. Defined here, next to the caps, because
625
+ # the derived budget below has to count it: it is part of what a full list of
626
+ # capped items actually measures (PR #85 round-3 nit).
627
+ DIRECTIVE_ITEM_TRUNCATED = "…"
628
+
624
629
  MAX_DIRECTIVE_ITEM_CHARS = 200
625
630
  MAX_DIRECTIVE_CONTEXTS = 10
626
- MAX_DIRECTIVE_DATA_CHARS = MAX_DIRECTIVE_CONTEXTS * MAX_DIRECTIVE_ITEM_CHARS
631
+ # The widest list the COUNT cap can pass, exactly: every item at its cap, every
632
+ # one of them carrying the cut marker, joined by "; ". The naive
633
+ # contexts x item was 28 characters short of that, so ten items at the item cap
634
+ # tripped the character backstop and nine were kept -- the character budget
635
+ # deciding how many jobs a reviewer hears about, which is the inversion the
636
+ # round-2 major was about, surviving at an input no real rollup produces. Stated
637
+ # as arithmetic so it stays true if either cap is retuned.
638
+ MAX_DIRECTIVE_DATA_CHARS = (
639
+ MAX_DIRECTIVE_CONTEXTS * (MAX_DIRECTIVE_ITEM_CHARS + len(DIRECTIVE_ITEM_TRUNCATED))
640
+ + (MAX_DIRECTIVE_CONTEXTS - 1) * len("; ")
641
+ )
627
642
 
628
643
  # The fence around interpolated data. A lead-in alone says where the data starts
629
644
  # and nothing says where it stops -- and in all three clauses the span is
@@ -642,7 +657,24 @@ DATA_CLOSE = "⟦end data⟧"
642
657
  # (PR #85 round-2 minor, with a working repro). Newlines and control characters
643
658
  # go for the same reason -- a fresh line reads as fresh prose -- and the fence's
644
659
  # own brackets go so the END marker cannot be forged.
645
- _DIRECTIVE_STRIP_RE = re.compile("[`\x00-\x1f\x7f" + re.escape("⟦⟧") + "]")
660
+ #
661
+ # The class is deliberately NOT ASCII-only, because the input is not: the first
662
+ # version stripped C0 and left U+0085, U+2028 and U+2029, every one of which
663
+ # renders as a line break, so the mitigation applied to one ENCODING of "start a
664
+ # fresh line" rather than to the effect (PR #85 round-3 minor). C1 goes with
665
+ # them, and so do the bidi controls -- U+202A-E and U+2066-9 reorder the rendered
666
+ # run, which is the same class of "what is displayed is not what the string
667
+ # says".
668
+ # Written as escapes, never as the characters themselves: every one of them
669
+ # is invisible or reorders its neighbours in an editor, which is precisely
670
+ # why they are stripped.
671
+ _DIRECTIVE_STRIP_RE = re.compile(
672
+ "[`\\x00-\\x1f\\x7f-\\x9f\\u2028\\u2029\\u202a-\\u202e\\u2066-\\u2069"
673
+ # The fence's BRACKETS only -- putting the whole markers in a character
674
+ # class would strip their letters out of every check name too.
675
+ + re.escape("⟦⟧")
676
+ + "]"
677
+ )
646
678
 
647
679
  # Says out loud that what follows is data, and exactly where it ends. A session
648
680
  # reading its directive has no other way to tell the daemon's instructions from a
@@ -711,12 +743,11 @@ CHECKS_AT_SPAWN_UNREADABLE = (
711
743
  # quotes mean something. Inside ⟦data⟧ the fence is the boundary.
712
744
  CHECKS_AT_SPAWN_FAILING = "{name} ({conclusion}){url}"
713
745
 
714
- # What the list becomes once the count cap has bitten, and what one over-long
715
- # item becomes. Both are visible on purpose: every truncation in this module has
746
+ # What the list becomes once the count cap has bitten. Visible on purpose, like
747
+ # the per-item marker beside the caps above: every truncation in this module has
716
748
  # to be readable in the directive, or a reviewer session cannot tell "these are
717
749
  # the failing checks" from "these are some of them".
718
750
  DIRECTIVE_DATA_TRUNCATED = " …(truncated: {dropped} more)"
719
- DIRECTIVE_ITEM_TRUNCATED = "…"
720
751
 
721
752
 
722
753
  def directive_text(value: str) -> str:
@@ -1350,7 +1381,9 @@ class ReviewWatcher:
1350
1381
  self.github = github or GitHub(
1351
1382
  config.reviewer_login, token_env=config.reviewer_token_env
1352
1383
  )
1353
- self.alissa = alissa or Alissa()
1384
+ self.alissa = alissa or Alissa(
1385
+ task_list_self_scope=config.task_list_self_scope
1386
+ )
1354
1387
  self.state = state or State(config.state_db)
1355
1388
  # (repo, number, comment id) of every re-entry directive already
1356
1389
  # refused in this process -- see _log_ignored_ack.
@@ -1476,9 +1509,29 @@ class ReviewWatcher:
1476
1509
  unreadable task is NOT a disproof, so the row survives a transient
1477
1510
  CLI failure and the pass just degrades to the old behaviour.
1478
1511
 
1512
+ ...and a FOURTH outcome that is none of those (issue #87): no cached ref
1513
+ and a recent search that already found nothing. The three above bound
1514
+ the cost of a PR whose review task EXISTS; a PR that has none missed
1515
+ every one of them on every pass and paid the corpus fetch for the same
1516
+ answer forever -- 1,440 whole-corpus reads a day, per unmapped PR, at a
1517
+ 60s poll. So a completed search that finds nothing is recorded as such
1518
+ and taken on trust for `review_task_miss_ttl_polls` further polls.
1519
+
1520
+ That negative answer is consulted ONLY when there is no cached ref at
1521
+ all, which is what keeps it from ever competing with the three outcomes
1522
+ above: a cached ref that reads and matches never reaches it, a cached
1523
+ ref that is DISPROVED must re-search on the strength of that fresh
1524
+ disproof (and only records a miss if that search also comes up empty),
1525
+ and a cached ref that could not be READ must fall back to the search,
1526
+ because an unreadable task is not a wrong one.
1527
+
1479
1528
  Fail-open is the whole contract: every degradation here lands on "do
1480
1529
  what the daemon did before the cache existed", and none of them can
1481
- answer "no review task" unless a successful search actually said so.
1530
+ answer "no review task" unless a successful search actually said so --
1531
+ including the negative cache, which is written only from a search that
1532
+ RAN and returned nothing, and which suppresses a pass only when the
1533
+ ledger accepted the write that spends it (see
1534
+ State.consume_review_task_miss).
1482
1535
 
1483
1536
  TWO consequences of resolving from cache, both accepted rather than
1484
1537
  overlooked (PR #68 round 1):
@@ -1517,6 +1570,12 @@ class ReviewWatcher:
1517
1570
  "an unreadable task is not a wrong one)",
1518
1571
  pr.slug, cached,
1519
1572
  )
1573
+ elif self.state.consume_review_task_miss(pr.full_name, pr.number):
1574
+ log.debug(
1575
+ "%s: a recent search found no review task and the answer has "
1576
+ "polls left — skipping the corpus fetch this pass", pr.slug,
1577
+ )
1578
+ return ResolvedTask(task=None)
1520
1579
 
1521
1580
  task = self.alissa.find_review_task(
1522
1581
  pr.owner, pr.repo, pr.number, tasks=self._pass_task_list()
@@ -1526,6 +1585,16 @@ class ReviewWatcher:
1526
1585
  # no open review task for this PR, whatever the cache said.
1527
1586
  if cached is not None:
1528
1587
  self.state.forget_review_task(pr.full_name, pr.number)
1588
+ # ...and it is the ONLY thing this daemon may record a negative
1589
+ # answer from. Recorded here rather than at the call sites because
1590
+ # this is the one place that knows the search RAN: a search that
1591
+ # raised never reaches this line at all (it propagates out of
1592
+ # `_pass_task_list` and the pass turns it into one PR's SKIPPED),
1593
+ # so a transient CLI failure can never buy itself a window of
1594
+ # silence.
1595
+ self.state.record_review_task_miss(
1596
+ pr.full_name, pr.number, self.config.review_task_miss_ttl_polls
1597
+ )
1529
1598
  return ResolvedTask(task=None)
1530
1599
 
1531
1600
  self.state.record_review_task(pr.full_name, pr.number, task.ref)
@@ -3863,6 +3932,19 @@ class ReviewWatcher:
3863
3932
  f"not an Alissa Code Workspace yet (`alissa code workspace init`)"
3864
3933
  )
3865
3934
 
3935
+ # The task-list narrowing probe (issue #87), run at BOOT so the answer
3936
+ # is memoized before the first pass and, more usefully, so the call the
3937
+ # daemon will actually make is in the startup log next to the config
3938
+ # that shaped it. Nothing here can fail the daemon: an unprobeable CLI
3939
+ # degrades to the unnarrowed call and says so.
3940
+ log.info("task list: %s", " ".join(self.alissa.task_list_argv()))
3941
+ if self.config.task_list_self_scope and not self.alissa.probe_task_list().self_scope:
3942
+ warnings.append(
3943
+ "task_list_self_scope is set but the installed `alissa` CLI "
3944
+ "does not advertise `task list --self` — the task list is not "
3945
+ "actor-scoped; upgrade the CLI or drop the key"
3946
+ )
3947
+
3866
3948
  if not self.config.dry_run and not self.alissa.worker_running():
3867
3949
  warnings.append(
3868
3950
  "`alissa worker` does not appear to be running — queued reviewer "
@@ -4072,14 +4154,14 @@ class ReviewWatcher:
4072
4154
  # effect AND every correctness write (`_spawn` skips record_spawn, the
4073
4155
  # reaper logs instead of killing, the drift/cap-out/deferral paths
4074
4156
  # return before both their comment and their record). The ledger writes
4075
- # it may still take are the snapshot and the review-task cache
4076
- # (`_review_task` runs in dry-run and both records and forgets
4077
- # mappings) -- both classified by this module as best-effort telemetry,
4078
- # both absorbed by _write_telemetry, and neither a decision the daemon
4079
- # has to remember. Writing the cache in dry-run is deliberate: a
4080
- # dry-run pass that learns a mapping hands it to the next production
4081
- # pass, and suppressing it would make the two disagree about ledger
4082
- # contents for no correctness reason. The cost on a read-only volume is
4157
+ # it may still take are the snapshot and the review-task cache, both
4158
+ # halves (`_review_task` runs in dry-run and records, forgets and spends
4159
+ # both mappings and misses) -- all classified by this module as
4160
+ # best-effort telemetry, all absorbed by _write_telemetry, and none a
4161
+ # decision the daemon has to remember. Writing the cache in dry-run is
4162
+ # deliberate: a dry-run pass that learns a mapping hands it to the next
4163
+ # production pass, and suppressing it would make the two disagree about
4164
+ # ledger contents for no correctness reason. The cost on a read-only volume is
4083
4165
  # a reconnect attempt on the first failure of the streak plus
4084
4166
  # streak-limited warnings, which is the same best-effort behaviour the
4085
4167
  # snapshot has always had there.
@@ -74,11 +74,17 @@ def _review_task_ref(alissa: Alissa, owner: str, repo: str, number: int) -> str
74
74
 
75
75
  Deliberately not `Alissa.find_review_task` (which filters to open tasks): we
76
76
  still want the ref just after the task is validated, to read its verdict.
77
+ `narrow_status=False` says the same thing to the LIST call for the same
78
+ reason -- the daemon's server-side status filter is exactly its `is_open`
79
+ set, and narrowing by it here would re-impose the filter this function
80
+ exists to avoid.
77
81
  """
78
82
  pattern = re.compile(
79
83
  rf"^Review PR\s+{re.escape(owner)}/{re.escape(repo)}#{number}\b", re.IGNORECASE
80
84
  )
81
- matches = [t for t in alissa.list_tasks() if pattern.match(t.title)]
85
+ matches = [
86
+ t for t in alissa.list_tasks(narrow_status=False) if pattern.match(t.title)
87
+ ]
82
88
  return matches[0].ref if matches else None
83
89
 
84
90
 
@@ -171,6 +171,33 @@ CREATE TABLE IF NOT EXISTS review_tasks (
171
171
  PRIMARY KEY (repo, number)
172
172
  );
173
173
 
174
+ -- The NEGATIVE half of the mapping above: PRs a completed search found NO open
175
+ -- review task for (issue #87). The table above can only remember an answer that
176
+ -- exists, so a PR without one missed the cache on every single pass and paid the
177
+ -- full corpus fetch for it -- 1,440 whole-corpus reads a day from one unmapped
178
+ -- PR, forever, because nothing about "there is no review task" ever changes on
179
+ -- its own. That is the widest read this daemon makes, on the path that repeats
180
+ -- most often.
181
+ --
182
+ -- `polls_left` is a COUNTDOWN, not a deadline: each pass that consults the row
183
+ -- burns one and the row is deleted at zero, so the suppression is measured in
184
+ -- polls of THIS PR (the unit the knob is written in) rather than in wall-clock
185
+ -- or in global passes -- a PR nobody is requesting a review on burns nothing.
186
+ -- It also makes the row self-cleaning, which matters because a negative answer
187
+ -- must never outlive its own re-check.
188
+ --
189
+ -- Best-effort like `review_tasks`, and fail-open in the same direction: a row
190
+ -- that cannot be written or decremented simply does not suppress anything, and
191
+ -- the pass pays the fetch it always paid. The one thing this table may never do
192
+ -- is answer "no review task" for longer than it was asked to.
193
+ CREATE TABLE IF NOT EXISTS review_task_misses (
194
+ repo TEXT NOT NULL,
195
+ number INTEGER NOT NULL,
196
+ polls_left INTEGER NOT NULL,
197
+ first_at INTEGER NOT NULL,
198
+ PRIMARY KEY (repo, number)
199
+ );
200
+
174
201
  -- One row per (PR, round, head) whose reviewer the pre-spawn CI gate has held
175
202
  -- back, stamped when the wait BEGAN (issue #84). That stamp is the only thing
176
203
  -- the gate needs to remember: everything else about the decision -- what is
@@ -1019,8 +1046,147 @@ class State:
1019
1046
  "(repo, number, task_ref, resolved_at) VALUES (?,?,?,?)",
1020
1047
  (repo, number, task_ref, int(time.time())),
1021
1048
  )
1049
+ # A mapping DISPROVES an outstanding negative answer, so the two tables
1050
+ # can never both speak for one PR. Dropped in the same transaction as
1051
+ # the mapping rather than by a separate call: a negative row that
1052
+ # survived a recorded mapping would keep the suppression running against
1053
+ # a PR the daemon can now resolve by ref -- which is not a wasted fetch
1054
+ # (the cached path skips the corpus anyway) but a stale claim in the
1055
+ # ledger, and it is the "mapping recorded mid-TTL re-arms" contract.
1056
+ self._db.execute(
1057
+ "DELETE FROM review_task_misses WHERE repo=? AND number=?",
1058
+ (repo, number),
1059
+ )
1060
+ self._db.commit()
1061
+
1062
+ # -- the NEGATIVE half: PRs with no review task at all (issue #87) ------
1063
+
1064
+ def record_review_task_miss(self, repo: str, number: int, polls: int) -> bool:
1065
+ """Remember that a COMPLETED search found no review task for this PR.
1066
+
1067
+ `polls` is how many later passes may take that answer on trust before
1068
+ the search is re-armed. REPLACE, not IGNORE: every fresh disproof
1069
+ restarts the countdown, which is what makes a run of misses cost one
1070
+ fetch per window rather than one per pass.
1071
+
1072
+ Only ever called on a search that RAN and found nothing -- never on a
1073
+ failed one. A miss recorded because the CLI hiccuped would suppress the
1074
+ retry that was supposed to correct it, which is the one way this table
1075
+ could cost a review instead of a read.
1076
+
1077
+ Best-effort, like every write in this class's optimization half: a
1078
+ `polls < 1` request records nothing (there is no window to remember),
1079
+ and a database error records nothing either. Both mean the next pass
1080
+ searches, which is what the daemon did before this table existed.
1081
+ """
1082
+ if polls < 1:
1083
+ return False
1084
+ return self._write_telemetry(
1085
+ lambda: self._replace_review_task_miss(repo, number, polls),
1086
+ "review-task miss write",
1087
+ )
1088
+
1089
+ def _replace_review_task_miss(self, repo: str, number: int, polls: int) -> None:
1090
+ self._db.execute(
1091
+ "INSERT OR REPLACE INTO review_task_misses "
1092
+ "(repo, number, polls_left, first_at) VALUES (?,?,?,?)",
1093
+ (repo, number, int(polls), int(time.time())),
1094
+ )
1022
1095
  self._db.commit()
1023
1096
 
1097
+ def consume_review_task_miss(self, repo: str, number: int) -> bool:
1098
+ """Spend one poll of this PR's negative answer. True = skip the search.
1099
+
1100
+ True is the ONLY answer that suppresses a corpus fetch, and it is
1101
+ returned only when a live row was read AND its countdown was
1102
+ successfully decremented. That coupling is the read-only-ledger
1103
+ contract: on a volume that flips read-only after a row was written, the
1104
+ row is still readable and would otherwise say "skip" forever, because
1105
+ nothing could ever burn it down. Requiring the write means such a ledger
1106
+ answers False every pass -- one fetch per pass, exactly today's
1107
+ behaviour -- rather than silently answering "no review task" until
1108
+ someone restarts the daemon.
1109
+
1110
+ False therefore means all of: no row, an exhausted row, an unreadable
1111
+ database, and a decrement that would not persist. The caller may
1112
+ conclude nothing from it beyond "search for it", which is what it did
1113
+ unconditionally before this table existed.
1114
+ """
1115
+ try:
1116
+ row = self._db.execute(
1117
+ "SELECT polls_left FROM review_task_misses WHERE repo=? AND number=?",
1118
+ (repo, number),
1119
+ ).fetchone()
1120
+ except sqlite3.DatabaseError as exc:
1121
+ log.warning(
1122
+ "state: review-task miss cache unreadable (%s: %s) — this pass "
1123
+ "resolves %s#%d by searching, as it did before the cache",
1124
+ type(exc).__name__, exc, repo, number,
1125
+ )
1126
+ return False
1127
+ if row is None or int(row["polls_left"]) < 1:
1128
+ # An exhausted row cannot occur through this method (the decrement
1129
+ # deletes at zero) but can through a hand-edited ledger; treat it as
1130
+ # the re-armed state it describes and clear it.
1131
+ if row is not None:
1132
+ self.forget_review_task_miss(repo, number)
1133
+ return False
1134
+ return self._write_telemetry(
1135
+ lambda: self._burn_review_task_miss(repo, number),
1136
+ "review-task miss decrement",
1137
+ )
1138
+
1139
+ def _burn_review_task_miss(self, repo: str, number: int) -> None:
1140
+ """One poll off the countdown, and the row goes when it runs out.
1141
+
1142
+ Both statements in one transaction so a row can never be left claiming a
1143
+ window it has already spent.
1144
+ """
1145
+ self._db.execute(
1146
+ "UPDATE review_task_misses SET polls_left = polls_left - 1 "
1147
+ "WHERE repo=? AND number=?",
1148
+ (repo, number),
1149
+ )
1150
+ self._db.execute(
1151
+ "DELETE FROM review_task_misses WHERE repo=? AND number=? AND polls_left < 1",
1152
+ (repo, number),
1153
+ )
1154
+ self._db.commit()
1155
+
1156
+ def forget_review_task_miss(self, repo: str, number: int) -> bool:
1157
+ """Re-arm the search for this PR now. Best-effort.
1158
+
1159
+ The manual counterpart to the countdown: `--pr` runs it so a one-shot
1160
+ diagnostic always reports what a search would find, never what a
1161
+ suppressed one was told to assume.
1162
+ """
1163
+ return self._write_telemetry(
1164
+ lambda: self._delete_review_task_miss(repo, number),
1165
+ "review-task miss invalidation",
1166
+ )
1167
+
1168
+ def _delete_review_task_miss(self, repo: str, number: int) -> None:
1169
+ self._db.execute(
1170
+ "DELETE FROM review_task_misses WHERE repo=? AND number=?", (repo, number)
1171
+ )
1172
+ self._db.commit()
1173
+
1174
+ def review_task_miss(self, repo: str, number: int) -> "int | None":
1175
+ """Polls left on this PR's negative answer, or None when it has none.
1176
+
1177
+ A read for tests and for an operator poking at the ledger; the decide
1178
+ path uses `consume_review_task_miss`, which is the only caller allowed
1179
+ to act on the answer (it burns a poll for the pass it grants).
1180
+ """
1181
+ try:
1182
+ row = self._db.execute(
1183
+ "SELECT polls_left FROM review_task_misses WHERE repo=? AND number=?",
1184
+ (repo, number),
1185
+ ).fetchone()
1186
+ except sqlite3.DatabaseError:
1187
+ return None
1188
+ return None if row is None else int(row["polls_left"])
1189
+
1024
1190
  def forget_review_task(self, repo: str, number: int) -> bool:
1025
1191
  """Drop a mapping that has been DISPROVED. Best-effort.
1026
1192
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.17.0
3
+ Version: 0.18.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera