alissa-tools-github-revloop 0.16.16__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.16.16/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.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/__main__.py +45 -0
  3. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/alissa.py +215 -12
  4. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/config.py +90 -0
  5. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/loop.py +668 -27
  6. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/prreview.py +7 -1
  7. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/state.py +247 -0
  8. alissa_tools_github_revloop-0.18.0/src/main/alissa/tools/github/revloop/version +1 -0
  9. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/webui/page.py +1 -1
  10. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  11. alissa_tools_github_revloop-0.16.16/src/main/alissa/tools/github/revloop/version +0 -1
  12. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/LICENSE +0 -0
  13. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/MANIFEST.in +0 -0
  14. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/NOTICE +0 -0
  15. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/README.md +0 -0
  16. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/requirements.txt +0 -0
  17. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/setup.cfg +0 -0
  18. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/setup.py +0 -0
  19. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  20. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  21. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  22. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  23. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  24. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  25. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  26. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  27. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  28. {alissa_tools_github_revloop-0.16.16 → alissa_tools_github_revloop-0.18.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  29. {alissa_tools_github_revloop-0.16.16 → 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.16.16 → 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.16.16 → 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.16.16 → 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.16.16
3
+ Version: 0.18.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -137,6 +137,40 @@ def build_parser() -> argparse.ArgumentParser:
137
137
  "rollup is still running (or unreadable) before recording the verdict "
138
138
  "as a comment instead; a red rollup never waits and never approves",
139
139
  )
140
+ over.add_argument(
141
+ "--checks-spawn-wait-seconds",
142
+ type=int,
143
+ metavar="SECONDS",
144
+ help="how long an owed round waits for the head's CI to conclude before "
145
+ "its reviewer is queued at all, so a session cannot approve ahead of the "
146
+ "evidence; 0 queues immediately and relies on the directive alone",
147
+ )
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
+ )
140
174
 
141
175
  dry = over.add_mutually_exclusive_group()
142
176
  dry.add_argument(
@@ -174,6 +208,9 @@ def overrides_from(args: argparse.Namespace) -> dict:
174
208
  "reap_session_cap": args.reap_session_cap,
175
209
  "max_concurrent_sessions": args.max_concurrent_sessions,
176
210
  "checks_wait_seconds": args.checks_wait_seconds,
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,
177
214
  "dry_run": args.dry_run,
178
215
  }
179
216
 
@@ -217,6 +254,14 @@ def main(argv: list[str] | None = None) -> int:
217
254
 
218
255
  if args.pr:
219
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)
220
265
  decision = watcher.evaluate(owner, repo, number)
221
266
  print(f"\n{args.pr} → {decision.action.value}")
222
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)
@@ -131,6 +131,9 @@ CONFIG_KEYS = (
131
131
  "reap_session_cap",
132
132
  "max_concurrent_sessions",
133
133
  "checks_wait_seconds",
134
+ "checks_spawn_wait_seconds",
135
+ "review_task_miss_ttl_polls",
136
+ "task_list_self_scope",
134
137
  "dry_run",
135
138
  )
136
139
 
@@ -204,6 +207,44 @@ DEFAULT_MAX_CONCURRENT_SESSIONS = 4
204
207
  # not of the daemon.
205
208
  DEFAULT_CHECKS_WAIT_SECONDS = 30 * 60
206
209
 
210
+ # How long an owed round waits for the head's checks to CONCLUDE before its
211
+ # reviewer is queued at all (issue #84). The bound above protects the verdict
212
+ # the DAEMON posts; this one protects the verdict the reviewer SESSION posts,
213
+ # which is the normal path and the one that has no gate anywhere else: on
214
+ # studio #560 a session approved a head 29 seconds before that same head's
215
+ # `test` job failed, and the red PR carried a green approval for two hours. A
216
+ # session that has not started cannot approve early, so the wait is applied to
217
+ # the spawn.
218
+ #
219
+ # 15 minutes, not the 30 above, and the asymmetry is deliberate: this bound is
220
+ # paid as LATENCY on every round of every PR whose checks are running, while the
221
+ # verdict bound is paid only by a round that has already finished reviewing. CI
222
+ # in this fleet concludes in 3-5 minutes, so 15 is several times the normal wait
223
+ # and still bounded well inside one review round. 0 disables the pre-spawn wait
224
+ # entirely -- the round is queued immediately and its directive carries the
225
+ # still-running rollup, which is the directive-only posture.
226
+ DEFAULT_CHECKS_SPAWN_WAIT_SECONDS = 15 * 60
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
+
207
248
 
208
249
  def default_state_path(workspace_root: Path) -> Path:
209
250
  return Path(workspace_root) / ".revloop" / "state.db"
@@ -264,6 +305,28 @@ class Config:
264
305
  # first poll that would have posted it.
265
306
  checks_wait_seconds: int = DEFAULT_CHECKS_WAIT_SECONDS
266
307
 
308
+ # The bound on holding an owed round's SPAWN while the head's rollup is
309
+ # still running; see DEFAULT_CHECKS_SPAWN_WAIT_SECONDS. 0 is legal and means
310
+ # "never hold the spawn": the round is queued at once and told what the
311
+ # rollup was. The re-check cadence is `poll_interval` -- a held round is
312
+ # re-decided by the poll like every other owed round, so there is no second
313
+ # timer to configure.
314
+ checks_spawn_wait_seconds: int = DEFAULT_CHECKS_SPAWN_WAIT_SECONDS
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
+
267
330
  dry_run: bool = False
268
331
 
269
332
  def __post_init__(self) -> None:
@@ -408,6 +471,30 @@ class Config:
408
471
  if checks_wait < 0:
409
472
  raise ValueError(f"checks_wait_seconds must be >= 0, got {checks_wait}")
410
473
 
474
+ spawn_wait = int(
475
+ raw.get("checks_spawn_wait_seconds", cls.checks_spawn_wait_seconds)
476
+ )
477
+ if spawn_wait < 0:
478
+ raise ValueError(
479
+ f"checks_spawn_wait_seconds must be >= 0, got {spawn_wait}"
480
+ )
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
+
411
498
  token_env = raw.get("reviewer_token_env")
412
499
  if token_env is not None:
413
500
  token_env = str(token_env).strip()
@@ -453,6 +540,9 @@ class Config:
453
540
  reap_session_cap=session_cap,
454
541
  max_concurrent_sessions=max_sessions,
455
542
  checks_wait_seconds=checks_wait,
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)),
456
546
  dry_run=bool(raw.get("dry_run", False)),
457
547
  )
458
548