alissa-tools-github-devloop 0.8.1__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.1/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.2}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/__main__.py +13 -2
  3. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/config.py +34 -0
  4. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/loop.py +438 -85
  5. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/state.py +13 -0
  6. alissa_tools_github_devloop-0.8.2/src/main/alissa/tools/github/devloop/version +1 -0
  7. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  8. alissa_tools_github_devloop-0.8.1/src/main/alissa/tools/github/devloop/version +0 -1
  9. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/LICENSE +0 -0
  10. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/MANIFEST.in +0 -0
  11. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/NOTICE +0 -0
  12. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/README.md +0 -0
  13. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/requirements.txt +0 -0
  14. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/setup.cfg +0 -0
  15. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/setup.py +0 -0
  16. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  17. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  18. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  19. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  20. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/version.py +0 -0
  21. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  22. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  23. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  24. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  25. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  26. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  27. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  28. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  29. {alissa_tools_github_devloop-0.8.1 → 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.1 → 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.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.1
3
+ Version: 0.8.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.1
3
+ Version: 0.8.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera