alissa-tools-github-revloop 0.16.0__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.2/LICENSE +202 -0
  2. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/MANIFEST.in +2 -0
  3. alissa_tools_github_revloop-0.16.2/NOTICE +5 -0
  4. {alissa_tools_github_revloop-0.16.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.2}/PKG-INFO +14 -1
  5. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/README.md +8 -0
  6. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/setup.py +2 -0
  7. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/__main__.py +16 -0
  8. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/alissa.py +98 -5
  9. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/config.py +69 -0
  10. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/loop.py +376 -74
  11. alissa_tools_github_revloop-0.16.2/src/main/alissa/tools/github/revloop/version +1 -0
  12. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +14 -1
  13. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +2 -0
  14. alissa_tools_github_revloop-0.16.0/src/main/alissa/tools/github/revloop/version +0 -1
  15. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/requirements.txt +0 -0
  16. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/setup.cfg +0 -0
  17. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  18. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  19. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  20. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  21. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/state.py +0 -0
  22. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/version.py +0 -0
  23. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  24. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  25. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  26. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  27. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  28. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  29. {alissa_tools_github_revloop-0.16.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  30. {alissa_tools_github_revloop-0.16.0 → 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.0 → 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.0 → alissa_tools_github_revloop-0.16.2}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -1,2 +1,4 @@
1
1
  include requirements.txt
2
2
  include src/main/alissa/tools/github/revloop/version
3
+ include LICENSE
4
+ include NOTICE
@@ -0,0 +1,5 @@
1
+ alissa-tools-github-revloop
2
+ Copyright 2026 CORE FAHERA ENTERPRISE HOLDINGS S. DE R.L.
3
+
4
+ "Alissa" and "Fahera" are trademarks of CORE FAHERA ENTERPRISE HOLDINGS S. DE R.L.
5
+ This NOTICE and the accompanying license do not grant permission to use these trademarks.
@@ -1,17 +1,22 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.16.0
3
+ Version: 0.16.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
7
7
  Author-email: support@alissa.app
8
+ License: Apache-2.0
8
9
  Requires-Python: >=3.11
9
10
  Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ License-File: NOTICE
10
13
  Dynamic: author
11
14
  Dynamic: author-email
12
15
  Dynamic: description
13
16
  Dynamic: description-content-type
14
17
  Dynamic: home-page
18
+ Dynamic: license
19
+ Dynamic: license-file
15
20
  Dynamic: requires-python
16
21
  Dynamic: summary
17
22
 
@@ -59,3 +64,11 @@ pip install -e ./alissa-tools-github-revloop
59
64
  distribution version lives in the plain-text `version` file next to the module
60
65
  it versions (`src/main/alissa/tools/github/revloop/version`), read by both
61
66
  `setup.py` and `version.py`.
67
+
68
+ ## License
69
+
70
+ Licensed under the Apache License 2.0 — see the `LICENSE` and `NOTICE` files
71
+ shipped with this distribution.
72
+
73
+ "Alissa" and "Fahera" are trademarks of CORE FAHERA ENTERPRISE HOLDINGS S. DE
74
+ R.L.; the license does not grant permission to use them.
@@ -42,3 +42,11 @@ pip install -e ./alissa-tools-github-revloop
42
42
  distribution version lives in the plain-text `version` file next to the module
43
43
  it versions (`src/main/alissa/tools/github/revloop/version`), read by both
44
44
  `setup.py` and `version.py`.
45
+
46
+ ## License
47
+
48
+ Licensed under the Apache License 2.0 — see the `LICENSE` and `NOTICE` files
49
+ shipped with this distribution.
50
+
51
+ "Alissa" and "Fahera" are trademarks of CORE FAHERA ENTERPRISE HOLDINGS S. DE
52
+ R.L.; the license does not grant permission to use them.
@@ -28,6 +28,8 @@ with open("README.md") as file:
28
28
  setup(
29
29
  name="alissa-tools-github-revloop",
30
30
  version=version,
31
+ license="Apache-2.0",
32
+ license_files=("LICENSE", "NOTICE"),
31
33
  description="ALISSA-TOOLS-GITHUB-REVLOOP",
32
34
  long_description=readme,
33
35
  long_description_content_type='text/markdown',
@@ -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,17 +1,22 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.16.0
3
+ Version: 0.16.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
7
7
  Author-email: support@alissa.app
8
+ License: Apache-2.0
8
9
  Requires-Python: >=3.11
9
10
  Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ License-File: NOTICE
10
13
  Dynamic: author
11
14
  Dynamic: author-email
12
15
  Dynamic: description
13
16
  Dynamic: description-content-type
14
17
  Dynamic: home-page
18
+ Dynamic: license
19
+ Dynamic: license-file
15
20
  Dynamic: requires-python
16
21
  Dynamic: summary
17
22
 
@@ -59,3 +64,11 @@ pip install -e ./alissa-tools-github-revloop
59
64
  distribution version lives in the plain-text `version` file next to the module
60
65
  it versions (`src/main/alissa/tools/github/revloop/version`), read by both
61
66
  `setup.py` and `version.py`.
67
+
68
+ ## License
69
+
70
+ Licensed under the Apache License 2.0 — see the `LICENSE` and `NOTICE` files
71
+ shipped with this distribution.
72
+
73
+ "Alissa" and "Fahera" are trademarks of CORE FAHERA ENTERPRISE HOLDINGS S. DE
74
+ R.L.; the license does not grant permission to use them.
@@ -1,4 +1,6 @@
1
+ LICENSE
1
2
  MANIFEST.in
3
+ NOTICE
2
4
  README.md
3
5
  requirements.txt
4
6
  setup.py