alissa-tools-github-revloop 0.16.1__tar.gz → 0.16.2__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.1/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.2}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/__main__.py +16 -0
  3. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/alissa.py +98 -5
  4. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/config.py +69 -0
  5. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/loop.py +376 -74
  6. alissa_tools_github_revloop-0.16.2/src/main/alissa/tools/github/revloop/version +1 -0
  7. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  8. alissa_tools_github_revloop-0.16.1/src/main/alissa/tools/github/revloop/version +0 -1
  9. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/LICENSE +0 -0
  10. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/MANIFEST.in +0 -0
  11. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/NOTICE +0 -0
  12. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/README.md +0 -0
  13. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/requirements.txt +0 -0
  14. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/setup.cfg +0 -0
  15. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/setup.py +0 -0
  16. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  17. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  18. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  19. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  20. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/state.py +0 -0
  21. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/version.py +0 -0
  22. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  23. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  24. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  25. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  26. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  27. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  28. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  29. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  30. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  31. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  32. {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/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.1
3
+ Version: 0.16.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -100,6 +100,20 @@ def build_parser() -> argparse.ArgumentParser:
100
100
  choices=[ON_MISSING_SPAWN, ON_MISSING_CREATE, ON_MISSING_SKIP],
101
101
  )
102
102
  over.add_argument("--on-missing-hub", choices=[HUB_SKIP, HUB_ADD])
103
+ over.add_argument(
104
+ "--reap-grace-seconds",
105
+ type=int,
106
+ metavar="SECONDS",
107
+ help="how long a reviewer session must be idle AND quiet before the "
108
+ "sweep reaps it (and before a stale round reads it as dead)",
109
+ )
110
+ over.add_argument(
111
+ "--reap-session-cap",
112
+ type=int,
113
+ metavar="N",
114
+ help="page-worthy threshold: more live reviewer sessions than this "
115
+ "after a sweep and the daemon logs loudly",
116
+ )
103
117
 
104
118
  dry = over.add_mutually_exclusive_group()
105
119
  dry.add_argument(
@@ -132,6 +146,8 @@ def overrides_from(args: argparse.Namespace) -> dict:
132
146
  "state_path": args.state_path,
133
147
  "on_missing_review_task": args.on_missing_review_task,
134
148
  "on_missing_hub": args.on_missing_hub,
149
+ "reap_grace_seconds": args.reap_grace_seconds,
150
+ "reap_session_cap": args.reap_session_cap,
135
151
  "dry_run": args.dry_run,
136
152
  }
137
153
 
@@ -44,10 +44,87 @@ class Task:
44
44
  return self.status in OPEN_STATUSES
45
45
 
46
46
 
47
- # The namespace loop.session_name() spawns reviewers into; the reap sweep only
48
- # ever considers sessions under it.
47
+ # The namespace loop.session_name() spawns reviewers into. Kept as the
48
+ # display-level answer to "is this session in the reviewer namespace?" (the
49
+ # console marks unmanaged sessions with it); the REAP path uses the stricter
50
+ # grammar below, because a prefix is not a strong enough claim of ownership to
51
+ # kill on.
49
52
  REVIEW_SESSION_PREFIX = "review-"
50
53
 
54
+ # The reviewer-session GRAMMAR. The sweep kills sessions and the worker
55
+ # container is shared with other lanes (`develop-*`, `fix-*`, `maintain-*`,
56
+ # ...), so only a name that parses as one of the two shapes the review loop
57
+ # itself produces is ever a reap candidate; anything else is another daemon's
58
+ # (or a human's) and is never touched. Two shapes, both ours:
59
+ #
60
+ # review-<repo>-pr<n>-r<k>-<nonce> this daemon's spawns (loop.session_name)
61
+ # review-pr-<n>[-r<k>] the alissa-code-review skill's own
62
+ # procedures (spawn-a-reviewer-session.md,
63
+ # run-the-review-loop.md) -- hand-driven
64
+ # rounds of the SAME loop. No spawn ledger
65
+ # knows about those and nothing reaped
66
+ # them: every session in the 2026-07-28
67
+ # memory incident was of this shape.
68
+ #
69
+ # The daemon shape is matched with a non-greedy repo so a hyphenated repo name
70
+ # (`alissa-github-review-daemon`) still lands in the repo group, and it is
71
+ # anchored on the `-pr<n>-r<k>-<nonce>` tail that loop.session_name always
72
+ # emits. The skill shape carries no repo and no nonce -- that is the whole
73
+ # reason a bare `review-pr-<n>` needs the repos allowlist to be resolvable at
74
+ # all (see loop.ReviewWatcher._resolve_pr).
75
+ _DAEMON_SESSION_RE = re.compile(
76
+ r"^review-(?P<repo>[a-z0-9-]+?)-pr(?P<number>\d+)-r(?P<round>\d+)-[0-9a-f]{4,16}$"
77
+ )
78
+ _SKILL_SESSION_RE = re.compile(
79
+ r"^review-pr-(?P<number>\d+)(?:-r(?P<round>\d+))?$"
80
+ )
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class SessionRef:
85
+ """What a reviewer session's NAME says about the round it is running.
86
+
87
+ `repo` is the sanitized repo slug (`session_repo_slug`), not an
88
+ `owner/repo`: session names never carry the owner. `repo` and `round` are
89
+ None for the skill shape, which encodes neither.
90
+ """
91
+
92
+ number: int
93
+ repo: "str | None" = None
94
+ round: "int | None" = None
95
+
96
+
97
+ def session_repo_slug(repo: str) -> str:
98
+ """The repo component of a session name: tmux-safe, lowercase, no dots.
99
+
100
+ Shared by the producer (loop.session_name) and the parser below so the two
101
+ can never drift -- a rename here that the matcher did not follow would make
102
+ the daemon stop recognizing its own sessions.
103
+ """
104
+ return re.sub(r"[^A-Za-z0-9-]", "-", repo).strip("-").lower()
105
+
106
+
107
+ def parse_session_name(name: "str | None") -> "SessionRef | None":
108
+ """Parse a reviewer session name, or None when it is not one of ours.
109
+
110
+ None is the security-relevant answer: it is what keeps the sweep off every
111
+ other lane's sessions in a shared container.
112
+ """
113
+ if not isinstance(name, str):
114
+ return None
115
+ for pattern in (_SKILL_SESSION_RE, _DAEMON_SESSION_RE):
116
+ match = pattern.match(name)
117
+ if match is None:
118
+ continue
119
+ parts = match.groupdict()
120
+ round_ = parts.get("round")
121
+ return SessionRef(
122
+ number=int(parts["number"]),
123
+ repo=parts.get("repo"),
124
+ round=int(round_) if round_ else None,
125
+ )
126
+ return None
127
+
51
128
 
52
129
  @dataclass(frozen=True)
53
130
  class ManagedSession:
@@ -62,6 +139,11 @@ class ManagedSession:
62
139
  def is_idle(self) -> bool:
63
140
  return self.status == "idle"
64
141
 
142
+ @property
143
+ def ref(self) -> "SessionRef | None":
144
+ """What the name says about the round -- see parse_session_name."""
145
+ return parse_session_name(self.name)
146
+
65
147
 
66
148
  def _title_pattern(owner: str, repo: str, number: int) -> re.Pattern[str]:
67
149
  """CR2 title convention: `Review PR <org>/<repo>#<n> (TASK-<origin>)`."""
@@ -257,7 +339,7 @@ class Alissa:
257
339
  log.warning("could not set respawn off for %s", session)
258
340
 
259
341
  def list_review_sessions(self) -> list[ManagedSession]:
260
- """The live `review-*` managed sessions, from `alissa tmux ls`.
342
+ """The live reviewer-grammar managed sessions, from `alissa tmux ls`.
261
343
 
262
344
  The reap sweep's starting point. Unlike the review-requested search,
263
345
  the live session list cannot lose a finished session, so every reap
@@ -265,6 +347,11 @@ class Alissa:
265
347
  already gone (self-killed, or killed by an operator) holds no worker
266
348
  slot and needs no reap. Raises CommandError upward -- the sweep skips
267
349
  this pass and tries again next poll.
350
+
351
+ The filter is `parse_session_name`, not the bare `review-` prefix: this
352
+ list is what the sweep kills from, the container is shared with other
353
+ daemons, and a name that does not parse as a reviewer session is never
354
+ enumerated here at all -- so no later bug in the sweep can reach one.
268
355
  """
269
356
  data = run_json(["alissa", "tmux", "ls", "--json", "--live"], timeout=60) or []
270
357
  sessions = []
@@ -272,7 +359,7 @@ class Alissa:
272
359
  if not isinstance(row, dict):
273
360
  continue
274
361
  name = row.get("name")
275
- if isinstance(name, str) and name.startswith(REVIEW_SESSION_PREFIX):
362
+ if isinstance(name, str) and parse_session_name(name) is not None:
276
363
  last = row.get("lastActivity")
277
364
  sessions.append(
278
365
  ManagedSession(
@@ -284,7 +371,13 @@ class Alissa:
284
371
  return sessions
285
372
 
286
373
  def kill_session(self, session: str) -> None:
287
- """Kill a finished reviewer's managed session to free its worker slot.
374
+ """Kill ONE finished reviewer's managed session to free its worker slot.
375
+
376
+ Per-session, always: `alissa tmux kill <name>` and never a server-wide
377
+ kill. The worker container is shared with every other lane, so a
378
+ `kill-server` here would take down unrelated daemons' sessions along
379
+ with the one that is actually finished. A test pins the absence of that
380
+ verb across the whole package.
288
381
 
289
382
  Best-effort and idempotent-friendly: the session may already be gone (the
290
383
  reviewer self-killed), so a non-zero exit is not an error here. Dry-run
@@ -47,11 +47,48 @@ CONFIG_KEYS = (
47
47
  "state_path",
48
48
  "on_missing_review_task",
49
49
  "on_missing_hub",
50
+ "reap_grace_seconds",
51
+ "reap_session_cap",
50
52
  "dry_run",
51
53
  )
52
54
 
53
55
  MIN_POLL_INTERVAL = 10 # the search API allows 30 req/min
54
56
 
57
+ # A reviewer session that has not submitted after this long is presumed dead
58
+ # (skill failure mode: "reviewer session stalls"). The round is re-enqueued --
59
+ # but only with a second signal agreeing: the timer alone cannot tell a dead
60
+ # session from a slow one, and a timer-only re-enqueue double-spends the round
61
+ # (two sessions review it, both submit -- observed live twice: double round-2
62
+ # approves on devloop's PR #11, double approves on this repo's PR #19). See
63
+ # loop._defer_stale_round for the liveness signal.
64
+ #
65
+ # It lives here rather than in `loop` (which re-exports it, so every import
66
+ # site is unchanged) because `reap_grace_seconds` is validated against it, and
67
+ # config cannot import loop without a cycle.
68
+ STALE_ROUND_SECONDS = 90 * 60
69
+
70
+ # How long a reviewer session must have been idle AND quiet (no tmux activity)
71
+ # before the sweep may reap it, and before the stale-round liveness probe
72
+ # reads it as finished rather than working.
73
+ #
74
+ # Both readings need the same number because they ask the same question -- "is
75
+ # this session done?" -- and a claude session parked at its prompt between
76
+ # turns reports "idle", so only the absence of activity can answer it. The
77
+ # default is generous on purpose: a just-merged PR's reviewer still has
78
+ # in-session close-out to do (CR6 envelope, task move, its own self-kill), and
79
+ # reaping under it loses that work for nothing. Configurable because the right
80
+ # value tracks how long a round's close-out takes on a given deployment.
81
+ DEFAULT_REAP_GRACE_SECONDS = 30 * 60
82
+
83
+ # Post-sweep alarm threshold: more live reviewer sessions than this and the
84
+ # sweep logs page-worthy, because the reaper is not keeping up with the spawn
85
+ # rate. Each idle agent session holds hundreds of MB forever, and the worker
86
+ # container is shared -- the 2026-07-28 incident was this exact drift, climbing
87
+ # past 10 GB with every review session idle and its PR long merged. A healthy
88
+ # loop runs a couple of concurrent rounds, so the default is a threshold no
89
+ # healthy deployment reaches, not a capacity limit.
90
+ DEFAULT_REAP_SESSION_CAP = 6
91
+
55
92
 
56
93
  def default_state_path(workspace_root: Path) -> Path:
57
94
  return Path(workspace_root) / ".revloop" / "state.db"
@@ -86,6 +123,12 @@ class Config:
86
123
 
87
124
  on_missing_review_task: str = ON_MISSING_SPAWN
88
125
  on_missing_hub: str = HUB_SKIP
126
+
127
+ # The reap sweep's two knobs -- see the constants above for what each one
128
+ # buys and why it is tunable rather than pinned.
129
+ reap_grace_seconds: int = DEFAULT_REAP_GRACE_SECONDS
130
+ reap_session_cap: int = DEFAULT_REAP_SESSION_CAP
131
+
89
132
  dry_run: bool = False
90
133
 
91
134
  def __post_init__(self) -> None:
@@ -180,6 +223,30 @@ class Config:
180
223
  f"poll_interval must be >= {MIN_POLL_INTERVAL} seconds, got {interval}"
181
224
  )
182
225
 
226
+ grace = int(raw.get("reap_grace_seconds", cls.reap_grace_seconds))
227
+ if grace < 0:
228
+ raise ValueError(f"reap_grace_seconds must be >= 0, got {grace}")
229
+ if grace >= STALE_ROUND_SECONDS:
230
+ # The same number answers the stale-round liveness probe, whose
231
+ # own window is STALE_ROUND_SECONDS. At or above it the
232
+ # "idle-finished -> dead -> respawn" branch is unreachable: a
233
+ # session can never have been quiet longer than the grace by the
234
+ # time its round goes stale, so every stale round defers forever
235
+ # and only the operator ping ever fires. Loud here rather than
236
+ # silently wedged there.
237
+ raise ValueError(
238
+ f"reap_grace_seconds must be well under the {STALE_ROUND_SECONDS}s "
239
+ f"stale-round window (it also gates the stale-round liveness "
240
+ f"probe, whose respawn branch it would make unreachable), "
241
+ f"got {grace}"
242
+ )
243
+
244
+ session_cap = int(raw.get("reap_session_cap", cls.reap_session_cap))
245
+ if session_cap < 1:
246
+ # 0 would page on every single live reviewer, which is the normal
247
+ # state of a working loop -- an alarm that always fires is noise.
248
+ raise ValueError(f"reap_session_cap must be >= 1, got {session_cap}")
249
+
183
250
  state_path = raw.get("state_path")
184
251
  return cls(
185
252
  workspace_root=Path(workspace_root),
@@ -193,6 +260,8 @@ class Config:
193
260
  state_path=Path(state_path).expanduser() if state_path else None,
194
261
  on_missing_review_task=mode,
195
262
  on_missing_hub=hub_mode,
263
+ reap_grace_seconds=grace,
264
+ reap_session_cap=session_cap,
196
265
  dry_run=bool(raw.get("dry_run", False)),
197
266
  )
198
267
 
@@ -20,23 +20,26 @@ from dataclasses import dataclass
20
20
  from enum import Enum
21
21
  from pathlib import Path
22
22
 
23
- from .alissa import VERDICT_APPROVE, Alissa, Task
24
- from .config import HUB_ADD, ON_MISSING_SKIP, Config
23
+ from .alissa import (
24
+ VERDICT_APPROVE,
25
+ Alissa,
26
+ ManagedSession,
27
+ SessionRef,
28
+ Task,
29
+ session_repo_slug,
30
+ )
31
+ from .config import (
32
+ HUB_ADD,
33
+ ON_MISSING_SKIP,
34
+ STALE_ROUND_SECONDS,
35
+ Config,
36
+ )
25
37
  from .ghclient import GitHub, IssueComment, PullRequest, RateLimited, Review
26
38
  from .proc import CommandError
27
39
  from .state import State
28
40
 
29
41
  log = logging.getLogger(__name__)
30
42
 
31
- # A reviewer session that has not submitted after this long is presumed dead
32
- # (skill failure mode: "reviewer session stalls"). The round is re-enqueued --
33
- # but only with a second signal agreeing: the timer alone cannot tell a dead
34
- # session from a slow one, and a timer-only re-enqueue double-spends the round
35
- # (two sessions review it, both submit -- observed live twice: double round-2
36
- # approves on devloop's PR #11, double approves on this repo's PR #19). See
37
- # _defer_stale_round for the liveness signal.
38
- STALE_ROUND_SECONDS = 90 * 60
39
-
40
43
  # The floor under the liveness deferral: a live session defers the stale
41
44
  # respawn indefinitely -- correct for a genuinely slow round, silent forever
42
45
  # for a session that is wedged but still registers tmux activity. Once the
@@ -48,13 +51,14 @@ STALE_ROUND_SECONDS = 90 * 60
48
51
  # wedged one surfaces the same day.
49
52
  STALLED_DEFER_MULTIPLE = 2
50
53
 
51
- # The sweep only reaps a session that has been idle AND quiet this long. The
52
- # GitHub review count increments the moment a review is submitted, but the
53
- # reviewer still has close-out work after that (CR6 envelope, task status) --
54
- # and a claude session parked at its prompt between turns reports "idle", so
55
- # idleness alone cannot distinguish "between turns" from "done". Recent tmux
56
- # activity can.
57
- REAP_QUIET_SECONDS = 5 * 60
54
+ # The sweep only reaps a session that has been idle AND quiet for
55
+ # `config.reap_grace_seconds`. The GitHub review count increments the moment a
56
+ # review is submitted, but the reviewer still has close-out work after that
57
+ # (CR6 envelope, task status) -- and a claude session parked at its prompt
58
+ # between turns reports "idle", so idleness alone cannot distinguish "between
59
+ # turns" from "done". Recent tmux activity can. The same number answers the
60
+ # stale-round liveness probe, which asks the same question; see
61
+ # config.DEFAULT_REAP_GRACE_SECONDS.
58
62
 
59
63
  # The closing contract is spelled out in both directives (not just the skill)
60
64
  # because it is the reviewer's most-skipped step: on re-review, sessions produce
@@ -395,9 +399,15 @@ def session_name(pr: PullRequest, round_: int) -> str:
395
399
  original 'stuck' failure). Safe to be non-deterministic: the generated name
396
400
  is recorded in the spawn ledger and is what gets reaped / self-killed, so the
397
401
  daemon never re-derives it.
402
+
403
+ The shape is a contract, not just a convention: alissa.parse_session_name
404
+ matches it, and only names it matches are ever reap candidates. Both sides
405
+ build the repo component through `session_repo_slug` so they cannot drift.
398
406
  """
399
- repo = re.sub(r"[^A-Za-z0-9-]", "-", pr.repo).strip("-").lower()
400
- return f"review-{repo}-pr{pr.number}-r{round_}-{secrets.token_hex(3)}"
407
+ return (
408
+ f"review-{session_repo_slug(pr.repo)}-pr{pr.number}"
409
+ f"-r{round_}-{secrets.token_hex(3)}"
410
+ )
401
411
 
402
412
 
403
413
  class ReviewWatcher:
@@ -415,6 +425,14 @@ class ReviewWatcher:
415
425
  # (repo, number, comment id) of every re-entry directive already
416
426
  # refused in this process -- see _log_ignored_ack.
417
427
  self._ignored_acks: set[tuple[str, int, int]] = set()
428
+ # Reviewer session name -> the (repo slug, number) its NAME resolved
429
+ # to, or None for "could not be resolved". Process-lifetime, pruned
430
+ # against the live list each sweep; see _resolve_pr for why the probe
431
+ # behind it must not be paid per poll.
432
+ self._probe_cache: dict[str, tuple[str, int] | None] = {}
433
+ # The surviving-session set the cap alarm last paged on, so a standing
434
+ # over-cap condition pages once per episode -- see _check_session_cap.
435
+ self._paged_cap: frozenset[str] | None = None
418
436
 
419
437
  # -- per-PR decision ---------------------------------------------------
420
438
 
@@ -688,7 +706,8 @@ class ReviewWatcher:
688
706
  round and both submit. So before respawning, consult external
689
707
  evidence of life: the round's recorded session in the live list
690
708
  (the reap sweep's own probe). Busy, or idle without a real quiet
691
- period (mid-close-out between turns; see REAP_QUIET_SECONDS) -> the
709
+ period (mid-close-out between turns; the same `reap_grace_seconds`
710
+ the sweep waits out, because it is the same question) -> the
692
711
  round is alive, defer with a reason. Gone, or idle-finished -> dead,
693
712
  respawn (the sweep separately handles any corpse). An unprobeable
694
713
  live list defers too: respawning on missing evidence is exactly the
@@ -726,7 +745,8 @@ class ReviewWatcher:
726
745
  ses = live.get(session)
727
746
  if ses is None:
728
747
  return None # session gone -> presumed dead -> respawn
729
- if ses.is_idle and time.time() - ses.last_activity >= REAP_QUIET_SECONDS:
748
+ quiet_for = time.time() - ses.last_activity
749
+ if ses.is_idle and quiet_for >= self.config.reap_grace_seconds:
730
750
  return None # idle-finished: it died without submitting -> respawn
731
751
 
732
752
  if (
@@ -823,18 +843,51 @@ class ReviewWatcher:
823
843
  spawn ledger. It must stay search-independent: never move it (back)
824
844
  into the evaluate() path.
825
845
 
826
- Every-poll cost, honestly: one `alissa tmux ls` when no review-*
846
+ Two reap edges, and the difference is what the sweep KNOWS about a
847
+ session:
848
+
849
+ * With a spawn-ledger row the session's PR and round are exact, so a
850
+ round whose verdict already landed is reapable on its own -- an
851
+ approved-but-unmerged PR is the common case, and waiting for a human
852
+ to merge before freeing the slot is the leak this sweep predates.
853
+ * Without one -- the hand-driven `review-pr-<n>` rounds the
854
+ alissa-code-review skill's procedures spawn, and any spawn whose
855
+ ledger was lost -- the name is the only evidence, and it cannot tell
856
+ a superseded round from an in-flight one. Those are reaped on a
857
+ TERMINAL PR only (issue #46): merged or closed ends every round on
858
+ the PR, so no round accounting is needed to be sure. Superseded
859
+ rounds of an OPEN PR are deliberately out of scope -- an operator
860
+ re-entry (`alissa-review: re-enter +N`) may still want that context.
861
+
862
+ Both edges additionally require the session to be idle and to have been
863
+ quiet for `reap_grace_seconds`: a busy session is NEVER killed, even on
864
+ a merged PR (scoped post-merge re-reviews of fold commits are an
865
+ established pattern), and the grace period leaves a just-merged PR's
866
+ reviewer time to finish its in-session close-out.
867
+
868
+ Every-poll cost, honestly: one `alissa tmux ls` when no reviewer
827
869
  session is live; otherwise one PR fetch per distinct PR with a live
828
870
  idle quiet session, plus -- per distinct (PR, task ref) among its
829
- rows -- exactly one of `alissa task get <ref>` (the row carries a
830
- task ref) or the reviews fetch (it does not). The ledger ref is used
831
- deliberately instead of
832
- find_review_task: that would fetch the actor's ENTIRE task list per
833
- PR, and its open-status filter would drop a human-validated review
834
- task back onto the racier GitHub-count fallback. Only individual
871
+ ledger rows -- exactly one of `alissa task get <ref>` (the row carries
872
+ a task ref) or the reviews fetch (it does not). The ledger ref is used
873
+ deliberately instead of find_review_task: that would fetch the actor's
874
+ ENTIRE task list per PR, and its open-status filter would drop a
875
+ human-validated review task back onto the racier GitHub-count
876
+ fallback. A ledger-less session costs its allowlist probe ONCE per
877
+ session lifetime, not once per poll -- `_probe_cache` remembers the
878
+ answer, including the negative one -- after which it is the same one
879
+ fetch per distinct PR as everything else. Busy and in-grace sessions
880
+ are filtered out BEFORE anything is fetched at all. Only individual
835
881
  sessions are ever killed (`alissa tmux kill <name>`) -- never the
836
- server. Best-effort throughout: an undecidable session is spared and
837
- looked at again next poll.
882
+ server, which in this shared container would take every other lane's
883
+ workers with it. Best-effort throughout: an undecidable session is
884
+ spared, logged at debug, and looked at again next poll; only genuine
885
+ failures (a fetch that blew up, a kill that raised) are logged louder.
886
+
887
+ Every spare records WHY against the session name. Those reasons are
888
+ debug-level (they repeat every poll), but the cap alarm interpolates
889
+ them, so the one line an operator does see in a container running at
890
+ INFO carries the explanation with it.
838
891
  """
839
892
  try:
840
893
  sessions = self.alissa.list_review_sessions()
@@ -842,93 +895,342 @@ class ReviewWatcher:
842
895
  log.warning("reap sweep skipped: could not list sessions: %s", exc)
843
896
  return 0
844
897
 
845
- # Per-sweep memos. The PR fetch is keyed per distinct PR; the round
846
- # count additionally keys on the task ref, because two spawns of one
847
- # PR can disagree on it (a round-1 row recorded before the review
898
+ # Drop cached resolutions for sessions that are gone. Names are unique
899
+ # per spawn (that is what the nonce is for) and a session's PR never
900
+ # changes, so this is the only invalidation the cache needs.
901
+ live_names = {ses.name for ses in sessions}
902
+ self._probe_cache = {
903
+ name: target
904
+ for name, target in self._probe_cache.items()
905
+ if name in live_names
906
+ }
907
+
908
+ # Per-sweep memos. The PR fetch is keyed per distinct (repo, number);
909
+ # the round count additionally keys on the task ref, because two spawns
910
+ # of one PR can disagree on it (a round-1 row recorded before the review
848
911
  # task existed carries None). None = undecidable this pass.
849
912
  prs: dict[tuple[str, int], PullRequest | None] = {}
850
913
  completed_cache: dict[tuple[str, int, str | None], float | None] = {}
851
- reaped = 0
914
+ holdouts: dict[str, str] = {}
915
+ reaped: list[str] = []
852
916
 
853
917
  for ses in sessions:
918
+ idle_for = time.time() - ses.last_activity
854
919
  if not ses.is_idle:
855
920
  # A busy session is still doing something (reviewing, or
856
- # closing out its round) -- never yank the slot from under it.
921
+ # closing out its round) -- never yank the slot from under it,
922
+ # whatever its PR's state. Logged, not killed. Deliberately
923
+ # without resolving the PR: the state cannot change the
924
+ # outcome, and paying a fetch per poll for every working
925
+ # reviewer just to log it is not worth the API budget.
926
+ self._hold(holdouts, ses, "busy — never reaped")
857
927
  continue
858
- if time.time() - ses.last_activity < REAP_QUIET_SECONDS:
928
+ if idle_for < self.config.reap_grace_seconds:
859
929
  # Idle but recently active: likely mid-close-out (the review
860
930
  # is submitted before the envelope and task move land). Wait
861
- # for a real quiet period; see REAP_QUIET_SECONDS.
931
+ # out the grace period.
932
+ self._hold(
933
+ holdouts, ses,
934
+ f"idle {idle_for // 60:.0f} min, inside the "
935
+ f"{self.config.reap_grace_seconds // 60} min grace",
936
+ )
862
937
  continue
938
+
863
939
  row = self.state.find_spawn_by_session(ses.name)
864
- if row is None:
865
- # Not in our ledger: another workspace's daemon (or a human)
866
- # owns it. Not ours to judge.
867
- continue
868
- pr_key = (row["repo"], row["number"])
869
- if pr_key not in prs:
870
- prs[pr_key] = self._sweep_pr(row["repo"], row["number"])
871
- pr = prs[pr_key]
940
+ pr = self._resolve_pr(ses, row, prs, holdouts)
872
941
  if pr is None:
873
- continue # fetch failed -- spare everything on this PR
874
- key = (row["repo"], row["number"], row["task_ref"])
875
- if key not in completed_cache:
876
- completed_cache[key] = self._completed_rounds(pr, row["task_ref"])
877
- completed = completed_cache[key]
878
- if completed is None or row["round"] > completed:
879
- continue # undecidable, or the round is still in flight
942
+ continue # undecidable -- already recorded, retried next poll
943
+
944
+ reason = self._reap_reason(pr, ses, row, completed_cache, holdouts)
945
+ if reason is None:
946
+ continue
947
+
948
+ # `last_activity` is 0 when the CLI reported none: that reads as
949
+ # "quiet for ages" to the guard above, but printing the epoch as a
950
+ # duration would be a lie, so the evidence says so instead.
951
+ idle_note = (
952
+ f"idle {idle_for // 60:.0f} min"
953
+ if ses.last_activity
954
+ else "idle, no activity timestamp"
955
+ )
956
+ evidence = (
957
+ f"{pr.slug} is {'merged' if pr.merged else pr.state}, "
958
+ f"{idle_note} — {reason}"
959
+ )
880
960
  if self.config.dry_run:
881
- log.info(
882
- "[dry-run] would reap finished reviewer session %s (round %d done)",
883
- ses.name, row["round"],
884
- )
961
+ log.info("[dry-run] would reap reviewer session %s (%s)", ses.name, evidence)
962
+ holdouts[ses.name] = f"dry-run: would have reaped ({evidence})"
885
963
  continue
886
964
  try:
887
965
  self.alissa.kill_session(ses.name)
888
966
  except Exception: # pragma: no cover - defence in depth
889
967
  log.exception("failed to reap session %s", ses.name)
968
+ holdouts[ses.name] = "the kill raised — see the traceback above"
890
969
  continue
891
970
  # Bookkeeping only -- deliberately never consulted before a kill.
892
971
  # The live list is the authority; gating on the reaps table would
893
972
  # spare any session killed behind the ledger's back.
894
973
  self.state.record_reap(ses.name)
895
- reaped += 1
896
- log.info(
897
- "reaped finished reviewer session %s (round %d done)",
898
- ses.name, row["round"],
974
+ reaped.append(ses.name)
975
+ log.info("reaped reviewer session %s (%s)", ses.name, evidence)
976
+
977
+ self._check_session_cap(sessions, reaped, holdouts)
978
+ return len(reaped)
979
+
980
+ def _hold(self, holdouts: dict[str, str], ses: ManagedSession, why: str) -> None:
981
+ """Record (and log at debug) why a live session was not reaped.
982
+
983
+ Debug because it repeats every poll for the whole life of a spared
984
+ session -- and the deployed container runs at INFO, so these lines are
985
+ invisible there by design. They are not the operator's channel: the
986
+ cap alarm is, and it interpolates what is recorded here.
987
+ """
988
+ holdouts[ses.name] = why
989
+ log.debug("reap sweep: %s spared — %s", ses.name, why)
990
+
991
+ def _resolve_pr(
992
+ self,
993
+ ses: ManagedSession,
994
+ row: sqlite3.Row | None,
995
+ prs: dict[tuple[str, int], PullRequest | None],
996
+ holdouts: dict[str, str],
997
+ ) -> PullRequest | None:
998
+ """The PR a reapable session is about, or None when undecidable.
999
+
1000
+ A ledger row names the PR outright. Without one the session name is
1001
+ the only evidence, so the number it carries is resolved against the
1002
+ `repos` allowlist. A name carrying a repo component picks its
1003
+ allowlist entry directly; a bare `review-pr-<n>` (the skill's shape)
1004
+ names no repo, so every watched repo is probed and EXACTLY one must
1005
+ have that PR. An empty allowlist -- "watch every repo that asks" --
1006
+ can never resolve a bare name at all; nothing bounds the search.
1007
+
1008
+ What that does and does NOT establish, precisely, because the
1009
+ difference decides whose session can be killed: the allowlist BOUNDS
1010
+ THE SEARCH, it does not prove ownership. A bare number carries no
1011
+ repo, so a session reviewing a PR in an UNWATCHED repo is reaped if
1012
+ exactly one watched repo happens to have a terminal PR of the same
1013
+ number. Requiring a unique hit bounds the blast radius rather than
1014
+ removing it. The converse costs reaps instead: once two watched repos
1015
+ both have a PR #n -- inevitable as newer repos' numbering catches up
1016
+ with older ones -- every bare `review-pr-<n>` in the overlap resolves
1017
+ to two hits and is spared forever. Both are known v1 limits, recorded
1018
+ in the README; the durable fix is a repo in the name (skill-side) or a
1019
+ ledger row for hand-spawned sessions.
1020
+
1021
+ The probe is paid ONCE per session name. Its answer -- the resolved
1022
+ (repo, number), or a sticky None for "could not be resolved" -- is
1023
+ cached on the watcher, because a name is unique per spawn and its PR
1024
+ never changes. Without that, a session spared on an open PR (the
1025
+ common case: a finished round waits idle for the implementer) re-ran
1026
+ the whole allowlist sweep on every poll for the life of the PR, which
1027
+ at 7 repos and a 30s interval is ~840 REST calls an hour for one
1028
+ session. The sticky None never re-probes within a process, so an
1029
+ ambiguity that later clears is not noticed until a restart -- the safe
1030
+ direction (spare, never guess), and the alternative is exactly the
1031
+ cost above.
1032
+ """
1033
+ if row is not None:
1034
+ return self._fetch_pr(prs, row["repo"], row["number"])
1035
+
1036
+ ref = ses.ref
1037
+ if ref is None: # pragma: no cover - list_review_sessions filters these
1038
+ return None
1039
+
1040
+ if ses.name not in self._probe_cache:
1041
+ self._probe_cache[ses.name] = self._probe_allowlist(ses, ref, prs)
1042
+ target = self._probe_cache[ses.name]
1043
+ if target is None:
1044
+ self._hold(
1045
+ holdouts, ses,
1046
+ f"no ledger row and PR #{ref.number} does not resolve to exactly "
1047
+ f"one watched repo — sparing it rather than guessing",
899
1048
  )
900
- return reaped
1049
+ return None
1050
+ # A memo hit in the common case: the probe that resolved this session
1051
+ # already fetched it. On a later poll this is the one fetch that can
1052
+ # turn the session reapable, so it is not cacheable.
1053
+ return self._fetch_pr(prs, target[0], target[1])
901
1054
 
902
- def _sweep_pr(self, repo_slug: str, number: int) -> PullRequest | None:
903
- """One PR fetch for the sweep; the caller memoizes per distinct PR.
1055
+ def _probe_allowlist(
1056
+ self,
1057
+ ses: ManagedSession,
1058
+ ref: SessionRef,
1059
+ prs: dict[tuple[str, int], PullRequest | None],
1060
+ ) -> tuple[str, int] | None:
1061
+ """Which watched repo owns this session's PR number, or None.
904
1062
 
905
- None = the fetch failed -- every session on that PR is spared this
906
- pass and looked at again next poll. RateLimited propagates so
1063
+ The expensive half of _resolve_pr, called once per session name.
1064
+ """
1065
+ candidates = self._name_candidates(ref)
1066
+ if not candidates:
1067
+ return None
1068
+ hits = []
1069
+ for repo_slug in candidates:
1070
+ # Quiet: probing a repo that simply has no PR #n is the expected
1071
+ # outcome for all but one candidate, not a failure worth a warning.
1072
+ if self._fetch_pr(prs, repo_slug, ref.number, quiet=True) is not None:
1073
+ hits.append(repo_slug)
1074
+ if len(hits) != 1:
1075
+ log.debug(
1076
+ "reap sweep: PR #%d of %s resolves to %d watched repo(s)",
1077
+ ref.number, ses.name, len(hits),
1078
+ )
1079
+ return None
1080
+ return (hits[0], ref.number)
1081
+
1082
+ def _name_candidates(self, ref: SessionRef) -> list[str]:
1083
+ """The watched repos a session name could be about.
1084
+
1085
+ A name-borne repo component narrows the allowlist to (at most) its own
1086
+ entry; a bare name leaves the whole allowlist as candidates. Matching
1087
+ goes through `session_repo_slug` on both sides so `studio.alissa.app`
1088
+ and the `studio-alissa-app` a session name can carry compare equal.
1089
+ """
1090
+ if ref.repo is None:
1091
+ return list(self.config.repos)
1092
+ return [
1093
+ full_name
1094
+ for full_name in self.config.repos
1095
+ if session_repo_slug(full_name.partition("/")[2]) == ref.repo
1096
+ ]
1097
+
1098
+ def _reap_reason(
1099
+ self,
1100
+ pr: PullRequest,
1101
+ ses: ManagedSession,
1102
+ row: sqlite3.Row | None,
1103
+ completed_cache: dict[tuple[str, int, str | None], float | None],
1104
+ holdouts: dict[str, str],
1105
+ ) -> str | None:
1106
+ """Why this session may be reaped, or None to spare it (recorded)."""
1107
+ if pr.is_terminal:
1108
+ return "the PR is terminal, so every round on it is over"
1109
+
1110
+ if row is None:
1111
+ # v1 reaps a ledger-less session on a terminal PR only. The name's
1112
+ # `-r<k>` cannot tell a superseded round from an in-flight one, and
1113
+ # an operator re-entry may still want the earlier round's context;
1114
+ # superseded-round reaping is a v2 with its own analysis (#46).
1115
+ self._hold(
1116
+ holdouts, ses,
1117
+ f"{pr.slug} is open and there is no ledger row — v1 reaps "
1118
+ f"ledger-less sessions on terminal PRs only",
1119
+ )
1120
+ return None
1121
+
1122
+ key = (row["repo"], row["number"], row["task_ref"])
1123
+ if key not in completed_cache:
1124
+ completed_cache[key] = self._completed_rounds(pr, row["task_ref"])
1125
+ completed = completed_cache[key]
1126
+ if completed is None or row["round"] > completed:
1127
+ self._hold(
1128
+ holdouts, ses,
1129
+ f"round {row['round']} of {pr.slug} is not finished "
1130
+ f"({completed} completed)",
1131
+ )
1132
+ return None
1133
+ return f"round {row['round']} of an open PR is done"
1134
+
1135
+ def _check_session_cap(
1136
+ self,
1137
+ sessions: list[ManagedSession],
1138
+ reaped: list[str],
1139
+ holdouts: dict[str, str],
1140
+ ) -> None:
1141
+ """Page-worthy log when the sweep is not keeping up.
1142
+
1143
+ Counted AFTER the sweep, from the same live list the sweep walked
1144
+ minus what it killed, so the number is "sessions this pass could not
1145
+ free". Every idle agent session holds hundreds of MB forever and the
1146
+ worker container is shared, so a count that stays above the cap is the
1147
+ 2026-07-28 incident happening again.
1148
+
1149
+ The page carries each survivor's spare reason inline, because the
1150
+ per-session holdout lines are debug and the deployed container runs at
1151
+ INFO: an alarm whose explanation is invisible is an alarm an operator
1152
+ cannot act on.
1153
+
1154
+ Deduped in-process on the set of surviving names: the sweep runs every
1155
+ poll (every 30s in the deployed config), and 120 identical pages an
1156
+ hour is not a page. It re-fires when the set changes, and clears once
1157
+ the count falls back inside the cap so the next episode pages again.
1158
+ Deliberately NOT the `pings` ledger the stalled-round escalation uses:
1159
+ that table is keyed (repo, number, kind) and this alarm belongs to no
1160
+ PR, so persisting it would mean a sentinel row every console reader
1161
+ then has to filter around -- and a standing over-cap condition SHOULD
1162
+ announce itself once to a freshly restarted daemon, which a persisted
1163
+ ping would suppress forever.
1164
+ """
1165
+ killed = set(reaped)
1166
+ remaining = sorted(s.name for s in sessions if s.name not in killed)
1167
+ if len(remaining) <= self.config.reap_session_cap:
1168
+ self._paged_cap = None
1169
+ return
1170
+ episode = frozenset(remaining)
1171
+ if episode == self._paged_cap:
1172
+ return
1173
+ self._paged_cap = episode
1174
+ log.error(
1175
+ "REVIEWER SESSION CAP EXCEEDED: %d live reviewer sessions after the "
1176
+ "sweep (cap %d) — each holds hundreds of MB in a shared container. "
1177
+ "Survivors and why the sweep spared each: %s",
1178
+ len(remaining),
1179
+ self.config.reap_session_cap,
1180
+ "; ".join(
1181
+ f"{name} ({holdouts.get(name, 'no reason recorded')})"
1182
+ for name in remaining
1183
+ ),
1184
+ )
1185
+
1186
+ def _fetch_pr(
1187
+ self,
1188
+ prs: dict[tuple[str, int], PullRequest | None],
1189
+ repo_slug: str,
1190
+ number: int,
1191
+ *,
1192
+ quiet: bool = False,
1193
+ ) -> PullRequest | None:
1194
+ """One memoized PR fetch for the sweep, per distinct (repo, number).
1195
+
1196
+ None = the PR could not be fetched -- it does not exist in that repo,
1197
+ or the call failed. Either way every session resolving through it is
1198
+ spared this pass and looked at again next poll. `quiet` demotes the
1199
+ log to debug for the allowlist probe, where a miss is the expected
1200
+ answer for all but one candidate. RateLimited propagates so
907
1201
  run_forever backs off instead of hammering the API once per session.
908
1202
  """
1203
+ key = (repo_slug, number)
1204
+ if key in prs:
1205
+ return prs[key]
909
1206
  owner, _, repo = repo_slug.partition("/")
910
1207
  try:
911
- return self.github.pull_request(owner, repo, number)
1208
+ prs[key] = self.github.pull_request(owner, repo, number)
912
1209
  except RateLimited:
913
1210
  raise
914
1211
  except CommandError as exc:
915
- log.warning("reap sweep: could not fetch %s#%d: %s", repo_slug, number, exc)
916
- return None
1212
+ log.log(
1213
+ logging.DEBUG if quiet else logging.WARNING,
1214
+ "reap sweep: could not fetch %s#%d: %s", repo_slug, number, exc,
1215
+ )
1216
+ prs[key] = None
1217
+ return prs[key]
917
1218
 
918
1219
  def _completed_rounds(self, pr: PullRequest, task_ref: str | None) -> float | None:
919
- """How many rounds of this PR are over, judged from GitHub/task state.
1220
+ """How many rounds of this OPEN PR are over, judged from task/GitHub.
920
1221
 
921
- A closed or merged PR terminates every round, so it reports infinity.
922
- Otherwise rounds completed = verdict envelopes on the review task (the
1222
+ Rounds completed = verdict envelopes on the review task (the
923
1223
  authoritative round record), addressed by the task ref the ledger
924
1224
  captured at spawn time -- NOT find_review_task, which would fetch the
925
1225
  whole task list and whose open-status filter loses validated tasks.
926
1226
  The substantive-review count is the fallback only for spawns recorded
927
1227
  before any review task existed. None means "could not tell" -- the
928
1228
  sweep spares the session and retries next poll.
1229
+
1230
+ Terminal PRs never reach here: the caller answers those before asking
1231
+ about rounds, because a merged or closed PR ends every round on it
1232
+ whatever the envelopes say.
929
1233
  """
930
- if pr.is_terminal:
931
- return float("inf")
932
1234
  if task_ref:
933
1235
  # count_verdicts never raises; unreadable evidence degrades to 0,
934
1236
  # which spares the session (round >= 1 > 0).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.16.1
3
+ Version: 0.16.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera