alissa-tools-github-revloop 0.16.11__tar.gz → 0.16.13__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.11/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.13}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/__main__.py +8 -0
  3. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/alissa.py +70 -16
  4. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/config.py +48 -0
  5. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/loop.py +509 -17
  6. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/state.py +23 -4
  7. alissa_tools_github_revloop-0.16.13/src/main/alissa/tools/github/revloop/version +1 -0
  8. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/webui/page.py +1 -1
  9. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  10. alissa_tools_github_revloop-0.16.11/src/main/alissa/tools/github/revloop/version +0 -1
  11. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/LICENSE +0 -0
  12. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/MANIFEST.in +0 -0
  13. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/NOTICE +0 -0
  14. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/README.md +0 -0
  15. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/requirements.txt +0 -0
  16. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/setup.cfg +0 -0
  17. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/setup.py +0 -0
  18. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  19. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  20. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  21. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  22. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/version.py +0 -0
  23. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  24. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  25. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  26. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  27. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  28. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  29. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  30. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  31. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  32. {alissa_tools_github_revloop-0.16.11 → alissa_tools_github_revloop-0.16.13}/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.11
3
+ Version: 0.16.13
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -122,6 +122,13 @@ def build_parser() -> argparse.ArgumentParser:
122
122
  help="page-worthy threshold: more live reviewer sessions than this "
123
123
  "after a sweep and the daemon logs loudly",
124
124
  )
125
+ over.add_argument(
126
+ "--max-concurrent-sessions",
127
+ type=int,
128
+ metavar="N",
129
+ help="spawn gate: at this many live reviewer sessions an owed round "
130
+ "waits for a slot instead of spawning (must be <= --reap-session-cap)",
131
+ )
125
132
  over.add_argument(
126
133
  "--checks-wait-seconds",
127
134
  type=int,
@@ -165,6 +172,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
165
172
  "on_missing_hub": args.on_missing_hub,
166
173
  "reap_grace_seconds": args.reap_grace_seconds,
167
174
  "reap_session_cap": args.reap_session_cap,
175
+ "max_concurrent_sessions": args.max_concurrent_sessions,
168
176
  "checks_wait_seconds": args.checks_wait_seconds,
169
177
  "dry_run": args.dry_run,
170
178
  }
@@ -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
@@ -190,17 +191,25 @@ def _task_from_row(row: object) -> "Task | None":
190
191
 
191
192
  @dataclass(frozen=True)
192
193
  class TaskDetail:
193
- """One task as `alissa task get` sees it: the task itself, plus the CR6
194
- verdict envelopes already on it.
195
-
196
- The two travel together because they come out of ONE payload. The decide
197
- path needs both (is this still the PR's open review task? how many rounds
198
- has it recorded?), and reading them separately would cost two task fetches
199
- per PR per poll where the CLI already returns everything in one.
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.
200
208
  """
201
209
 
202
210
  task: Task
203
211
  verdicts: int
212
+ verdict: "str | None"
204
213
 
205
214
 
206
215
  class Alissa:
@@ -251,7 +260,11 @@ class Alissa:
251
260
  task = _task_from_row(data)
252
261
  if task is None:
253
262
  return None
254
- return TaskDetail(task=task, verdicts=self._count_verdicts(data))
263
+ return TaskDetail(
264
+ task=task,
265
+ verdicts=self._count_verdicts(data),
266
+ verdict=self._newest_verdict(data),
267
+ )
255
268
  except Exception: # pragma: no cover - defence in depth
256
269
  log.exception("could not parse task payload for %s", ref)
257
270
  return None
@@ -316,6 +329,44 @@ class Alissa:
316
329
  log.exception("could not parse verdict evidence for %s", task_ref)
317
330
  return None
318
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
+
319
370
  @staticmethod
320
371
  def _newest_verdict(payload: object) -> str | None:
321
372
  """Pick the newest parseable verdict out of a task's evidence array.
@@ -329,8 +380,8 @@ class Alissa:
329
380
  if not isinstance(evidence, list):
330
381
  return None
331
382
 
332
- found: list[tuple[str, str]] = []
333
- for item in evidence:
383
+ found: list[tuple[tuple[int, float], int, str]] = []
384
+ for index, item in enumerate(evidence):
334
385
  if not isinstance(item, dict):
335
386
  continue
336
387
  title = item.get("title")
@@ -340,18 +391,21 @@ class Alissa:
340
391
  continue
341
392
  match = _VERDICT_RE.search(blob)
342
393
  if match:
343
- created = item.get("createdAt")
344
394
  found.append(
345
- (created if isinstance(created, str) else "", match.group(1).lower())
395
+ (Alissa._created_key(item.get("createdAt")),
396
+ index,
397
+ match.group(1).lower())
346
398
  )
347
399
  break
348
400
 
349
401
  if not found:
350
402
  return None
351
- # ISO-8601 timestamps sort lexicographically. Undated evidence sorts
352
- # first (empty string), so a dated envelope always wins over one that
353
- # lost its timestamp.
354
- 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]
355
409
 
356
410
  def count_verdicts(self, task_ref: str) -> int:
357
411
  """How many CR6 verdict envelopes are on the review task.
@@ -129,6 +129,7 @@ CONFIG_KEYS = (
129
129
  "on_missing_hub",
130
130
  "reap_grace_seconds",
131
131
  "reap_session_cap",
132
+ "max_concurrent_sessions",
132
133
  "checks_wait_seconds",
133
134
  "dry_run",
134
135
  )
@@ -170,6 +171,25 @@ DEFAULT_REAP_GRACE_SECONDS = 30 * 60
170
171
  # healthy deployment reaches, not a capacity limit.
171
172
  DEFAULT_REAP_SESSION_CAP = 6
172
173
 
174
+ # The spawn gate: how many reviewer sessions of THIS daemon's own grammar may be
175
+ # live before an owed round waits for a slot instead of spawning (issue #70).
176
+ #
177
+ # Distinct from `reap_session_cap` above in kind, not just in number: that one is
178
+ # an ALARM on a condition the loop cannot fix (sessions the sweep could not
179
+ # reap), this one is a LIMIT the loop enforces on itself before it acts. Nothing
180
+ # bounded concurrency before it -- `round_cap` bounds rounds per PR, and the
181
+ # alarm only logs -- so a merge wave spawned one interactive claude session per
182
+ # PR, all at once, against a fixed container budget. On 2026-07-29 the 18:45-19:00Z
183
+ # burst pegged the deployment's 2 vCPU ceiling with 4+ concurrent reviewers plus
184
+ # the poll loop; throttled sessions review slower, hold their round slots longer,
185
+ # and widen the very burst that is starving them.
186
+ #
187
+ # 4 is the deployed shape's honest ceiling: two vCPUs, and a reviewer session is
188
+ # a full interactive agent. It is deliberately BELOW the reap alarm (6) so the
189
+ # steady state never pages -- and `Config.build` refuses a config where the alarm
190
+ # sits under the limit, which would page on healthy load.
191
+ DEFAULT_MAX_CONCURRENT_SESSIONS = 4
192
+
173
193
  # How long a round holds its APPROVE while the head's CI rollup is still
174
194
  # running (or unreadable) before it gives up and records the verdict as a
175
195
  # COMMENT instead. An approve from the reviewer identity is the operator's cue
@@ -233,6 +253,11 @@ class Config:
233
253
  reap_grace_seconds: int = DEFAULT_REAP_GRACE_SECONDS
234
254
  reap_session_cap: int = DEFAULT_REAP_SESSION_CAP
235
255
 
256
+ # The spawn gate's limit -- see DEFAULT_MAX_CONCURRENT_SESSIONS. At or above
257
+ # it an owed round defers to a later poll instead of spawning; it burns no
258
+ # round number and no attempt while it waits.
259
+ max_concurrent_sessions: int = DEFAULT_MAX_CONCURRENT_SESSIONS
260
+
236
261
  # The bound on holding a round's approve for a rollup that has not settled;
237
262
  # see DEFAULT_CHECKS_WAIT_SECONDS. 0 is legal and means "never hold": a
238
263
  # rollup that is not already green degrades the verdict to a comment on the
@@ -357,6 +382,28 @@ class Config:
357
382
  # state of a working loop -- an alarm that always fires is noise.
358
383
  raise ValueError(f"reap_session_cap must be >= 1, got {session_cap}")
359
384
 
385
+ max_sessions = int(
386
+ raw.get("max_concurrent_sessions", cls.max_concurrent_sessions)
387
+ )
388
+ if max_sessions < 1:
389
+ # 0 would defer every round forever: no session may spawn, so no
390
+ # slot ever frees. "Review nothing" is not a tuning value.
391
+ raise ValueError(
392
+ f"max_concurrent_sessions must be >= 1, got {max_sessions}"
393
+ )
394
+ if session_cap < max_sessions:
395
+ # The alarm would then fire on load the gate considers healthy --
396
+ # every poll of a fully-loaded, correctly-behaving daemon pages the
397
+ # operator, and a page that fires in the steady state trains people
398
+ # to ignore the one that matters. Refused at load rather than
399
+ # discovered at 3am.
400
+ raise ValueError(
401
+ f"reap_session_cap ({session_cap}) must be >= "
402
+ f"max_concurrent_sessions ({max_sessions}): the cap is the "
403
+ f"page-worthy alarm and the gate is the spawn limit, so an "
404
+ f"alarm below the limit pages on healthy load"
405
+ )
406
+
360
407
  checks_wait = int(raw.get("checks_wait_seconds", cls.checks_wait_seconds))
361
408
  if checks_wait < 0:
362
409
  raise ValueError(f"checks_wait_seconds must be >= 0, got {checks_wait}")
@@ -404,6 +451,7 @@ class Config:
404
451
  on_missing_hub=hub_mode,
405
452
  reap_grace_seconds=grace,
406
453
  reap_session_cap=session_cap,
454
+ max_concurrent_sessions=max_sessions,
407
455
  checks_wait_seconds=checks_wait,
408
456
  dry_run=bool(raw.get("dry_run", False)),
409
457
  )
@@ -27,6 +27,7 @@ from .alissa import (
27
27
  ManagedSession,
28
28
  SessionRef,
29
29
  Task,
30
+ TaskDetail,
30
31
  is_review_task_for,
31
32
  session_repo_slug,
32
33
  )
@@ -140,6 +141,81 @@ POLL_FAILURE_LOG_EVERY = 10
140
141
  EXPECTED_POLL_FAILURES = (CommandError,)
141
142
 
142
143
 
144
+ # -- the spawn gate (issue #70) -----------------------------------------------
145
+ #
146
+ # The one INFO line a poll pass emits about deferrals, summarizing them all.
147
+ # Per PASS, never per round: a wave of eight PRs behind a gate of four is one
148
+ # fact about the container, not eight facts about PRs, and the deployed daemon
149
+ # polls every 30s. Streak-limited on the same rule as the poll firewall (the
150
+ # first few in full, then one in ten) so a long queue costs a handful of lines
151
+ # an hour.
152
+ DEFERRAL_SUMMARY = (
153
+ "spawn gate: %d round(s) deferred — %d/%d reviewer sessions live. "
154
+ "%s Nothing is lost: a deferred round burns no round number and no "
155
+ "attempt, and the oldest waiter takes the next free slot."
156
+ )
157
+
158
+ # What the summary becomes once the gate has been shut, with NOTHING spawning,
159
+ # for longer than POLL_ESCALATE_SECONDS (PR #71 round-1 [major]). Deferral
160
+ # itself is never page-worthy -- a container at its limit that keeps handing
161
+ # out freed slots is the gate working -- but a gate that has spawned nothing
162
+ # for half an hour is not that. Three session classes can hold it shut with the
163
+ # reap alarm silent: a BUSY session is never reaped whatever its PR's state, a
164
+ # hand-spawned `review-pr-<n>` on an OPEN PR is out of the reaper's scope by
165
+ # design (issue #46), and an undecidable session is spared every poll. Four of
166
+ # those against the shipped defaults (limit 4, alarm 6) is a fleet-wide review
167
+ # outage that no other channel reports: the gated rounds write no ledger row,
168
+ # so the stale-round probe cannot see them either.
169
+ DEFERRAL_STALLED = (
170
+ "spawn gate: %d round(s) deferred — %d/%d reviewer sessions live, and "
171
+ "NOTHING has spawned for %.0f min. %s This is no longer back-pressure "
172
+ "doing its job: the reap sweep never frees a busy session, and never "
173
+ "frees a hand-spawned review-pr-<n> on an open PR, so a wedged session "
174
+ "holds its slot indefinitely — check the sweep's survivors above."
175
+ )
176
+
177
+ # The recovery line for an ESCALATED stall: the gate started something again
178
+ # while rounds are still waiting. Logged unconditionally, outside the streak
179
+ # limit, on the rule _note_ledger_writable states -- the operator's last word
180
+ # on a degraded daemon must not be the degradation.
181
+ GATE_STALL_CLEARED = (
182
+ "spawn gate: spawning again after %d pass(es) over %.0f min with nothing "
183
+ "started — %d round(s) still waiting, and the queue is moving"
184
+ )
185
+
186
+ # The recovery line, logged once when a deferral streak ends -- the operator's
187
+ # only evidence in the log that a queue drained on its own.
188
+ DEFERRAL_CLEARED = (
189
+ "spawn gate: clear after %d pass(es) over %.0f min — every owed round "
190
+ "spawned"
191
+ )
192
+
193
+
194
+ @dataclass(frozen=True)
195
+ class Waiting:
196
+ """One round's place in the spawn queue, from its FIRST deferral.
197
+
198
+ `seq` is the ordering key and it is a COUNTER, not a clock: two rounds
199
+ deferred in the same pass are microseconds apart, and a wall-clock tie
200
+ would be broken by whatever `sorted` felt like -- which is the search's own
201
+ order, i.e. the starvation this exists to prevent. `since` is monotonic and
202
+ only ever reported, never compared.
203
+ """
204
+
205
+ seq: int
206
+ since: float
207
+
208
+
209
+ def _slug_key(slug: str) -> tuple[str, int]:
210
+ """`acme/widgets#7` -> the `_waiting` key `("acme/widgets", 7)`.
211
+
212
+ The poll walk carries decisions keyed by slug and the gate keys by
213
+ (repo, number); this is the one place the two spellings meet.
214
+ """
215
+ repo, _, number = slug.partition("#")
216
+ return repo, int(number)
217
+
218
+
143
219
  class LedgerUnwritable(RuntimeError):
144
220
  """Raised by `poll_once` when the ledger gate refuses the pass.
145
221
 
@@ -850,6 +926,17 @@ class Action(str, Enum):
850
926
  # this one has given up, and the poll snapshot and console aggregate the
851
927
  # action rather than the reason.
852
928
  ABANDONED = "abandoned"
929
+ # The round is owed and nothing about the PR blocks it -- the SPAWN GATE
930
+ # does: `max_concurrent_sessions` reviewer sessions are already live, so it
931
+ # waits for a slot and is retried on a later poll (issue #70).
932
+ #
933
+ # NOT folded into the liveness deferral's IN_FLIGHT/`deferred` pair, which
934
+ # it superficially resembles: that one names a live session still working
935
+ # its round, and the console counts it as an ACTIVE SESSION
936
+ # (webui.sources: in_flight + deferred). A gated round has no session at
937
+ # all -- counting it as one would inflate exactly the number this gate
938
+ # exists to hold down.
939
+ QUEUED = "queued"
853
940
 
854
941
 
855
942
  @dataclass(frozen=True)
@@ -872,6 +959,56 @@ class Decision:
872
959
  reenqueued: bool = False
873
960
 
874
961
 
962
+ @dataclass(frozen=True)
963
+ class ResolvedTask:
964
+ """One PR's review task as the decide path resolved it THIS pass.
965
+
966
+ Carries what the resolution happened to learn, and -- the part that matters
967
+ -- whether it learned it. The two resolution paths read different things:
968
+
969
+ * the CACHED path fetches the task by ref to check the mapping still holds,
970
+ so the whole evidence array comes with it and both the round count and the
971
+ newest verdict are free;
972
+ * the SEARCH path matches TITLES out of the task corpus, so it reads the
973
+ matched task afterwards to learn both -- and only when THAT read fails
974
+ does it fall back to a bare `count_verdicts` and know no verdict.
975
+
976
+ Hence `verdict_read`, rather than letting `verdict=None` stand for both "no
977
+ envelope parses" and "nobody looked". Convergence treats a None verdict as
978
+ "not approved"; conflating the two would silently refuse to close a round
979
+ that had in fact approved, every time the mapping was resolved by search.
980
+ """
981
+
982
+ task: "Task | None"
983
+ verdicts: int = 0
984
+ verdict: "str | None" = None
985
+ verdict_read: bool = False
986
+
987
+ @classmethod
988
+ def from_detail(cls, detail: TaskDetail) -> "ResolvedTask":
989
+ """One `alissa task get` payload answered all three questions.
990
+
991
+ The `verdict_read=True` invariant lives here rather than at the call
992
+ sites: it is a property of having read a task detail, and every path
993
+ that reads one gets it the same way.
994
+ """
995
+ return cls(
996
+ task=detail.task,
997
+ verdicts=detail.verdicts,
998
+ verdict=detail.verdict,
999
+ verdict_read=True,
1000
+ )
1001
+
1002
+ def newest_verdict(self, alissa: Alissa) -> "str | None":
1003
+ """The newest CR6 verdict envelope, fetching it only if this resolution
1004
+ did not already have it in hand."""
1005
+ if self.task is None:
1006
+ return None
1007
+ if self.verdict_read:
1008
+ return self.verdict
1009
+ return alissa.latest_verdict(self.task.ref)
1010
+
1011
+
875
1012
  @dataclass(frozen=True)
876
1013
  class ChecksGate:
877
1014
  """What the head's CI rollup does to a round's APPROVE verdict.
@@ -966,6 +1103,38 @@ class ReviewWatcher:
966
1103
  # de-duplication, and a corpus that outlived its pass would answer the
967
1104
  # next one from titles and statuses that have since moved.
968
1105
  self._pass_tasks: list[Task] | None = None
1106
+ # -- the spawn gate's per-pass and cross-pass state (issue #70) ------
1107
+ # How many own-grammar reviewer sessions are live THIS pass, and
1108
+ # whether anyone has looked. The sweep seeds both (it lists the
1109
+ # sessions anyway); a count of None with `_census_probed` True means
1110
+ # the list could not be read, which the gate treats as "unknown" and
1111
+ # fails OPEN -- see _live_session_count.
1112
+ self._session_census: int | None = None
1113
+ self._census_probed = False
1114
+ self._census_warned = False
1115
+ # (repo full name, PR number) -> where that round sits in the spawn
1116
+ # queue, from the first pass that deferred it. Cross-pass and
1117
+ # in-memory: it is a FAIRNESS ORDER, not a decision the daemon must
1118
+ # remember -- a restart costs the queue its order for one pass (every
1119
+ # waiter is re-stamped on its next deferral) and nothing else, which
1120
+ # does not justify a ledger table on the poll path. Pruned every pass
1121
+ # against the live candidate set, so a merged or converged PR cannot
1122
+ # hold a place forever.
1123
+ self._waiting: dict[tuple[str, int], Waiting] = {}
1124
+ self._wait_seq = 0
1125
+ # Consecutive passes that deferred at least one round -- the summary
1126
+ # line's streak limiter, and ONLY that. It never escalates: a queue
1127
+ # that keeps draining is the gate working, and a wave permanently
1128
+ # larger than the limit would otherwise page forever on a daemon that
1129
+ # is reviewing everything, just serially.
1130
+ self._gate_streak = Streak()
1131
+ # Consecutive passes that deferred a round and spawned NOTHING -- the
1132
+ # escalating one (PR #71 round-1 [major]). Cleared by any spawn,
1133
+ # because a spawn is the proof the queue is moving; once it outlasts
1134
+ # POLL_ESCALATE_SECONDS the summary switches to DEFERRAL_STALLED at
1135
+ # WARNING. Separate from the limiter above so the predicate that pages
1136
+ # is "the gate is stuck", not "the gate is busy".
1137
+ self._gate_stall = Streak()
969
1138
  # Consecutive passes refused by the ledger gate in poll_once, and when
970
1139
  # the refusal began -- the same streak-limit-then-escalate shape the
971
1140
  # poll firewall uses, for the same reason: a read-only volume refuses
@@ -994,8 +1163,8 @@ class ReviewWatcher:
994
1163
  self._pass_tasks = self.alissa.list_tasks()
995
1164
  return self._pass_tasks
996
1165
 
997
- def _review_task(self, pr: PullRequest) -> tuple["Task | None", int]:
998
- """This PR's open CR2 review task and its verdict count, cheaply.
1166
+ def _review_task(self, pr: PullRequest) -> "ResolvedTask":
1167
+ """This PR's open CR2 review task and what its evidence says, cheaply.
999
1168
 
1000
1169
  The decide path needs the review task on every pass of every open
1001
1170
  round, and the only way to FIND one is to search the actor's whole
@@ -1019,13 +1188,31 @@ class ReviewWatcher:
1019
1188
  Fail-open is the whole contract: every degradation here lands on "do
1020
1189
  what the daemon did before the cache existed", and none of them can
1021
1190
  answer "no review task" unless a successful search actually said so.
1191
+
1192
+ TWO consequences of resolving from cache, both accepted rather than
1193
+ overlooked (PR #68 round 1):
1194
+
1195
+ * A cached hit does NOT re-check CR2 uniqueness. The duplicate-task
1196
+ alarm lives in `find_review_task`, which only runs on a miss, so a
1197
+ second open review task created for this PR AFTER the mapping was
1198
+ cached goes unreported and the daemon keeps counting envelopes on the
1199
+ one it pinned. Detecting it needs the corpus, and not fetching the
1200
+ corpus is the point -- relocating the check is TASK-317167904.
1201
+ * VALIDATING the review task drops the mapping, because
1202
+ `is_review_task_for` requires an open task and the search cannot find
1203
+ a terminal one either, so `evaluate` falls back to the GitHub review
1204
+ count -- the fallback `_completed_rounds` deliberately avoids. That is
1205
+ pre-existing and unchanged, but this path now HAS a durable ref that
1206
+ could survive validation; it is not used because a terminal task can
1207
+ never become open again, so a retained ref would have no disproof and
1208
+ its row would be immortal. Settling that is TASK-1897198077.
1022
1209
  """
1023
1210
  cached = self.state.review_task(pr.full_name, pr.number)
1024
1211
  if cached is not None:
1025
1212
  detail = self.alissa.get_task(cached)
1026
1213
  if detail is not None:
1027
1214
  if is_review_task_for(pr.owner, pr.repo, pr.number, detail.task):
1028
- return detail.task, detail.verdicts
1215
+ return ResolvedTask.from_detail(detail)
1029
1216
  log.info(
1030
1217
  "%s: cached review task %s no longer matches (title=%r "
1031
1218
  "status=%s) — re-resolving",
@@ -1048,10 +1235,24 @@ class ReviewWatcher:
1048
1235
  # no open review task for this PR, whatever the cache said.
1049
1236
  if cached is not None:
1050
1237
  self.state.forget_review_task(pr.full_name, pr.number)
1051
- return None, 0
1238
+ return ResolvedTask(task=None)
1052
1239
 
1053
1240
  self.state.record_review_task(pr.full_name, pr.number, task.ref)
1054
- return task, self.alissa.count_verdicts(task.ref)
1241
+ # The search matched a TITLE out of the corpus; the round count and the
1242
+ # verdict both live in the task's evidence, and ONE read returns both.
1243
+ # Reading it here rather than paying `count_verdicts` now and
1244
+ # `latest_verdict` again from convergence is the same saving the cached
1245
+ # path makes -- and it matters most in the degraded state this whole
1246
+ # change serves: an unreadable ledger sends EVERY PR down this path on
1247
+ # EVERY pass (PR #69 round 1).
1248
+ detail = self.alissa.get_task(task.ref)
1249
+ if detail is not None:
1250
+ return ResolvedTask.from_detail(detail)
1251
+ # That read failed, so the verdict is genuinely unknown -- left UNREAD
1252
+ # rather than defaulted to None, which convergence would take as "no
1253
+ # approve" and lose a closed round. Falls back to exactly the two-read
1254
+ # behaviour this path had before.
1255
+ return ResolvedTask(task=task, verdicts=self.alissa.count_verdicts(task.ref))
1055
1256
 
1056
1257
  # -- per-PR decision ---------------------------------------------------
1057
1258
 
@@ -1082,9 +1283,10 @@ class ReviewWatcher:
1082
1283
  # Fall back to the substantive-review count only before the review task
1083
1284
  # exists (round 1). Looked up here (not in _spawn) because both the count
1084
1285
  # and convergence need it.
1085
- task, verdicts = self._review_task(pr)
1286
+ resolved = self._review_task(pr)
1287
+ task = resolved.task
1086
1288
  native = countable_rounds(my_reviews)
1087
- completed = verdicts if task is not None else native
1289
+ completed = resolved.verdicts if task is not None else native
1088
1290
 
1089
1291
  # A round is not over until its verdict exists as a native review by
1090
1292
  # the reviewer identity (issue #51). An envelope ahead of the native
@@ -1114,7 +1316,7 @@ class ReviewWatcher:
1114
1316
  # anything else the round is still open and nothing may follow it.
1115
1317
  return self._close_round_natively(pr, task, round_=completed)
1116
1318
 
1117
- converged = self._convergence_reason(my_reviews, task, pr.head_sha)
1319
+ converged = self._convergence_reason(my_reviews, resolved, pr.head_sha)
1118
1320
  if converged is not None:
1119
1321
  # THE terminal branch: a verdict of record exists at the current
1120
1322
  # head, so no round k+1 can be owed from here -- every path that
@@ -1156,6 +1358,22 @@ class ReviewWatcher:
1156
1358
  deferred = self._defer_stale_round(pr, round_, age, cap)
1157
1359
  if deferred is not None:
1158
1360
  return deferred
1361
+
1362
+ # THE SPAWN GATE, and it sits here -- past every branch that decides
1363
+ # WHETHER a round is owed, immediately before the one that acts.
1364
+ # Upstream of it the loop is only reading; downstream it starts an
1365
+ # interactive agent. So a gated round has passed convergence, the cap
1366
+ # and CR9 re-entry identically to one that spawns: re-entry-granted
1367
+ # rounds queue through the gate like any other, delayed and never
1368
+ # denied. A stale-round respawn is gated too -- it is a spawn, and the
1369
+ # dead session it replaces is exactly as absent next poll.
1370
+ held = self._gate_spawn(pr, round_)
1371
+ if held is not None:
1372
+ return held
1373
+
1374
+ if age is not None:
1375
+ # Logged only once the gate has let the respawn through, so the
1376
+ # line cannot claim a re-enqueue that back-pressure then deferred.
1159
1377
  log.warning(
1160
1378
  "%s round %d has been in flight %.0f min with no submitted review "
1161
1379
  "and its session is gone or finished — re-enqueuing (reviewer "
@@ -1167,6 +1385,206 @@ class ReviewWatcher:
1167
1385
 
1168
1386
  return self._spawn(pr, round_, task, cap, reenqueued=age is not None)
1169
1387
 
1388
+ # -- the spawn gate ----------------------------------------------------
1389
+
1390
+ def _gate_spawn(self, pr: PullRequest, round_: int) -> Decision | None:
1391
+ """Hold an owed round back when the container is already full.
1392
+
1393
+ Returns a QUEUED Decision when the round must wait, or None to spawn.
1394
+
1395
+ DEFERRAL IS NOT FAILURE, and every part of that is load-bearing:
1396
+
1397
+ * it writes NO spawn-ledger row, so the round number is untouched (the
1398
+ next poll computes the same `completed + 1`) and no attempt is spent;
1399
+ * with no row there is no `spawn_age`, so the stale-round probe and its
1400
+ respawn branch cannot see the round at all -- a round can sit gated
1401
+ for hours without ever being "in flight 90 minutes";
1402
+ * it posts nothing: no PR comment, no operator page, no escalation.
1403
+ The only trace is one summary line per pass (see _note_deferrals).
1404
+
1405
+ The count is of sessions matching THIS package's grammar and no other
1406
+ (`Alissa.list_review_sessions` filters on `parse_session_name`), which
1407
+ is the same ownership boundary the reaper draws: other lanes share the
1408
+ container and are invisible here in both directions. Hand-spawned
1409
+ `review-pr-<n>` sessions are ours by that grammar and DO count -- they
1410
+ consume the same CPU, and a gate that ignored them would let a
1411
+ hand-driven round and a daemon round each think it had the last slot.
1412
+
1413
+ Sessions this pass has already enqueued count too (`_spawn` increments
1414
+ the census): the census is read once per pass, so without that a single
1415
+ pass would hand the same free slot to every PR in the wave.
1416
+ """
1417
+ limit = self.config.max_concurrent_sessions
1418
+ live = self._live_session_count()
1419
+ key = (pr.full_name, pr.number)
1420
+ if live is None or live < limit:
1421
+ # Spawning: this round is no longer waiting on anything. Dropped
1422
+ # here rather than in `_spawn`, which can still bail on a missing
1423
+ # hub or review task -- a round that never reaches the enqueue is
1424
+ # not holding a queue place either.
1425
+ self._waiting.pop(key, None)
1426
+ return None
1427
+
1428
+ wait = self._waiting.get(key)
1429
+ if wait is None:
1430
+ self._wait_seq += 1
1431
+ wait = Waiting(seq=self._wait_seq, since=time.monotonic())
1432
+ self._waiting[key] = wait
1433
+ return Decision(
1434
+ Action.QUEUED,
1435
+ f"round {round_} deferred — {live}/{limit} reviewer sessions live "
1436
+ f"(waiting {int(time.monotonic() - wait.since)}s)",
1437
+ round_,
1438
+ )
1439
+
1440
+ def _live_session_count(self) -> int | None:
1441
+ """Own-grammar reviewer sessions live this pass, or None if unknown.
1442
+
1443
+ Seeded by the reap sweep, which lists them anyway, so the gate costs no
1444
+ extra `alissa tmux ls` in the daemon. A caller that reaches the gate
1445
+ without a sweep (a direct `evaluate`, or a pass whose sweep bailed
1446
+ early) probes once and memoizes for the pass.
1447
+
1448
+ None -- the list could not be read -- FAILS OPEN: the spawn proceeds.
1449
+ The alternative was tried on paper and rejected: `alissa tmux ls` is
1450
+ also the reaper's only input, so a CLI that cannot answer it means
1451
+ nothing is being reaped either, and refusing every spawn would turn one
1452
+ broken subprocess into a fleet-wide review outage while the container
1453
+ sat idle. Failing open restores exactly the pre-gate behaviour for as
1454
+ long as the outage lasts, and it is loud (once per pass) about doing
1455
+ so.
1456
+ """
1457
+ if not self._census_probed:
1458
+ self._census_probed = True
1459
+ try:
1460
+ self._session_census = len(self.alissa.list_review_sessions())
1461
+ except CommandError as exc:
1462
+ self._session_census = None
1463
+ log.warning(
1464
+ "spawn gate: could not count live reviewer sessions (%s) — "
1465
+ "allowing spawns this pass rather than stalling the loop on "
1466
+ "a session list the reaper cannot read either",
1467
+ exc,
1468
+ )
1469
+ self._census_warned = True
1470
+ if self._session_census is None and not self._census_warned:
1471
+ self._census_warned = True
1472
+ log.warning(
1473
+ "spawn gate: the live reviewer-session count is unknown this "
1474
+ "pass — allowing spawns (the gate is back-pressure, not a "
1475
+ "safety interlock)"
1476
+ )
1477
+ return self._session_census
1478
+
1479
+ def _gate_order(
1480
+ self, requests: list[tuple[str, str, int]]
1481
+ ) -> list[tuple[str, str, int]]:
1482
+ """This pass's candidates, oldest waiter first.
1483
+
1484
+ FIFO fairness, and it needs its own order because the walk order it
1485
+ replaces is NOT fair: `review_requests` is a `search/issues` query, and
1486
+ that API sorts by best match by default -- a relevance ranking with no
1487
+ relation to how long a round has been waiting, and not even stable
1488
+ between calls. Under a gate that hands out one slot per freed session,
1489
+ an unstable order starves whichever PR keeps losing the coin toss.
1490
+
1491
+ So: everything that has already been deferred goes first, in the order
1492
+ it was FIRST deferred (`Waiting.seq`), then everything else in the
1493
+ search's own order. A round that has waited two passes therefore takes
1494
+ the next free slot ahead of one that has waited none -- which is the
1495
+ whole anti-starvation guarantee -- and a pass with no deferrals is
1496
+ byte-for-byte the old walk.
1497
+ """
1498
+ if not self._waiting:
1499
+ return requests
1500
+ return sorted(
1501
+ requests,
1502
+ key=lambda item: (
1503
+ self._waiting[(f"{item[0]}/{item[1]}", item[2])].seq
1504
+ if (f"{item[0]}/{item[1]}", item[2]) in self._waiting
1505
+ else self._wait_seq + 1
1506
+ ),
1507
+ )
1508
+
1509
+ def _note_deferrals(self, results: list[tuple[str, Decision]]) -> None:
1510
+ """One streak-limited line per pass summarizing the gate.
1511
+
1512
+ INFO while the queue MOVES -- a full container that keeps handing out
1513
+ freed slots is the gate working, and paging on it would make normal
1514
+ load indistinguishable from a leak. It escalates to WARNING on a
1515
+ different predicate: passes that deferred and spawned NOTHING,
1516
+ consecutively, for longer than POLL_ESCALATE_SECONDS. That is not
1517
+ back-pressure, it is a review outage the reap alarm can miss entirely
1518
+ (see DEFERRAL_STALLED), and this module's own rule for it is stated at
1519
+ _note_ledger_unwritable: a daemon that is up, polling, and deciding
1520
+ nothing is the most misleading state it can be in.
1521
+
1522
+ The two are separate streaks on purpose. One spawn proves the queue is
1523
+ moving and clears the stall, but it does NOT end the deferral episode
1524
+ the log limiter is rationing -- collapsing them would either page on a
1525
+ draining wave or reset the limiter every pass and log one line per
1526
+ poll, which is the spam the summary exists to avoid.
1527
+ """
1528
+ now = time.monotonic()
1529
+ held = [(slug, d) for slug, d in results if d.action is Action.QUEUED]
1530
+ if not held:
1531
+ self._gate_stall.clear()
1532
+ ended = self._gate_streak.resolve(now)
1533
+ if ended is not None:
1534
+ passes, seconds = ended
1535
+ log.info(DEFERRAL_CLEARED, passes, seconds / 60)
1536
+ return
1537
+
1538
+ should_log, _ = self._gate_streak.record(now)
1539
+ if any(d.action is Action.SPAWNED for _, d in results):
1540
+ # The queue is draining: rounds waited, but a slot freed and the
1541
+ # oldest waiter took it. Whatever the backlog, this is not a stall.
1542
+ # A stall that had ESCALATED says so out loud on its way out, past
1543
+ # the streak limit -- otherwise the last word an operator has on
1544
+ # the gate is a WARNING the daemon has already recovered from.
1545
+ escalated = self._gate_stall.escalated
1546
+ ended = self._gate_stall.resolve(now)
1547
+ if escalated and ended is not None:
1548
+ passes, seconds = ended
1549
+ log.info(GATE_STALL_CLEARED, passes, seconds / 60, len(held))
1550
+ else:
1551
+ _, crossing = self._gate_stall.record(now)
1552
+ # The crossing BYPASSES the streak limit, on the same reasoning as
1553
+ # the ledger gate's: it is a state change, and suppressing it would
1554
+ # hide the one transition the line exists to show.
1555
+ should_log = should_log or crossing
1556
+ if not should_log:
1557
+ return
1558
+ # A round is only ever deferred against a census the gate could read,
1559
+ # so this is never the fallback -- an unreadable count fails open and
1560
+ # produces no deferrals at all.
1561
+ live = self._session_census or 0
1562
+ waiting = "Waiting, oldest first: " + ", ".join(
1563
+ slug for slug, _ in sorted(
1564
+ held,
1565
+ key=lambda item: self._waiting.get(
1566
+ _slug_key(item[0]), Waiting(seq=0, since=0.0)
1567
+ ).seq,
1568
+ )
1569
+ ) + "."
1570
+ if self._gate_stall.escalated:
1571
+ log.warning(
1572
+ DEFERRAL_STALLED,
1573
+ len(held),
1574
+ live,
1575
+ self.config.max_concurrent_sessions,
1576
+ self._gate_stall.held(now) / 60,
1577
+ waiting,
1578
+ )
1579
+ return
1580
+ log.info(
1581
+ DEFERRAL_SUMMARY,
1582
+ len(held),
1583
+ live,
1584
+ self.config.max_concurrent_sessions,
1585
+ waiting,
1586
+ )
1587
+
1170
1588
  # -- native verdict post -----------------------------------------------
1171
1589
 
1172
1590
  def _close_round_natively(
@@ -1972,7 +2390,7 @@ class ReviewWatcher:
1972
2390
  )
1973
2391
 
1974
2392
  def _convergence_reason(
1975
- self, my_reviews: list[Review], task: Task | None, head_sha: str
2393
+ self, my_reviews: list[Review], resolved: "ResolvedTask", head_sha: str
1976
2394
  ) -> str | None:
1977
2395
  """Why the loop is done, or None if it is not.
1978
2396
 
@@ -2033,9 +2451,14 @@ class ReviewWatcher:
2033
2451
  return None
2034
2452
 
2035
2453
  # Only checkable once a review task exists; before that there is
2036
- # nowhere for a verdict to have been recorded.
2037
- if task is not None and self.alissa.latest_verdict(task.ref) == VERDICT_APPROVE:
2038
- return f"newest verdict envelope on {task.ref} reads approve"
2454
+ # nowhere for a verdict to have been recorded. On the cached path the
2455
+ # envelope was already read back with the task, so this costs nothing;
2456
+ # on the search path it is the one fetch that still has to happen.
2457
+ if (
2458
+ resolved.task is not None
2459
+ and resolved.newest_verdict(self.alissa) == VERDICT_APPROVE
2460
+ ):
2461
+ return f"newest verdict envelope on {resolved.task.ref} reads approve"
2039
2462
 
2040
2463
  return None
2041
2464
 
@@ -2313,6 +2736,10 @@ class ReviewWatcher:
2313
2736
  sessions = self.alissa.list_review_sessions()
2314
2737
  except CommandError as exc:
2315
2738
  log.warning("reap sweep skipped: could not list sessions: %s", exc)
2739
+ # Probed and unanswerable. The spawn gate reads the same list, so
2740
+ # it is told the count is unknown rather than left to pay for a
2741
+ # second failing subprocess per candidate PR.
2742
+ self._census_probed, self._session_census = True, None
2316
2743
  return 0
2317
2744
 
2318
2745
  # Drop cached resolutions for sessions that are gone. Names are unique
@@ -2333,6 +2760,13 @@ class ReviewWatcher:
2333
2760
  completed_cache: dict[tuple[str, int, str | None], float | None] = {}
2334
2761
  holdouts: dict[str, str] = {}
2335
2762
  reaped: list[str] = []
2763
+ # Dry-run only: what a production sweep would have killed here. The
2764
+ # spawn gate's census has to be seeded with production's slot count or
2765
+ # `--dry-run` reports rounds QUEUED that production would spawn -- the
2766
+ # opposite error to the one _spawn's self-increment fixes, in the one
2767
+ # tool an operator reaches for during exactly this incident class (PR
2768
+ # #71 round-1 [minor]). Empty in production, where `reaped` carries it.
2769
+ would_reap: list[str] = []
2336
2770
 
2337
2771
  for ses in sessions:
2338
2772
  idle_for = time.time() - ses.last_activity
@@ -2380,6 +2814,7 @@ class ReviewWatcher:
2380
2814
  if self.config.dry_run:
2381
2815
  log.info("[dry-run] would reap reviewer session %s (%s)", ses.name, evidence)
2382
2816
  holdouts[ses.name] = f"dry-run: would have reaped ({evidence})"
2817
+ would_reap.append(ses.name)
2383
2818
  continue
2384
2819
  try:
2385
2820
  self.alissa.kill_session(ses.name)
@@ -2394,7 +2829,7 @@ class ReviewWatcher:
2394
2829
  reaped.append(ses.name)
2395
2830
  log.info("reaped reviewer session %s (%s)", ses.name, evidence)
2396
2831
 
2397
- self._check_session_cap(sessions, reaped, holdouts)
2832
+ self._check_session_cap(sessions, reaped, holdouts, would_reap)
2398
2833
  return len(reaped)
2399
2834
 
2400
2835
  def _hold(self, holdouts: dict[str, str], ses: ManagedSession, why: str) -> None:
@@ -2557,6 +2992,7 @@ class ReviewWatcher:
2557
2992
  sessions: list[ManagedSession],
2558
2993
  reaped: list[str],
2559
2994
  holdouts: dict[str, str],
2995
+ would_reap: list[str] | None = None,
2560
2996
  ) -> None:
2561
2997
  """Page-worthy log when the sweep is not keeping up.
2562
2998
 
@@ -2584,6 +3020,23 @@ class ReviewWatcher:
2584
3020
  """
2585
3021
  killed = set(reaped)
2586
3022
  remaining = sorted(s.name for s in sessions if s.name not in killed)
3023
+ # The spawn gate's census for this pass, taken from the same post-sweep
3024
+ # list the alarm counts: what the sweep could not free is exactly what
3025
+ # is still holding CPU when the gate decides. Seeded here so the gate
3026
+ # never runs its own `alissa tmux ls`.
3027
+ #
3028
+ # `would_reap` is the dry-run correction and nothing else: a dry-run
3029
+ # sweep kills nothing, so without it the census counts slots production
3030
+ # would have freed and the diagnostic reports rounds gated that
3031
+ # production spawns. Deliberately NOT subtracted from `remaining`
3032
+ # below -- the alarm's own count is documented as "sessions this pass
3033
+ # could not free", and a dry-run pass genuinely freed none of them
3034
+ # (each carries a `dry-run: would have reaped` holdout reason that says
3035
+ # so). That reading predates the gate; the census is the part the gate
3036
+ # owns.
3037
+ pretend = set(would_reap or ())
3038
+ self._census_probed = True
3039
+ self._session_census = sum(1 for name in remaining if name not in pretend)
2587
3040
  if len(remaining) <= self.config.reap_session_cap:
2588
3041
  self._paged_cap = None
2589
3042
  return
@@ -2715,6 +3168,16 @@ class ReviewWatcher:
2715
3168
  dry_run=self.config.dry_run,
2716
3169
  )
2717
3170
 
3171
+ # This pass's own spawns count against the gate immediately (issue
3172
+ # #70). The census is read once per pass and a just-enqueued session
3173
+ # takes a moment to appear in `alissa tmux ls`, so without this a
3174
+ # merge wave would hand one free slot to every PR in it -- the
3175
+ # unbounded burst the gate exists to prevent, reintroduced by a
3176
+ # caching detail. Incremented in dry-run too: the diagnostic's job is
3177
+ # to report the decisions production would take.
3178
+ if self._session_census is not None:
3179
+ self._session_census += 1
3180
+
2718
3181
  if not self.config.dry_run:
2719
3182
  self.state.record_spawn(
2720
3183
  repo=pr.full_name,
@@ -3015,6 +3478,14 @@ class ReviewWatcher:
3015
3478
  # to this one's title searches as if it were current, and it is the one
3016
3479
  # piece of per-pass state whose staleness would be invisible.
3017
3480
  self._pass_tasks = None
3481
+ # ...and so does the spawn gate's session census: a count carried over
3482
+ # from the previous pass would gate this one on sessions that have
3483
+ # since been reaped (or miss ones that have since spawned). Cleared
3484
+ # with `_pass_tasks` and for the same reason. The `_waiting` queue is
3485
+ # deliberately NOT cleared -- it is what remembers who has waited
3486
+ # longest ACROSS passes.
3487
+ self._session_census = None
3488
+ self._census_probed = self._census_warned = False
3018
3489
 
3019
3490
  # THE LEDGER GATE (issue #62, PR #63 round-1 blocker). Nothing below
3020
3491
  # may run when the ledger cannot record what it does.
@@ -3043,9 +3514,18 @@ class ReviewWatcher:
3043
3514
  # DRY-RUN IS EXEMPT, and vacuously so: it already suppresses every side
3044
3515
  # effect AND every correctness write (`_spawn` skips record_spawn, the
3045
3516
  # reaper logs instead of killing, the drift/cap-out/deferral paths
3046
- # return before both their comment and their record). Its only ledger
3047
- # write is the snapshot, which this module classifies as best-effort
3048
- # telemetry and which _write_snapshot writes in dry-run deliberately.
3517
+ # return before both their comment and their record). The ledger writes
3518
+ # it may still take are the snapshot and the review-task cache
3519
+ # (`_review_task` runs in dry-run and both records and forgets
3520
+ # mappings) -- both classified by this module as best-effort telemetry,
3521
+ # both absorbed by _write_telemetry, and neither a decision the daemon
3522
+ # has to remember. Writing the cache in dry-run is deliberate: a
3523
+ # dry-run pass that learns a mapping hands it to the next production
3524
+ # pass, and suppressing it would make the two disagree about ledger
3525
+ # contents for no correctness reason. The cost on a read-only volume is
3526
+ # a reconnect attempt on the first failure of the streak plus
3527
+ # streak-limited warnings, which is the same best-effort behaviour the
3528
+ # snapshot has always had there.
3049
3529
  # So the gate would protect nothing there and cost the operator the one
3050
3530
  # tool that answers "what would you do right now" -- asked, precisely,
3051
3531
  # during the substrate incident this whole change is about.
@@ -3064,8 +3544,17 @@ class ReviewWatcher:
3064
3544
  requests = self.github.review_requests(self.config.repos)
3065
3545
  log.info("%d PR(s) with a review pending from %s", len(requests), self.github.login)
3066
3546
 
3547
+ # Forget queue places belonging to PRs this pass cannot act on at all
3548
+ # (merged, converged, withdrawn): they are gone from the search, so
3549
+ # nothing would ever clear them and the oldest-first order would be
3550
+ # anchored to a PR that is never coming back.
3551
+ live_keys = {(f"{owner}/{repo}", number) for owner, repo, number in requests}
3552
+ self._waiting = {
3553
+ key: wait for key, wait in self._waiting.items() if key in live_keys
3554
+ }
3555
+
3067
3556
  results = []
3068
- for owner, repo, number in requests:
3557
+ for owner, repo, number in self._gate_order(requests):
3069
3558
  slug = f"{owner}/{repo}#{number}"
3070
3559
  if not self.config.watches(f"{owner}/{repo}"):
3071
3560
  continue
@@ -3081,6 +3570,8 @@ class ReviewWatcher:
3081
3570
  log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
3082
3571
  results.append((slug, decision))
3083
3572
 
3573
+ self._note_deferrals(results)
3574
+
3084
3575
  # Persist one poll_snapshots row per pass, built entirely from the
3085
3576
  # Decision list already in hand plus the reap count -- no new GitHub
3086
3577
  # calls. Written in dry-run too: a snapshot OBSERVES the pass, it is
@@ -3191,6 +3682,7 @@ class ReviewWatcher:
3191
3682
  if d.action is Action.IN_FLIGHT and d.deferred
3192
3683
  )
3193
3684
  self.state.record_snapshot(
3685
+ queued=counts[Action.QUEUED],
3194
3686
  duration_ms=duration_ms,
3195
3687
  candidates=len(results),
3196
3688
  spawned=spawned,
@@ -157,6 +157,12 @@ CREATE TABLE IF NOT EXISTS verdict_posts (
157
157
  -- losing the whole table only costs one title search per PR (see
158
158
  -- loop._review_task). That is why this is best-effort like the snapshots
159
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.
160
166
  CREATE TABLE IF NOT EXISTS review_tasks (
161
167
  repo TEXT NOT NULL,
162
168
  number INTEGER NOT NULL,
@@ -182,6 +188,12 @@ CREATE TABLE IF NOT EXISTS poll_snapshots (
182
188
  posted INTEGER NOT NULL DEFAULT 0,
183
189
  awaiting_post INTEGER NOT NULL DEFAULT 0,
184
190
  abandoned INTEGER NOT NULL DEFAULT 0,
191
+ -- Rounds the spawn gate held back this pass because the container was
192
+ -- already at max_concurrent_sessions (issue #70). Its own column rather
193
+ -- than a share of `deferred`: that one counts rounds whose LIVE session is
194
+ -- still working, and the console reads the two together as active
195
+ -- sessions -- a gated round has no session at all.
196
+ queued INTEGER NOT NULL DEFAULT 0,
185
197
  stages_json TEXT NOT NULL
186
198
  );
187
199
  """
@@ -198,6 +210,7 @@ _ADDED_COLUMNS = {
198
210
  ("posted", "INTEGER NOT NULL DEFAULT 0"),
199
211
  ("awaiting_post", "INTEGER NOT NULL DEFAULT 0"),
200
212
  ("abandoned", "INTEGER NOT NULL DEFAULT 0"),
213
+ ("queued", "INTEGER NOT NULL DEFAULT 0"),
201
214
  ),
202
215
  "verdict_posts": (
203
216
  ("checks_held_at", "INTEGER"),
@@ -905,8 +918,10 @@ class State:
905
918
  """Remember which review task a PR resolved to. Best-effort.
906
919
 
907
920
  REPLACE, not IGNORE: re-resolving is how a mapping is corrected, so the
908
- newest answer has to win. `resolved_at` is when the mapping was last
909
- confirmed by a search, which is the audit question worth answering.
921
+ newest answer has to win. `resolved_at` records when the mapping was
922
+ last confirmed by a search; it is kept for inspection (the age of a
923
+ mapping is the first thing worth knowing if one is ever suspected) and
924
+ no query, log line or console view reads it.
910
925
 
911
926
  A database error here is absorbed exactly like a snapshot's: the cache
912
927
  is an optimization, and a pass that cannot persist it still decides the
@@ -962,6 +977,7 @@ class State:
962
977
  posted: int = 0,
963
978
  awaiting_post: int = 0,
964
979
  abandoned: int = 0,
980
+ queued: int = 0,
965
981
  stages: list[dict],
966
982
  ) -> bool:
967
983
  """Append one poll-pass observation, then prune to the newest
@@ -996,6 +1012,7 @@ class State:
996
1012
  posted=posted,
997
1013
  awaiting_post=awaiting_post,
998
1014
  abandoned=abandoned,
1015
+ queued=queued,
999
1016
  stages=stages,
1000
1017
  ),
1001
1018
  "poll snapshot",
@@ -1018,6 +1035,7 @@ class State:
1018
1035
  posted: int,
1019
1036
  awaiting_post: int,
1020
1037
  abandoned: int,
1038
+ queued: int,
1021
1039
  stages: list[dict],
1022
1040
  ) -> None:
1023
1041
  """The snapshot INSERT + prune itself, strict. Split out so the
@@ -1026,8 +1044,8 @@ class State:
1026
1044
  "INSERT INTO poll_snapshots "
1027
1045
  "(ts, duration_ms, candidates, spawned, stale_reenqueued, "
1028
1046
  "in_flight, deferred, converged, capped, escalated, skipped, "
1029
- "reaped, posted, awaiting_post, abandoned, stages_json) "
1030
- "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1047
+ "reaped, posted, awaiting_post, abandoned, queued, stages_json) "
1048
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
1031
1049
  (
1032
1050
  int(time.time()),
1033
1051
  duration_ms,
@@ -1044,6 +1062,7 @@ class State:
1044
1062
  posted,
1045
1063
  awaiting_post,
1046
1064
  abandoned,
1065
+ queued,
1047
1066
  json.dumps(stages, separators=(",", ":")),
1048
1067
  ),
1049
1068
  )
@@ -204,7 +204,7 @@ section.panel {
204
204
  border-color: var(--status-in-progress); background: var(--status-in-progress-bg); }
205
205
  .pill.in-flight { color: var(--status-committed);
206
206
  border-color: var(--status-committed); background: var(--status-committed-bg); }
207
- .pill.deferred { color: var(--status-pending);
207
+ .pill.deferred, .pill.queued { color: var(--status-pending);
208
208
  border-color: var(--status-pending); background: var(--status-pending-bg); }
209
209
  .pill.converged, .pill.posted { color: var(--status-in-progress);
210
210
  border-color: var(--status-in-progress); background: var(--status-in-progress-bg); }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.16.11
3
+ Version: 0.16.13
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera