alissa-tools-github-devloop 0.8.0__tar.gz → 0.8.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 (31) hide show
  1. alissa_tools_github_devloop-0.8.2/LICENSE +202 -0
  2. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/MANIFEST.in +2 -0
  3. alissa_tools_github_devloop-0.8.2/NOTICE +5 -0
  4. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/PKG-INFO +18 -1
  5. alissa_tools_github_devloop-0.8.0/src/main/alissa_tools_github_devloop.egg-info/PKG-INFO → alissa_tools_github_devloop-0.8.2/README.md +12 -17
  6. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/setup.py +6 -0
  7. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/__main__.py +13 -2
  8. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/config.py +34 -0
  9. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/loop.py +438 -85
  10. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/state.py +13 -0
  11. alissa_tools_github_devloop-0.8.2/src/main/alissa/tools/github/devloop/version +1 -0
  12. alissa_tools_github_devloop-0.8.0/README.md → alissa_tools_github_devloop-0.8.2/src/main/alissa_tools_github_devloop.egg-info/PKG-INFO +34 -0
  13. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +2 -0
  14. alissa_tools_github_devloop-0.8.0/src/main/alissa/tools/github/devloop/version +0 -1
  15. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/requirements.txt +0 -0
  16. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/setup.cfg +0 -0
  17. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  18. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  19. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  20. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  21. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/version.py +0 -0
  22. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  23. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  24. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  25. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  26. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  27. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  28. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  29. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  30. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  31. {alissa_tools_github_devloop-0.8.0 → alissa_tools_github_devloop-0.8.2}/src/main/alissa_tools_github_devloop.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
+ include LICENSE
2
+ include NOTICE
1
3
  include requirements.txt
2
4
  include src/main/alissa/tools/github/devloop/version
@@ -0,0 +1,5 @@
1
+ alissa-tools-github-devloop
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-devloop
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
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,15 @@ pip install -e ./alissa-tools-github-devloop
59
64
  distribution version lives in the plain-text `version` file next to the module
60
65
  it versions (`src/main/alissa/tools/github/devloop/version`), read by both
61
66
  `setup.py` and `version.py`.
67
+
68
+ ## License
69
+
70
+ Licensed under the
71
+ [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0) — the
72
+ `LICENSE` and `NOTICE` files shipped with this distribution carry the full text
73
+ and the attribution and trademark terms. Both ship inside the sdist and the
74
+ wheel's `.dist-info`.
75
+
76
+ "Alissa" and "Fahera" are trademarks of CORE FAHERA ENTERPRISE HOLDINGS
77
+ S. DE R.L. and are not covered by the license — the grant covers the code, not
78
+ the names.
@@ -1,20 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: alissa-tools-github-devloop
3
- Version: 0.8.0
4
- Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
- Home-page: https://alissa.app
6
- Author: Fahera
7
- Author-email: support@alissa.app
8
- Requires-Python: >=3.11
9
- Description-Content-Type: text/markdown
10
- Dynamic: author
11
- Dynamic: author-email
12
- Dynamic: description
13
- Dynamic: description-content-type
14
- Dynamic: home-page
15
- Dynamic: requires-python
16
- Dynamic: summary
17
-
18
1
  # alissa-tools-github-devloop
19
2
 
20
3
  The `alissa.tools.github.devloop` module: a GitHub watcher that turns
@@ -59,3 +42,15 @@ pip install -e ./alissa-tools-github-devloop
59
42
  distribution version lives in the plain-text `version` file next to the module
60
43
  it versions (`src/main/alissa/tools/github/devloop/version`), read by both
61
44
  `setup.py` and `version.py`.
45
+
46
+ ## License
47
+
48
+ Licensed under the
49
+ [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0) — the
50
+ `LICENSE` and `NOTICE` files shipped with this distribution carry the full text
51
+ and the attribution and trademark terms. Both ship inside the sdist and the
52
+ wheel's `.dist-info`.
53
+
54
+ "Alissa" and "Fahera" are trademarks of CORE FAHERA ENTERPRISE HOLDINGS
55
+ S. DE R.L. and are not covered by the license — the grant covers the code, not
56
+ the names.
@@ -34,6 +34,12 @@ setup(
34
34
  url="https://alissa.app",
35
35
  author="Fahera",
36
36
  author_email="support@alissa.app",
37
+ # SPDX expression, no license trove classifier: PEP 639 deprecates the
38
+ # classifiers and newer setuptools rejects one that restates the expression.
39
+ # LICENSE and NOTICE are copies of the repo-root files -- the build context
40
+ # is this directory, so a root-only copy never reaches the sdist/wheel.
41
+ license="Apache-2.0",
42
+ license_files=("LICENSE", "NOTICE"),
37
43
  packages=find_namespace_packages(
38
44
  where=CODEBASE_PATH,
39
45
  include=[PACKAGE, f"{PACKAGE}.*"],
@@ -98,7 +98,15 @@ def build_parser() -> argparse.ArgumentParser:
98
98
  metavar="N",
99
99
  help="max concurrent worker sessions (develop-*/fix-*/maintain-*) "
100
100
  "before actionable items defer to the next poll; 0 = unlimited "
101
- "(the default)",
101
+ "(the default). Also the reaper's post-sweep alert threshold",
102
+ )
103
+ over.add_argument(
104
+ "--reap-grace-minutes",
105
+ type=int,
106
+ metavar="MINUTES",
107
+ help="how long a worker session must have been quiet before the "
108
+ "reaper may kill it on a terminal anchor; 0 = use stale_minutes "
109
+ "(default: 30)",
102
110
  )
103
111
  over.add_argument(
104
112
  "--on-missing-origin-task",
@@ -267,6 +275,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
267
275
  "attempt_cap": args.attempt_cap,
268
276
  "stale_minutes": args.stale_minutes,
269
277
  "max_sessions": args.max_sessions,
278
+ "reap_grace_minutes": args.reap_grace_minutes,
270
279
  "on_missing_origin_task": args.on_missing_origin_task,
271
280
  "on_missing_hub": args.on_missing_hub,
272
281
  "fix_rounds_enabled": args.fix_rounds_enabled,
@@ -309,12 +318,14 @@ def log_effective_config(config: Config, login: str) -> None:
309
318
  log.info("reviewers: %s", ", ".join(config.reviewers) or "none")
310
319
  log.info(
311
320
  "poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
312
- "max_sessions=%s; fix_rounds=%s; maintain=%s (label %r); resume=%s; "
321
+ "max_sessions=%s; reap_grace=%s min; fix_rounds=%s; maintain=%s "
322
+ "(label %r); resume=%s; "
313
323
  "rerequest=%s (grace %s min); orphan_sweep=%s (grace %s min); "
314
324
  "activity_claims=%s",
315
325
  config.poll_interval, config.dry_run, config.attempt_cap,
316
326
  config.stale_minutes,
317
327
  config.max_sessions or "unlimited",
328
+ config.reap_grace_seconds // 60,
318
329
  config.fix_rounds_enabled,
319
330
  config.maintain_enabled, config.maintain_label,
320
331
  config.resume_enabled,
@@ -57,6 +57,7 @@ CONFIG_KEYS = (
57
57
  "attempt_cap",
58
58
  "stale_minutes",
59
59
  "max_sessions",
60
+ "reap_grace_minutes",
60
61
  "on_missing_origin_task",
61
62
  "on_missing_hub",
62
63
  "fix_rounds_enabled",
@@ -149,6 +150,18 @@ class Config:
149
150
  # listing, so enabling it costs no extra GitHub or CLI calls.
150
151
  max_sessions: int = 0
151
152
 
153
+ # The reaper's grace: how long a worker session must have been QUIET (no
154
+ # tmux activity) before a terminal anchor licenses killing it. The point
155
+ # is the window right after an anchor goes terminal -- a PR merges while
156
+ # its session is still attaching evidence, answering a triage thread,
157
+ # writing its delivery note -- where the anchor already says "done" and
158
+ # the session is not. Busy is spared regardless (see DevWatcher's sweep);
159
+ # this covers the session that is idle between two bursts of that work.
160
+ # 30 minutes by default: long enough to outlast a close-out, short enough
161
+ # that a corpse costs at most one extra poll window of memory. 0 falls
162
+ # back to stale_minutes, the sentinel every other grace knob here uses.
163
+ reap_grace_minutes: int = 30
164
+
152
165
  on_missing_origin_task: str = ON_MISSING_WARN
153
166
  on_missing_hub: str = HUB_SKIP
154
167
 
@@ -280,6 +293,15 @@ class Config:
280
293
  the re-request instantly on a default config."""
281
294
  return (self.rerequest_grace_minutes or self.stale_minutes) * 60
282
295
 
296
+ @property
297
+ def reap_grace_seconds(self) -> int:
298
+ """How long a worker session must have been quiet before the reaper
299
+ may kill it, in seconds. Read this, never the raw field -- the
300
+ `rerequest_grace_seconds` contract verbatim: 0 is the sentinel for
301
+ "the stale window", and a caller reading the field directly would kill
302
+ a session that went quiet seconds ago on a config that set 0."""
303
+ return (self.reap_grace_minutes or self.stale_minutes) * 60
304
+
283
305
  @property
284
306
  def orphan_grace_seconds(self) -> int:
285
307
  """How long a draft's head must have been public before the orphan
@@ -391,6 +413,17 @@ class Config:
391
413
  f"max_sessions must be >= 0 (0 = unlimited), got {max_sessions}"
392
414
  )
393
415
 
416
+ # The reaper's grace, validated like the two below it -- same 0
417
+ # sentinel, and a negative value is the only invalid one (it would
418
+ # make every listed session instantly reapable, which is the one
419
+ # mistake here that kills live work).
420
+ reap_grace = int(raw.get("reap_grace_minutes", cls.reap_grace_minutes))
421
+ if reap_grace < 0:
422
+ raise ValueError(
423
+ f"reap_grace_minutes must be >= 0 (0 = use stale_minutes), "
424
+ f"got {reap_grace}"
425
+ )
426
+
394
427
  # 0 is the sentinel for "fall back to stale_minutes" (the documented
395
428
  # default), so the floor is 0 like max_sessions' -- a negative grace
396
429
  # would make the self-heal fire on a head pushed in the future.
@@ -433,6 +466,7 @@ class Config:
433
466
  attempt_cap=cap,
434
467
  stale_minutes=stale,
435
468
  max_sessions=max_sessions,
469
+ reap_grace_minutes=reap_grace,
436
470
  on_missing_origin_task=mode,
437
471
  on_missing_hub=hub_mode,
438
472
  fix_rounds_enabled=bool(
@@ -256,25 +256,58 @@ cover the window it is for. The cost is a crash between the line and the
256
256
  enqueue leaving a claim with no session -- one stale window of deferral, then
257
257
  the line ages out and the next poll spawns normally.
258
258
 
259
- Finally, every pass runs the SESSION REAPER: finished `develop-*`/`fix-*`
260
- worker sessions otherwise idle in tmux forever (observed live on the reviewer
261
- daemon, 2026-07-22 -- and dev workers are worse, because they block through
262
- entire review loops). Both directives tell the session to kill itself as its
263
- last act, but that self-kill is UNRELIABLE by assumption -- a session that
264
- crashes, stalls, or is superseded never reaches its last act -- so the sweep
265
- is the guarantee, not the fast path. The sweep is deliberately
259
+ Finally, every pass runs the SESSION REAPER: finished worker sessions
260
+ otherwise idle in tmux forever (observed live on the reviewer daemon,
261
+ 2026-07-22 -- and dev workers are worse, because they block through entire
262
+ review loops; by 2026-07-28 the shared container had climbed past 10 GB with 4
263
+ corpses among 12 live claude sessions). Every directive tells the session to
264
+ kill itself as its last act, but that self-kill is UNRELIABLE by assumption --
265
+ a session that crashes, stalls, or is superseded never reaches its last act --
266
+ so the sweep is the guarantee, not the fast path. The sweep is deliberately
266
267
  SEARCH-INDEPENDENT, the reviewer daemon's structural lesson baked in: its
267
268
  `_reap_finished` keyed off the poll's search results and starved -- it became
268
269
  unreachable the moment the trigger state cleared, which is exactly when
269
270
  sessions finish. Here the sweep runs unconditionally at the top of poll_once,
270
- BEFORE the searches, off the tmux list and the local ledgers alone: it lists
271
- `develop-*`/`fix-*` sessions via `alissa tmux ls`, cross-references each
272
- against the spawn/fix ledgers and GitHub state (issue closed / PR closed or
273
- merged / attempt or round superseded), and kills the idle finished ones via
274
- `alissa tmux kill <name>` -- one session at a time, never the server. Busy
275
- sessions and fresh in-flight ones (ledger row younger than stale_minutes) are
276
- spared, dry-run only logs, and kills are best-effort: a CLI without
277
- `tmux kill` degrades to a once-per-process warning.
271
+ BEFORE the searches, off the tmux list, the local ledgers, and GitHub alone.
272
+
273
+ What the sweep will even LOOK at is an exact GRAMMAR, not a prefix (see
274
+ `parse_worker_session`): `develop-<owner>-<repo>-i<n>-a<k>`,
275
+ `fix-<owner>-<repo>-pr<n>-r<r>-a<k>`, `maintain-<owner>-<repo>-pr<n>-a<k>`,
276
+ spelled by the very builders that name the spawns. The container is SHARED
277
+ with other daemons' lanes and with operator shells, and this code kills
278
+ processes: a name that does not parse is not devloop's worker and is never
279
+ considered, let alone killed. Killing is per-session (`alissa tmux kill
280
+ <name>`), never `kill-server` -- the server hosts every lane's workers.
281
+
282
+ "Finished" is an ANCHOR question: the issue closed, or the PR closed/merged.
283
+ The anchor is resolved twice over -- from the spawn ledgers when a row exists
284
+ (which also yields the ledger-only supersession signals: a newer attempt or a
285
+ newer review round ends an older session whatever GitHub says), and otherwise
286
+ from the session NAME, whose slug is matched back to the repos allowlist and
287
+ whose number is asked of GitHub directly. The name path is what makes the
288
+ sweep survive the ledger: a fresh state volume, a migrated deployment, or the
289
+ enqueue-before-ledger crash window all leave real sessions no local row can
290
+ explain, and before it they idled forever.
291
+
292
+ A reap needs THREE things at once: a terminal anchor, an IDLE session, and
293
+ `reap_grace_minutes` (default 30) elapsed since the session's last tmux
294
+ activity. Busy is never killed, terminal anchor or not -- post-merge close-out
295
+ work is real (the PR #275 -> #279 replay-onto-main incident happened in
296
+ exactly that window) -- so a busy session with a terminal anchor is LOGGED as
297
+ a holdout, with its anchor evidence, and left alone. The grace exists so a
298
+ just-merged PR's session can finish its in-session handoff before dying.
299
+ Terminal-anchor-only reaping is also what makes the sweep compose with the
300
+ respawn paths: a closed issue / merged PR has no pending work, and every edge
301
+ re-fetches its anchor and returns before it consults session liveness, so a
302
+ reaped session can never be resurrected as a resume.
303
+
304
+ After the sweep, if the live worker sessions STILL exceed `max_sessions`, the
305
+ pass logs loudly (ERROR, page-worthy): the reaper only kills idle sessions
306
+ with terminal anchors, so an overflow means busy-but-terminal work is piling
307
+ up faster than it finishes and a human should look. Reap failures never block
308
+ the walk: a tmux error or a GitHub 404 on an anchor spares that session for
309
+ the pass and moves on, dry-run only logs, and kills are best-effort (a CLI
310
+ without `tmux kill` degrades to a once-per-process warning).
278
311
  """
279
312
 
280
313
  from __future__ import annotations
@@ -1451,6 +1484,21 @@ class SessionLiveness:
1451
1484
  return tuple(sorted(n for n in self._live if n.startswith(prefix)))
1452
1485
 
1453
1486
 
1487
+ @dataclass(frozen=True, slots=True)
1488
+ class Sweep:
1489
+ """What ONE reaper sweep did and saw.
1490
+
1491
+ `reaped` counts the kills (would-kills under dry-run), `names` carries
1492
+ them so the pass can discount them from the live-session count, and
1493
+ `holdouts` are the sessions whose anchor is terminal but which were BUSY
1494
+ -- never killed, by the locked guardrail, and reported because they are
1495
+ the usual answer to "why is the machine still over the cap?"."""
1496
+
1497
+ reaped: int
1498
+ names: "set[str]"
1499
+ holdouts: "tuple[str, ...]" = ()
1500
+
1501
+
1454
1502
  class SessionBudget:
1455
1503
  """The per-pass concurrency budget behind `max_sessions`.
1456
1504
 
@@ -1640,6 +1688,151 @@ def maintain_session_name(pr: PullRequest, attempt: int) -> str:
1640
1688
  return f"{pr_session_prefix(pr, 'maintain')}a{attempt}"
1641
1689
 
1642
1690
 
1691
+ # The reaper's GRAMMAR: devloop's own worker session names, read back. Each
1692
+ # expression is the exact shape its builder above writes -- `_name_slug`
1693
+ # lowercases and squashes to `[a-z0-9-]`, so the slug alphabet is stated once
1694
+ # here and nowhere else. The slug is greedy on purpose: it must swallow every
1695
+ # dash of `<owner>-<repo>` and stop at the LAST `-i<n>-a<k>` / `-pr<n>-...`
1696
+ # tail, which is the only split the builders can ever have produced.
1697
+ _DEVELOP_NAME_RE = re.compile(r"develop-(?P<slug>[a-z0-9-]+)-i(?P<number>\d+)-a(?P<attempt>\d+)")
1698
+ _FIX_NAME_RE = re.compile(
1699
+ r"fix-(?P<slug>[a-z0-9-]+)-pr(?P<number>\d+)-r(?P<round>\d+)-a(?P<attempt>\d+)"
1700
+ )
1701
+ _MAINTAIN_NAME_RE = re.compile(r"maintain-(?P<slug>[a-z0-9-]+)-pr(?P<number>\d+)-a(?P<attempt>\d+)")
1702
+
1703
+ # The three edges' names for themselves, as they appear in a session name and
1704
+ # in the reaper's log evidence.
1705
+ EDGE_DEVELOP = "develop"
1706
+ EDGE_FIX = "fix"
1707
+ EDGE_MAINTAIN = "maintain"
1708
+
1709
+
1710
+ @dataclass(frozen=True, slots=True)
1711
+ class WorkerSession:
1712
+ """One tmux session name parsed back into the anchor it works on.
1713
+
1714
+ The reaper's admission ticket AND its anchor resolver in one object: a
1715
+ name that parses is provably one of devloop's own workers (see
1716
+ `parse_worker_session`), and what it parses INTO -- the repo slug, the
1717
+ issue or PR number -- is the anchor whose terminal state decides whether
1718
+ the session still has a reason to exist. The `slug` is the NAME slug
1719
+ (`<owner>-<repo>` squashed by `_name_slug`), not an `owner/repo`: the
1720
+ squash is lossy (`a/b-c` and `a-b/c` both slug to `a-b-c`), so turning
1721
+ it back into a repo is the allowlist's job, never a string split's.
1722
+ """
1723
+
1724
+ name: str
1725
+ edge: str
1726
+ slug: str
1727
+ number: int
1728
+ attempt: int
1729
+ round_: "int | None" = None
1730
+
1731
+ @property
1732
+ def is_issue(self) -> bool:
1733
+ """Only the develop edge anchors on an issue; both PR edges do not."""
1734
+ return self.edge == EDGE_DEVELOP
1735
+
1736
+ @property
1737
+ def anchor(self) -> str:
1738
+ """The anchor as log evidence: `acme-widgets issue #7`. Every reap and
1739
+ every busy holdout line carries it -- a kill with no stated reason is
1740
+ not auditable after the fact, which is the whole complaint against the
1741
+ self-kill this sweep backstops."""
1742
+ return f"{self.slug} {'issue' if self.is_issue else 'PR'} #{self.number}"
1743
+
1744
+
1745
+ def parse_worker_session(name: str) -> "WorkerSession | None":
1746
+ """A tmux session name as one of devloop's own workers, or None.
1747
+
1748
+ THE guard on everything the reaper touches. The container is shared --
1749
+ reviewloop's sessions, other instances' lanes, operator shells -- so
1750
+ "is this mine?" is answered by a full-match against the grammar the three
1751
+ session-name builders write (`session_name`, `fix_session_name`,
1752
+ `maintain_session_name`), never by a prefix: `develop-` matches anything
1753
+ an operator felt like naming that way, and this code kills processes.
1754
+ Non-matching names are not spared after a check; they are never
1755
+ CONSIDERED, which is the property the tests pin.
1756
+ """
1757
+ match = _DEVELOP_NAME_RE.fullmatch(name)
1758
+ if match:
1759
+ return WorkerSession(
1760
+ name=name,
1761
+ edge=EDGE_DEVELOP,
1762
+ slug=match["slug"],
1763
+ number=int(match["number"]),
1764
+ attempt=int(match["attempt"]),
1765
+ )
1766
+ match = _FIX_NAME_RE.fullmatch(name)
1767
+ if match:
1768
+ return WorkerSession(
1769
+ name=name,
1770
+ edge=EDGE_FIX,
1771
+ slug=match["slug"],
1772
+ number=int(match["number"]),
1773
+ attempt=int(match["attempt"]),
1774
+ round_=int(match["round"]),
1775
+ )
1776
+ match = _MAINTAIN_NAME_RE.fullmatch(name)
1777
+ if match:
1778
+ return WorkerSession(
1779
+ name=name,
1780
+ edge=EDGE_MAINTAIN,
1781
+ slug=match["slug"],
1782
+ number=int(match["number"]),
1783
+ attempt=int(match["attempt"]),
1784
+ )
1785
+ return None
1786
+
1787
+
1788
+ def _casefolded(repo: "tuple[str, str]") -> "tuple[str, str]":
1789
+ """An (owner, repo) pair as `Config.watches` compares it: casefolded."""
1790
+ return (repo[0].casefold(), repo[1].casefold())
1791
+
1792
+
1793
+ def _slug_index(repos: "tuple[str, ...]") -> "dict[str, tuple[str, str]]":
1794
+ """`{name slug: (owner, repo)}` over the repos allowlist -- the reverse of
1795
+ `_name_slug`, which is the only way a session NAME can name a repo.
1796
+
1797
+ The squash is lossy -- `a/b-c` and `a-b/c` both slug to `a-b-c`, and a
1798
+ squashed character can manufacture the same clash (`acme/wid.gets` and
1799
+ `acme/wid-gets` both slug to `acme-wid-gets`, and a dot in a repo name is
1800
+ ordinary) -- so an allowlist that collides two repos onto one slug leaves
1801
+ that slug UNRESOLVABLE rather than guessing: the
1802
+ consumer is a reaper, and killing the right session for the wrong repo's
1803
+ reason is exactly the mistake worth failing closed on. Entries that are
1804
+ not `owner/repo` are ignored -- `Config.watches` would never match them
1805
+ either.
1806
+
1807
+ Two spellings of the SAME repo are not a collision. `Config.watches`
1808
+ matches casefolded ("GitHub owner/repo names are case-insensitive"), so
1809
+ `["Acme/Widgets", "acme/widgets"]` is one watched repo everywhere else in
1810
+ the daemon; comparing raw here would fail closed on a duplicate that does
1811
+ not exist, silently turning the name-anchored reap off for that repo and
1812
+ sending an operator looking for a second one.
1813
+ """
1814
+ index: dict[str, tuple[str, str]] = {}
1815
+ collided: set[str] = set()
1816
+ for entry in repos:
1817
+ owner, sep, repo = entry.partition("/")
1818
+ if not sep or not owner or not repo:
1819
+ continue
1820
+ slug = _name_slug(owner, repo)
1821
+ seen = index.get(slug)
1822
+ if seen is not None and _casefolded(seen) != _casefolded((owner, repo)):
1823
+ collided.add(slug)
1824
+ index[slug] = (owner, repo)
1825
+ for slug in collided:
1826
+ log.warning(
1827
+ "reaper: two allowlisted repos share the session-name slug %r — "
1828
+ "sessions carrying it cannot be resolved to a repo from their "
1829
+ "name and will only be reaped when the ledger knows them",
1830
+ slug,
1831
+ )
1832
+ index.pop(slug, None)
1833
+ return index
1834
+
1835
+
1643
1836
  def orphan_activity_lane(pr: PullRequest) -> str:
1644
1837
  """`orphan-<owner>-<repo>-pr<n>` — the name the orphan sweep signs its
1645
1838
  activity line with.
@@ -1787,6 +1980,10 @@ class DevWatcher:
1787
1980
  # The reaper's warn-once latch for a CLI without `tmux kill` (kills
1788
1981
  # are best-effort; the condition is per-process, not per-session).
1789
1982
  self._kill_warned = False
1983
+ # The reaper's name-slug -> (owner, repo) index (see `_anchor_repo`),
1984
+ # built once from the frozen allowlist rather than per session per
1985
+ # sweep.
1986
+ self._slug_repos = _slug_index(config.repos)
1790
1987
 
1791
1988
  # -- per-issue decision ------------------------------------------------
1792
1989
 
@@ -4888,22 +5085,14 @@ class DevWatcher:
4888
5085
 
4889
5086
  # -- the session reaper ------------------------------------------------
4890
5087
 
4891
- # Worker-session name prefixes. The reaper SWEEPS the first two
4892
- # (`develop-*`/`fix-*` -- a maintenance session's terminal act is its own
4893
- # label removal + self-kill, and `_session_finished` has no maintain
4894
- # branch), but the max_sessions budget COUNTS all three: a live
4895
- # maintenance session consumes memory whether or not the sweep reaps it.
4896
- _REAP_PREFIXES = ("develop-", "fix-")
4897
- _WORKER_PREFIXES = ("develop-", "fix-", "maintain-")
4898
-
4899
5088
  def reap_finished(self) -> int:
4900
- """The sweep: kill idle, finished `develop-*`/`fix-*` sessions.
4901
- Returns how many were killed (or would be, under dry-run).
5089
+ """The sweep: kill idle, finished worker sessions whose anchor is
5090
+ terminal. Returns how many were killed (or would be, under dry-run).
4902
5091
 
4903
5092
  Lists the managed sessions itself and delegates the per-session work
4904
5093
  to `_reap_sessions`. `poll_once` takes a different path -- it lists
4905
- ONCE and feeds that same listing to both the sweep and the
4906
- max_sessions count -- so the tmux CLI is hit exactly once per pass;
5094
+ ONCE and feeds that same listing to the sweep, the max_sessions count,
5095
+ and the cap check -- so the tmux CLI is hit exactly once per pass;
4907
5096
  this standalone entry point stays for callers (and tests) that just
4908
5097
  want a sweep. See `_reap_sessions` for the finished-detection
4909
5098
  contract."""
@@ -4913,41 +5102,72 @@ class DevWatcher:
4913
5102
  log.warning("reaper: could not list sessions (%s) — skipping "
4914
5103
  "this sweep", exc)
4915
5104
  return 0
4916
- reaped, _ = self._reap_sessions(sessions)
4917
- return reaped
4918
-
4919
- def _reap_sessions(self, sessions: "list[dict]") -> "tuple[int, set[str]]":
4920
- """Kill the idle, finished `develop-*`/`fix-*` sessions in `sessions`.
4921
- Returns (count reaped, the set of reaped names) -- the name set lets
4922
- `poll_once` discount them from the live-session count so a sweep frees
4923
- max_sessions capacity in the same pass.
4924
-
4925
- Runs off the tmux list and the local ledgers alone -- never the polls'
4926
- search results (the reviewer daemon's `_reap_finished` starved exactly
4927
- there: keyed off the search, it became unreachable once the trigger
4928
- state cleared, which is when sessions finish). "Finished" needs a
4929
- positive signal per session: the issue closed, the PR closed/merged,
4930
- or a newer attempt/round on the ledger superseded it. Everything else
4931
- is spared -- busy sessions, fresh in-flight ones (row younger than
4932
- stale_minutes, the same grace the retry logic uses), sessions this
4933
- ledger does not know (another workspace's, or the documented
4934
- enqueue-before-ledger crash window), and sessions whose GitHub check
4935
- fails this pass. Kills go through `alissa tmux kill <name>` one session
4936
- at a time (never the server) and are best-effort; dry-run only logs
4937
- (and still counts the would-kill, so the freed capacity matches what a
4938
- live pass would free)."""
5105
+ return self._reap_sessions(sessions).reaped
5106
+
5107
+ def _reap_sessions(self, sessions: "list[dict]") -> Sweep:
5108
+ """Kill the idle, finished worker sessions in `sessions`, and report
5109
+ what the sweep saw (see `Sweep`) -- the reaped NAMES let `poll_once`
5110
+ discount them from the live-session count, so a sweep frees
5111
+ max_sessions capacity in the same pass it runs.
5112
+
5113
+ Runs off the tmux list, the local ledgers, and GitHub alone -- never
5114
+ the polls' search results (the reviewer daemon's `_reap_finished`
5115
+ starved exactly there: keyed off the search, it became unreachable
5116
+ once the trigger state cleared, which is when sessions finish).
5117
+
5118
+ Three conditions, ALL required (see the module docstring):
5119
+
5120
+ * the name parses as one of devloop's own workers
5121
+ (`parse_worker_session`); anything else is never considered;
5122
+ * the anchor is TERMINAL -- the issue closed, the PR closed/merged, or
5123
+ the ledger shows a newer attempt/round superseding this session;
5124
+ * the session is IDLE and its last tmux activity is older than
5125
+ `reap_grace_minutes`.
5126
+
5127
+ Everything else is spared: busy sessions (logged as holdouts once the
5128
+ anchor is known terminal -- never killed, whatever the anchor says),
5129
+ sessions still inside the grace, sessions whose slug matches no
5130
+ allowlisted repo AND no ledger row, and sessions whose GitHub check
5131
+ fails this pass. The idle-and-still-in-grace check is deliberately
5132
+ cheap and runs BEFORE the anchor fetch, so a quiet machine's fresh
5133
+ sessions cost no API calls; a BUSY session does pay the fetch, because
5134
+ the holdout log has to state the anchor evidence it is holding out on.
5135
+ Kills go through `alissa tmux kill <name>` one session at a time
5136
+ (never `kill-server` -- the container is shared) and are best-effort;
5137
+ dry-run only logs (and still counts the would-kill, so the freed
5138
+ capacity matches what a live pass would free)."""
4939
5139
  reaped = 0
4940
5140
  reaped_names: set[str] = set()
5141
+ holdouts: list[str] = []
4941
5142
  for entry in sessions:
4942
5143
  name = str(entry.get("name") or "")
4943
- if not name.startswith(self._REAP_PREFIXES):
5144
+ worker = parse_worker_session(name)
5145
+ if worker is None:
5146
+ # Not devloop's grammar: another daemon's lane, an operator
5147
+ # shell, a console. Never touched, never even looked up.
4944
5148
  continue
4945
5149
  if entry.get("live") is False:
4946
5150
  # A registered-but-gone session has no tmux session to kill;
4947
5151
  # `alissa tmux cleanup` owns purging stale registrations.
4948
5152
  continue
5153
+ idle = entry.get("status") == "idle"
5154
+ quiet_for = self._session_quiet_for(entry, worker)
5155
+ graced = (
5156
+ quiet_for is not None
5157
+ and quiet_for >= self.config.reap_grace_seconds
5158
+ )
5159
+ if idle and not graced:
5160
+ log.debug(
5161
+ "reaper: %s is idle but inside the grace (%s of %d min) "
5162
+ "— spared",
5163
+ name,
5164
+ "unknown activity" if quiet_for is None
5165
+ else f"quiet {int(quiet_for / 60)} min",
5166
+ self.config.reap_grace_seconds // 60,
5167
+ )
5168
+ continue
4949
5169
  try:
4950
- verdict = self._session_finished(name)
5170
+ verdict = self._session_finished(worker)
4951
5171
  except CommandError as exc:
4952
5172
  log.warning(
4953
5173
  "reaper: could not cross-reference %s (%s) — spared "
@@ -4956,14 +5176,25 @@ class DevWatcher:
4956
5176
  continue
4957
5177
  if verdict is None:
4958
5178
  continue
4959
- if entry.get("status") != "idle":
4960
- log.debug(
4961
- "reaper: %s is finished (%s) but not idle (%s) — spared",
4962
- name, verdict, entry.get("status"),
5179
+ if not idle:
5180
+ # The locked guardrail: a terminal anchor never licenses
5181
+ # killing a WORKING session. Post-merge close-out is real work
5182
+ # (the PR #275 -> #279 replay-onto-main incident happened in
5183
+ # this window), so this is logged as evidence for a human, at
5184
+ # a level an operator sees without -v, and left alone.
5185
+ log.warning(
5186
+ "reaper: HOLDOUT %s — anchor %s is terminal (%s) but the "
5187
+ "session is %s, not idle; never killing a busy session",
5188
+ name, worker.anchor, verdict, entry.get("status"),
4963
5189
  )
5190
+ holdouts.append(name)
4964
5191
  continue
4965
5192
  if self.config.dry_run:
4966
- log.info("[dry-run] would kill session %s (%s)", name, verdict)
5193
+ log.info(
5194
+ "[dry-run] would kill session %s (anchor %s: %s; quiet "
5195
+ "%d min)",
5196
+ name, worker.anchor, verdict, int((quiet_for or 0) / 60),
5197
+ )
4967
5198
  reaped += 1
4968
5199
  reaped_names.add(name)
4969
5200
  continue
@@ -4981,25 +5212,65 @@ class DevWatcher:
4981
5212
  else:
4982
5213
  log.debug("reaper: could not kill %s (%s)", name, exc)
4983
5214
  continue
4984
- log.info("reaper: killed finished session %s (%s)", name, verdict)
5215
+ log.info(
5216
+ "reaper: killed finished session %s (anchor %s: %s; quiet "
5217
+ "%d min)",
5218
+ name, worker.anchor, verdict, int((quiet_for or 0) / 60),
5219
+ )
4985
5220
  reaped += 1
4986
5221
  reaped_names.add(name)
4987
- return reaped, reaped_names
5222
+ return Sweep(reaped=reaped, names=reaped_names, holdouts=tuple(holdouts))
5223
+
5224
+ def _session_quiet_for(
5225
+ self, entry: "dict", worker: WorkerSession
5226
+ ) -> "float | None":
5227
+ """Seconds since this session last did anything, or None when nothing
5228
+ can date it.
5229
+
5230
+ `alissa tmux ls --json` carries `lastActivity` (unix seconds) per
5231
+ session -- the signal the grace period is actually about: a session
5232
+ that is still typing is not a corpse however old its spawn is. When
5233
+ the listing does not carry one (an older CLI), the spawn ledger's row
5234
+ is the fallback lower bound, which is the clock every other
5235
+ presumed-dead decision in this loop already runs on. Neither
5236
+ available means the sweep CANNOT date the session, and `None` reads as
5237
+ "no grace has provably elapsed" at the call site -- never kill what
5238
+ cannot be dated. Clamped at 0 like every other age here: `lastActivity`
5239
+ is wall-clock and an NTP step must not read as a long-quiet session."""
5240
+ stamp = entry.get("lastActivity")
5241
+ last: "int | None"
5242
+ try:
5243
+ last = int(stamp) # type: ignore[arg-type] # non-numeric -> except
5244
+ except (TypeError, ValueError):
5245
+ last = None
5246
+ if last is None:
5247
+ row = (
5248
+ self.state.spawn_for_session(worker.name)
5249
+ or self.state.fix_spawn_for_session(worker.name)
5250
+ or self.state.maintain_spawn_for_session(worker.name)
5251
+ )
5252
+ last = int(row["spawned_at"]) if row is not None else None
5253
+ if last is None:
5254
+ return None
5255
+ return max(0.0, time.time() - last)
4988
5256
 
4989
5257
  def _count_active_sessions(
4990
5258
  self, sessions: "list[dict]", reaped_names: "set[str]"
4991
5259
  ) -> int:
4992
5260
  """How many worker sessions are live and NOT reaped this pass -- the
4993
- `active` half of the max_sessions budget. Counts every
4994
- `develop-*`/`fix-*`/`maintain-*` session the CLI reports as live
4995
- (busy AND fresh in-flight ones alike -- both are holding memory),
4996
- minus the ones the sweep just killed (a registered-but-gone `live:
4997
- False` row is already dead, so it never counted). Read from the SAME
4998
- listing the sweep used, so counting adds no CLI call."""
5261
+ `active` half of the max_sessions budget, and the number the post-sweep
5262
+ cap check judges. Counts every session whose name parses as one of
5263
+ devloop's own workers (`parse_worker_session` -- the same grammar the
5264
+ sweep admits, so the budget and the reaper can never disagree about
5265
+ what a worker is), live, busy AND fresh in-flight ones alike (both are
5266
+ holding memory), minus the ones the sweep just killed (a
5267
+ registered-but-gone `live: False` row is already dead, so it never
5268
+ counted). Read from the SAME listing the sweep used, so counting adds
5269
+ no CLI call."""
4999
5270
  active = 0
5000
5271
  for entry in sessions:
5001
5272
  name = str(entry.get("name") or "")
5002
- if not name.startswith(self._WORKER_PREFIXES):
5273
+ if parse_worker_session(name) is None:
5003
5274
  continue
5004
5275
  if entry.get("live") is False:
5005
5276
  continue
@@ -5034,27 +5305,64 @@ class DevWatcher:
5034
5305
  "reads INDETERMINATE (no resume can fire)", exc,
5035
5306
  )
5036
5307
  return 0, SessionBudget(None, 0), SessionLiveness(None)
5037
- reaped, reaped_names = self._reap_sessions(sessions)
5038
- active = self._count_active_sessions(sessions, reaped_names)
5308
+ sweep = self._reap_sessions(sessions)
5309
+ active = self._count_active_sessions(sessions, sweep.names)
5310
+ self._check_session_cap(active, sweep)
5039
5311
  return (
5040
- reaped,
5312
+ sweep.reaped,
5041
5313
  SessionBudget(limit, active),
5042
- SessionLiveness(sessions, reaped_names),
5314
+ SessionLiveness(sessions, sweep.names),
5315
+ )
5316
+
5317
+ def _check_session_cap(self, active: int, sweep: Sweep) -> None:
5318
+ """Page-worthy log when the machine is STILL over `max_sessions` after
5319
+ the sweep.
5320
+
5321
+ The sweep is bounded by design -- it only kills idle sessions with
5322
+ terminal anchors -- so it cannot be the answer to every kind of
5323
+ overload. Sessions that stay over the cap after it ran are, by
5324
+ elimination, busy ones (holdouts and live work) or sessions no anchor
5325
+ marks terminal: the daemon has done everything it may safely do and
5326
+ the excess is a human's call. Logged at ERROR for exactly that reason,
5327
+ with the holdouts named, because that list is usually the answer.
5328
+ `max_sessions=0` (unlimited, the default) configures no cap, so there
5329
+ is nothing to exceed and nothing to say."""
5330
+ limit = self.config.max_sessions
5331
+ if not limit or active <= limit:
5332
+ return
5333
+ log.error(
5334
+ "SESSION CAP EXCEEDED: %d live worker session(s) after the sweep, "
5335
+ "over max_sessions=%d. The reaper only kills IDLE sessions whose "
5336
+ "anchor is terminal, so the excess is busy or unfinished work and "
5337
+ "no sweep will clear it — a human should look%s",
5338
+ active,
5339
+ limit,
5340
+ (
5341
+ "; busy-but-terminal holdout(s) this pass: "
5342
+ + ", ".join(sweep.holdouts)
5343
+ if sweep.holdouts else " (no busy-but-terminal holdouts this "
5344
+ "pass — the live sessions are on open anchors)"
5345
+ ),
5043
5346
  )
5044
5347
 
5045
- def _session_finished(self, name: str) -> "str | None":
5348
+ def _session_finished(self, worker: WorkerSession) -> "str | None":
5046
5349
  """Why this worker session is finished, or None to spare it.
5047
5350
 
5048
- The cheap checks run first: the ledger lookup (no match -> spare:
5049
- not provably ours), the fresh-in-flight grace (no GitHub call for a
5050
- session younger than stale_minutes -- it may still be initializing,
5051
- and a finished one waits at most one stale window for the sweep),
5052
- and ledger supersession (a newer attempt/round exists, so this
5053
- session's episode is over whatever GitHub says). Only a stale,
5054
- non-superseded session costs a GitHub fetch, whose closed/merged
5055
- state is the terminal signal. CommandError propagates to the caller
5056
- (spare this pass); RateLimited propagates further out to
5351
+ Two resolutions of one question -- is this session's anchor terminal?
5352
+ The LEDGER answers first when it has a row, because a row carries what
5353
+ a name cannot: supersession (a newer attempt, a newer review round
5354
+ ends this session's episode whatever GitHub says) and the spawn
5355
+ instant that keeps a just-enqueued session off the GitHub path
5356
+ entirely. With no row -- a fresh state volume, a migrated deployment,
5357
+ another workspace's ledger, or the enqueue-before-ledger crash window
5358
+ -- the session NAME answers instead (`_anchor_finished`), which is the
5359
+ path that stops ledger-less corpses idling forever.
5360
+
5361
+ Only a stale, non-superseded session costs a GitHub fetch, whose
5362
+ closed/merged state is the terminal signal. CommandError propagates to
5363
+ the caller (spare this pass); RateLimited propagates further out to
5057
5364
  run_forever's backoff, like every other GitHub call in the pass."""
5365
+ name = worker.name
5058
5366
  row = self.state.spawn_for_session(name)
5059
5367
  if row is not None:
5060
5368
  slug, number = row["repo_slug"], row["issue"]
@@ -5086,11 +5394,56 @@ class DevWatcher:
5086
5394
  return "PR closed/merged"
5087
5395
  return None
5088
5396
 
5089
- log.debug(
5090
- "reaper: %s matches a worker name but is not on this ledger — "
5091
- "spared (another workspace's session, or the "
5092
- "enqueue-before-ledger crash window)", name,
5093
- )
5397
+ row = self.state.maintain_spawn_for_session(name)
5398
+ if row is not None:
5399
+ slug, number = row["repo_slug"], row["number"]
5400
+ if time.time() - row["spawned_at"] < self.config.stale_minutes * 60:
5401
+ return None
5402
+ # One supersession axis only: the maintenance edge has no rounds,
5403
+ # and `maintain_max_attempt` is deliberately lifetime-scoped (a
5404
+ # re-label resets the budget, not the numbering), so an older
5405
+ # attempt's session is over whichever request spawned the newer.
5406
+ newest = self.state.maintain_max_attempt(slug, number)
5407
+ if row["attempt"] < newest:
5408
+ return f"superseded by attempt {newest}"
5409
+ owner, _, repo = slug.partition("/")
5410
+ if self.github.pull_request(owner, repo, number).state != "open":
5411
+ return "PR closed/merged"
5412
+ return None
5413
+
5414
+ return self._anchor_finished(worker)
5415
+
5416
+ def _anchor_finished(self, worker: WorkerSession) -> "str | None":
5417
+ """The ledger-free half of `_session_finished`: is the anchor named by
5418
+ the SESSION NAME terminal?
5419
+
5420
+ The name is not a weaker signal than a ledger row, it is a different
5421
+ one -- it survives the ledger. The 2026-07-28 fleet incident was
5422
+ exactly this shape: idle workers whose PRs had merged hours earlier,
5423
+ with nothing local left to explain them. What the name cannot do is
5424
+ name a REPO (`_name_slug` is lossy), so the slug is resolved through
5425
+ the repos allowlist (`_slug_index`); a slug matching no watched repo
5426
+ is another workspace's or another owner's and is spared, never
5427
+ guessed at. Supersession is unavailable here by construction -- it is
5428
+ a ledger fact -- so this path reaps on the anchor alone, which is the
5429
+ conservative half anyway: a closed issue and a merged PR have no
5430
+ pending work for anyone."""
5431
+ repo = self._slug_repos.get(worker.slug)
5432
+ if repo is None:
5433
+ log.debug(
5434
+ "reaper: %s parses as a worker but its slug %r matches no "
5435
+ "watched repo and no ledger row — spared (another "
5436
+ "workspace's session, or a repo this daemon does not watch)",
5437
+ worker.name, worker.slug,
5438
+ )
5439
+ return None
5440
+ owner, name = repo
5441
+ if worker.is_issue:
5442
+ if self.github.issue(owner, name, worker.number).state != "open":
5443
+ return "issue closed (anchor read from the session name)"
5444
+ return None
5445
+ if self.github.pull_request(owner, name, worker.number).state != "open":
5446
+ return "PR closed/merged (anchor read from the session name)"
5094
5447
  return None
5095
5448
 
5096
5449
  # -- polling -----------------------------------------------------------
@@ -395,6 +395,19 @@ class State:
395
395
  (session,),
396
396
  ).fetchone()
397
397
 
398
+ def maintain_spawn_for_session(self, session: str) -> "sqlite3.Row | None":
399
+ """maintain_spawns counterpart of spawn_for_session: the row behind a
400
+ maintenance session name (repo_slug, PR number, attempt), or None when
401
+ the name is not on this ledger. The third of the reaper's three ledger
402
+ lookups -- a maintenance session holds as much memory as any other
403
+ worker, so the sweep resolves its anchor the same way."""
404
+ return self._db.execute(
405
+ "SELECT repo_slug, number, attempt, spawned_at "
406
+ "FROM maintain_spawns WHERE session=? "
407
+ "ORDER BY spawned_at DESC LIMIT 1",
408
+ (session,),
409
+ ).fetchone()
410
+
398
411
  def max_fix_round(self, repo_slug: str, number: int) -> int:
399
412
  """The newest round any fix session was spawned for on this PR (0
400
413
  when none) -- the reaper's supersession horizon: a fix session whose
@@ -1,3 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: alissa-tools-github-devloop
3
+ Version: 0.8.2
4
+ Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
+ Home-page: https://alissa.app
6
+ Author: Fahera
7
+ Author-email: support@alissa.app
8
+ License: Apache-2.0
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ License-File: NOTICE
13
+ Dynamic: author
14
+ Dynamic: author-email
15
+ Dynamic: description
16
+ Dynamic: description-content-type
17
+ Dynamic: home-page
18
+ Dynamic: license
19
+ Dynamic: license-file
20
+ Dynamic: requires-python
21
+ Dynamic: summary
22
+
1
23
  # alissa-tools-github-devloop
2
24
 
3
25
  The `alissa.tools.github.devloop` module: a GitHub watcher that turns
@@ -42,3 +64,15 @@ pip install -e ./alissa-tools-github-devloop
42
64
  distribution version lives in the plain-text `version` file next to the module
43
65
  it versions (`src/main/alissa/tools/github/devloop/version`), read by both
44
66
  `setup.py` and `version.py`.
67
+
68
+ ## License
69
+
70
+ Licensed under the
71
+ [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0) — the
72
+ `LICENSE` and `NOTICE` files shipped with this distribution carry the full text
73
+ and the attribution and trademark terms. Both ship inside the sdist and the
74
+ wheel's `.dist-info`.
75
+
76
+ "Alissa" and "Fahera" are trademarks of CORE FAHERA ENTERPRISE HOLDINGS
77
+ S. DE R.L. and are not covered by the license — the grant covers the code, not
78
+ the names.
@@ -1,4 +1,6 @@
1
+ LICENSE
1
2
  MANIFEST.in
3
+ NOTICE
2
4
  README.md
3
5
  requirements.txt
4
6
  setup.py