alissa-tools-github-revloop 0.31.18__tar.gz → 0.31.20__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 (40) hide show
  1. {alissa_tools_github_revloop-0.31.18/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.20}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/config.py +29 -1
  3. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/janitor.py +199 -41
  4. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/loop.py +137 -22
  5. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/loop_events.py +59 -1
  6. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/prompts.py +5 -2
  7. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/trust.py +10 -1
  8. alissa_tools_github_revloop-0.31.20/src/main/alissa/tools/github/revloop/version +1 -0
  9. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  10. alissa_tools_github_revloop-0.31.18/src/main/alissa/tools/github/revloop/version +0 -1
  11. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/LICENSE +0 -0
  12. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/MANIFEST.in +0 -0
  13. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/NOTICE +0 -0
  14. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/README.md +0 -0
  15. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/requirements.txt +0 -0
  16. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/setup.cfg +0 -0
  17. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/setup.py +0 -0
  18. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  19. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  20. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  21. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  22. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  23. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  24. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  25. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/parking.py +0 -0
  26. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  27. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  28. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/state.py +0 -0
  29. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/version.py +0 -0
  30. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  31. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  32. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  33. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  34. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  35. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  36. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  37. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  38. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  39. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  40. {alissa_tools_github_revloop-0.31.18 → alissa_tools_github_revloop-0.31.20}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.18
3
+ Version: 0.31.20
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -211,6 +211,7 @@ CONFIG_KEYS = (
211
211
  "janitor_grace_minutes",
212
212
  "janitor_interval_minutes",
213
213
  "janitor_warn_pct",
214
+ "janitor_pressure_grace_minutes",
214
215
  "alissa_endpoint",
215
216
  "dry_run",
216
217
  )
@@ -245,6 +246,8 @@ JANITOR_ENABLED_ENV = "ALISSA_REVIEW_JANITOR_ENABLED"
245
246
  JANITOR_GRACE_MINUTES_ENV = "ALISSA_REVIEW_JANITOR_GRACE_MINUTES"
246
247
  JANITOR_INTERVAL_MINUTES_ENV = "ALISSA_REVIEW_JANITOR_INTERVAL_MINUTES"
247
248
  JANITOR_WARN_PCT_ENV = "ALISSA_REVIEW_JANITOR_WARN_PCT"
249
+ # The fifth (issue #177): the grace under disk pressure.
250
+ JANITOR_PRESSURE_GRACE_MINUTES_ENV = "ALISSA_REVIEW_JANITOR_PRESSURE_GRACE_MINUTES"
248
251
 
249
252
  # The plan directive's two allowlists (issue #148; genloop design §6.1, D12).
250
253
  # A PR gets the PLAN directive only when all four signals hold, and two of
@@ -453,6 +456,10 @@ def env_janitor(environ: "Mapping[str, str] | None" = None) -> "dict[str, Any]":
453
456
  ("janitor_grace_minutes", JANITOR_GRACE_MINUTES_ENV, "number of minutes"),
454
457
  ("janitor_interval_minutes", JANITOR_INTERVAL_MINUTES_ENV, "number of minutes"),
455
458
  ("janitor_warn_pct", JANITOR_WARN_PCT_ENV, "percentage"),
459
+ (
460
+ "janitor_pressure_grace_minutes", JANITOR_PRESSURE_GRACE_MINUTES_ENV,
461
+ "number of minutes",
462
+ ),
456
463
  ):
457
464
  value = _env_int(name, what, environ)
458
465
  if value is not None:
@@ -731,6 +738,14 @@ DEFAULT_JANITOR_INTERVAL_MINUTES = 10
731
738
  # WARNING. Devloop's threshold; 0 = never warn.
732
739
  DEFAULT_JANITOR_WARN_PCT = 80
733
740
 
741
+ # The grace UNDER PRESSURE (issue #177): while the volume is at or above
742
+ # `janitor_warn_pct`, a folder older than this many minutes is removable too,
743
+ # oldest first, until the volume is back under the line. Two hours: past the
744
+ # stale-round window (90 minutes), so a round that could still want its
745
+ # folder has ended -- and the live-session / open-round guard holds the rest.
746
+ # On 2026-10-18 a day's grace kept 119 dead checkouts on a full volume.
747
+ DEFAULT_JANITOR_PRESSURE_GRACE_MINUTES = 120
748
+
734
749
 
735
750
  # -- the product-stability guard (issue #105) ---------------------------------
736
751
  #
@@ -1057,11 +1072,14 @@ class Config:
1057
1072
  # `janitor_grace_minutes` that no live session or open ledger round holds,
1058
1073
  # prune `.source`'s worktree records, and WARN once when the workspace
1059
1074
  # volume is at or above `janitor_warn_pct` (0 = never). ON by default: the
1060
- # disk it reclaims fills a shared volume within weeks without it.
1075
+ # disk it reclaims fills a shared volume within weeks without it. At or
1076
+ # above that line the grace drops to `janitor_pressure_grace_minutes`
1077
+ # (issue #177), oldest first, until the volume is back under it.
1061
1078
  janitor_enabled: bool = True
1062
1079
  janitor_grace_minutes: int = DEFAULT_JANITOR_GRACE_MINUTES
1063
1080
  janitor_interval_minutes: int = DEFAULT_JANITOR_INTERVAL_MINUTES
1064
1081
  janitor_warn_pct: int = DEFAULT_JANITOR_WARN_PCT
1082
+ janitor_pressure_grace_minutes: int = DEFAULT_JANITOR_PRESSURE_GRACE_MINUTES
1065
1083
 
1066
1084
  dry_run: bool = False
1067
1085
 
@@ -1560,6 +1578,15 @@ class Config:
1560
1578
  f"janitor_warn_pct must be between 0 and 100 (0 = never warn), "
1561
1579
  f"got {janitor_warn}"
1562
1580
  )
1581
+ janitor_pressure_grace = int(raw.get(
1582
+ "janitor_pressure_grace_minutes", cls.janitor_pressure_grace_minutes
1583
+ ))
1584
+ if janitor_pressure_grace < 1:
1585
+ # Same floor as the normal grace, for the same reason.
1586
+ raise ValueError(
1587
+ f"janitor_pressure_grace_minutes must be >= 1, "
1588
+ f"got {janitor_pressure_grace}"
1589
+ )
1563
1590
 
1564
1591
  state_path = raw.get("state_path")
1565
1592
  return cls(
@@ -1604,6 +1631,7 @@ class Config:
1604
1631
  janitor_grace_minutes=janitor_grace,
1605
1632
  janitor_interval_minutes=janitor_interval,
1606
1633
  janitor_warn_pct=janitor_warn,
1634
+ janitor_pressure_grace_minutes=janitor_pressure_grace,
1607
1635
  alissa_endpoint=endpoint,
1608
1636
  dry_run=bool(raw.get("dry_run", False)),
1609
1637
  )
@@ -11,7 +11,12 @@ and nothing removed either before this module:
11
11
  shared volume held 169 of them, 27 GB of a 46 GB volume.
12
12
  * `<hub>/REVIEW-<task ref>/` -- the throwaway checkout the review skill lets a
13
13
  reviewer create beside `main/` when it must run code (`trust.
14
- review_checkout_name`). Another ~5 GB on the same day.
14
+ review_checkout_name`). Another ~5 GB on the same day. A reviewer whose
15
+ shell sits in its spawn cwd makes it INSIDE `main/` instead
16
+ (`<hub>/main/REVIEW-<task ref>/`): on 2026-10-18 121 of those, 0.6-1.2 GB
17
+ apiece with their own `node_modules`, filled the fleet's 46 GB volume to
18
+ 100% while the janitor looked only beside `main/` (issue #177). Both
19
+ places are swept.
15
20
 
16
21
  Modeled on devloop's janitor.py, but smaller, because what it removes is
17
22
  smaller in kind: a reviewer's folders hold no unpushed work by construction
@@ -26,15 +31,26 @@ Three properties everything here is written around:
26
31
 
27
32
  * **Only the two shapes, only where they are made.** A scratch folder is a
28
33
  `.review-scratch-*` DIRECT child of `<hub>/main`, a review checkout a
29
- `REVIEW-*` direct child of the hub; symlinks are never candidates. Nothing
30
- else under a hub -- `main/`'s own files, `.source`, a task worktree, an
31
- operator's clone -- is ever considered, so it is never at risk.
34
+ `REVIEW-*` direct child of the hub or of `<hub>/main`; symlinks are never
35
+ candidates. Nothing else under a hub -- `main/`'s own files and its
36
+ `node_modules`, `.source`, a task worktree, an operator's clone -- is ever
37
+ considered, so it is never at risk. A review checkout that is a linked git
38
+ worktree is removed with `git worktree remove --force`, so its record goes
39
+ with it; anything else with `shutil.rmtree`.
32
40
  * **Old AND unheld.** Age is the newest mtime of the folder and its direct
33
41
  children (so a round that writes a fresh file into an old folder renews it),
34
42
  and the grace is a day by default. A held folder is kept whatever its age.
43
+ Under disk PRESSURE (issue #177) the grace drops -- to two hours by default
44
+ -- oldest first, only while the volume is still at or above its warn line.
35
45
  * **Nothing raises past its own concern.** A folder that vanishes mid-sweep,
36
46
  an unreadable hub, a failed removal: logged and skipped, because this runs
37
47
  at the end of a poll pass that must complete.
48
+
49
+ Two more bound it (issue #175). The layout is fixed: hubs are found at
50
+ `<root>/<repo>` with the session cwd at `<hub>/main`, so under any other
51
+ `hub_template` (`layout_supported` says no) the caller sweeps nothing rather
52
+ than mis-key the hold. And one sweep removes at most `MAX_REMOVALS` folders in
53
+ `MAX_SWEEP_SECONDS`; the rest wait for the next interval (`deferred`).
38
54
  """
39
55
 
40
56
  from __future__ import annotations
@@ -43,6 +59,7 @@ import logging
43
59
  import os
44
60
  import re
45
61
  import shutil
62
+ import time
46
63
  from dataclasses import dataclass, field
47
64
  from pathlib import Path
48
65
  from typing import Callable, Iterable
@@ -70,6 +87,23 @@ SOURCE_DIR = ".source"
70
87
  KIND_SCRATCH = "scratch"
71
88
  KIND_REVIEW = "review-checkout"
72
89
 
90
+ # One sweep's bound (issue #175): at most this many removals, and no removal
91
+ # started past this many seconds into the sweep. It runs synchronously at the
92
+ # end of a poll pass, so a large backlog is drained over several intervals
93
+ # instead of stalling one pass for minutes.
94
+ MAX_REMOVALS = 25
95
+ MAX_SWEEP_SECONDS = 120.0
96
+
97
+ # The one hub layout the janitor knows: `discover_hubs` reads the root's
98
+ # direct children and `scan_hub` reads `<hub>/main`. Named in the caller's
99
+ # refusal WARNING.
100
+ SUPPORTED_TEMPLATE = "{root}/{repo}/main"
101
+
102
+ # What `layout_supported` probes a template with: two owners that differ, so a
103
+ # template that puts the owner in the path resolves to two different places.
104
+ _PROBE_OWNERS = ("janitor-probe-owner-a", "janitor-probe-owner-b")
105
+ _PROBE_REPO = "janitor-probe-repo"
106
+
73
107
  Runner = Callable[..., str]
74
108
 
75
109
 
@@ -158,11 +192,44 @@ class SweepResult:
158
192
  fresh: int = 0
159
193
  failed: "list[tuple[Path, str]]" = field(default_factory=list)
160
194
  pruned: int = 0
195
+ # Unheld stale candidates the per-sweep bound left for the next interval.
196
+ deferred: int = 0
197
+ # Whether the volume was at or above its warn line when the sweep began,
198
+ # so the pressure grace applied (issue #177), and how many removals (or,
199
+ # in a dry run, would-be removals) only that shorter grace allowed.
200
+ pressure: bool = False
201
+ pressure_removed: int = 0
161
202
 
162
203
  @property
163
204
  def freed(self) -> int:
164
205
  return sum(size for _, size in self.removed)
165
206
 
207
+ @property
208
+ def left(self) -> int:
209
+ """Candidate folders still on disk after a live sweep: inside the
210
+ grace, held, deferred, or failed."""
211
+ return self.fresh + len(self.held) + self.deferred + len(self.failed)
212
+
213
+
214
+ def layout_supported(
215
+ hub_for: "Callable[[str, str], Path]", workspace_root: "Path | str"
216
+ ) -> bool:
217
+ """Whether `hub_for` (`Config.hub_for`) places every hub at
218
+ `<root>/<repo>` with the session cwd at `<hub>/main` -- the one layout
219
+ `discover_hubs` and `scan_hub` read, and the one in which a hub's
220
+ directory name is the repo slug the `Guard` keys on.
221
+
222
+ Probed with two distinct owners and one repo name: both must resolve to
223
+ `workspace_root / repo / "main"`. A template that cannot be formatted is
224
+ unsupported. Pure: nothing is read off the disk."""
225
+ expected = Path(os.fspath(workspace_root)).expanduser() / _PROBE_REPO / "main"
226
+ try:
227
+ return all(
228
+ Path(hub_for(owner, _PROBE_REPO)) == expected for owner in _PROBE_OWNERS
229
+ )
230
+ except (KeyError, IndexError, ValueError, AttributeError, TypeError):
231
+ return False
232
+
166
233
 
167
234
  def discover_hubs(workspace_root: "Path | str") -> "list[Path]":
168
235
  """Every worktree hub directly under the workspace root: a non-symlink
@@ -209,8 +276,9 @@ def _dirs(parent: Path, prefix: str) -> "list[Path]":
209
276
 
210
277
 
211
278
  def scan_hub(hub: Path) -> "list[Candidate]":
212
- """The janitor's candidates in one hub: `main/.review-scratch-*` and
213
- `REVIEW-*`, direct children only, never a walk."""
279
+ """The janitor's candidates in one hub: `main/.review-scratch-*`,
280
+ `REVIEW-*` and `main/REVIEW-*` (issue #177), direct children only, never
281
+ a walk."""
214
282
  found = []
215
283
  for path in _dirs(hub / "main", SCRATCH_PREFIX):
216
284
  match = _SCRATCH_PR_RE.match(path.name[len(SCRATCH_PREFIX):])
@@ -218,7 +286,9 @@ def scan_hub(hub: Path) -> "list[Candidate]":
218
286
  path=path, hub=hub, kind=KIND_SCRATCH,
219
287
  number=int(match["number"]) if match else None,
220
288
  ))
221
- for path in _dirs(hub, REVIEW_CHECKOUT_PREFIX):
289
+ for path in _dirs(hub, REVIEW_CHECKOUT_PREFIX) + _dirs(
290
+ hub / "main", REVIEW_CHECKOUT_PREFIX
291
+ ):
222
292
  found.append(Candidate(
223
293
  path=path, hub=hub, kind=KIND_REVIEW,
224
294
  task_ref=path.name[len(REVIEW_CHECKOUT_PREFIX):],
@@ -296,17 +366,53 @@ def disk_used_pct(path: "Path | str") -> "float | None":
296
366
 
297
367
  def _contained(candidate: Candidate) -> bool:
298
368
  """The containment rule, re-asserted at the removal site: the folder is
299
- a direct child of its hub (review checkout) or of `<hub>/main` (scratch),
300
- both resolved, and not a symlink."""
301
- parent = candidate.hub / "main" if candidate.kind == KIND_SCRATCH else candidate.hub
369
+ a direct child of `<hub>/main` (scratch) or of its hub or `<hub>/main`
370
+ (review checkout), all resolved, and not a symlink."""
371
+ main = candidate.hub / "main"
372
+ parents = (main,) if candidate.kind == KIND_SCRATCH else (candidate.hub, main)
302
373
  try:
303
374
  if candidate.path.is_symlink():
304
375
  return False
305
- return candidate.path.resolve().parent == parent.resolve()
376
+ parent = candidate.path.resolve().parent
377
+ return any(parent == allowed.resolve() for allowed in parents)
306
378
  except OSError:
307
379
  return False
308
380
 
309
381
 
382
+ def _is_linked_worktree(path: Path) -> bool:
383
+ """Whether `path` is a linked git worktree: its `.git` is a FILE (the
384
+ `gitdir:` pointer `git worktree add` writes), not a directory."""
385
+ try:
386
+ git = path / ".git"
387
+ return git.is_file() and not git.is_symlink()
388
+ except OSError:
389
+ return False
390
+
391
+
392
+ def _remove(candidate: Candidate, runner: Runner) -> None:
393
+ """Remove one contained candidate. A review checkout that is a linked
394
+ worktree goes through `git worktree remove --force` in its hub's
395
+ `.source` (issue #177), so the record and the folder go together; when
396
+ git refuses (a worktree of another repository, a locked one) or the
397
+ folder is anything else, `shutil.rmtree` removes it and the hub's prune
398
+ drops whatever record is left. Raises OSError when the folder stays."""
399
+ if candidate.kind == KIND_REVIEW and _is_linked_worktree(candidate.path):
400
+ try:
401
+ runner(
402
+ ["git", "-C", str(candidate.hub / SOURCE_DIR), "worktree",
403
+ "remove", "--force", str(candidate.path)],
404
+ timeout=120,
405
+ )
406
+ except (CommandError, OSError) as exc:
407
+ log.debug(
408
+ "janitor: git worktree remove refused %s (%s) — removing the "
409
+ "folder instead", candidate.path, exc,
410
+ )
411
+ if not os.path.lexists(candidate.path):
412
+ return
413
+ shutil.rmtree(candidate.path)
414
+
415
+
310
416
  def prune_worktrees(hub: Path, runner: Runner = run) -> None:
311
417
  """`git worktree prune` in the hub's `.source`, as this process's user
312
418
  (the seat's own): drops the records of worktrees whose folders are gone
@@ -323,48 +429,100 @@ def sweep(
323
429
  now: float,
324
430
  dry_run: bool = False,
325
431
  runner: Runner = run,
432
+ max_removals: int = MAX_REMOVALS,
433
+ max_seconds: float = MAX_SWEEP_SECONDS,
434
+ clock: "Callable[[], float]" = time.monotonic,
435
+ pressure_grace_seconds: "float | None" = None,
436
+ under_pressure: "Callable[[], bool] | None" = None,
326
437
  ) -> SweepResult:
327
438
  """Remove every candidate under `hubs` older than `grace_seconds` that
328
- `guard` does not hold, then prune each hub's worktree records.
439
+ `guard` does not hold, oldest first, then prune each hub's worktree
440
+ records.
441
+
442
+ Under pressure (issue #177): when `under_pressure` is given and answers
443
+ True at the start of the sweep, a candidate younger than `grace_seconds`
444
+ but older than `pressure_grace_seconds` is removable too -- but only
445
+ while `under_pressure()`, asked again before each such removal, still
446
+ answers True. Candidates are taken oldest first across every hub, so the
447
+ oldest go first and the sweep stops shrinking the grace the moment the
448
+ volume is back under its line. A held folder is never removed.
449
+
450
+ Bounded (issue #175): before each removal, a sweep that has already
451
+ removed `max_removals` folders, or has run `max_seconds` by `clock`,
452
+ stops removing. Every unheld stale candidate past that point is counted
453
+ in `deferred` and left for the next interval; every hub is still scanned
454
+ and pruned.
329
455
 
330
456
  Dry-run removes nothing and prunes nothing: it logs each folder it WOULD
331
- remove (with its size) and reports them in `would_remove`."""
457
+ remove (with its size) and reports them in `would_remove`, under the
458
+ same bound -- so it reports what one live sweep would do (a dry run frees
459
+ nothing, so under pressure it reports every pressure-eligible folder)."""
332
460
  result = SweepResult()
333
- for hub in hubs:
461
+ started = clock()
462
+ bounded = False
463
+ hub_list = list(hubs)
464
+ shrunk = grace_seconds
465
+ if pressure_grace_seconds is not None and under_pressure is not None:
466
+ result.pressure = under_pressure()
467
+ if result.pressure:
468
+ shrunk = min(grace_seconds, pressure_grace_seconds)
469
+ aged: "list[tuple[float, Candidate]]" = []
470
+ for hub in hub_list:
334
471
  result.hubs += 1
335
472
  for candidate in scan_hub(hub):
336
473
  mtime = newest_mtime(candidate.path)
337
474
  if mtime is None:
338
475
  continue # vanished mid-sweep
339
- if now - mtime < grace_seconds:
340
- result.fresh += 1
341
- continue
342
- why = guard.holds(candidate)
343
- if why is not None:
344
- log.debug("janitor: keeping %s — %s", candidate.path, why)
345
- result.held.append((candidate.path, why))
346
- continue
347
- if not _contained(candidate):
348
- result.failed.append((candidate.path, "not contained in its hub"))
349
- continue
350
- size = dir_bytes(candidate.path)
351
- if dry_run:
352
- log.info(
353
- "[dry-run] janitor would remove %s (%s, %.1f h old)",
354
- candidate.path, human(size), (now - mtime) / 3600,
355
- )
356
- result.would_remove.append((candidate.path, size))
357
- continue
358
- try:
359
- shutil.rmtree(candidate.path)
360
- except OSError as exc:
361
- log.warning("janitor: could not remove %s (%s)", candidate.path, exc)
362
- result.failed.append((candidate.path, str(exc)))
363
- continue
364
- log.debug("janitor: removed %s (%s)", candidate.path, human(size))
365
- result.removed.append((candidate.path, size))
476
+ aged.append((mtime, candidate))
477
+ aged.sort(key=lambda pair: (pair[0], str(pair[1].path)))
478
+ for mtime, candidate in aged:
479
+ age = now - mtime
480
+ if age < shrunk:
481
+ result.fresh += 1
482
+ continue
483
+ why = guard.holds(candidate)
484
+ if why is not None:
485
+ log.debug("janitor: keeping %s — %s", candidate.path, why)
486
+ result.held.append((candidate.path, why))
487
+ continue
488
+ early = age < grace_seconds
489
+ if early and not dry_run and not (under_pressure and under_pressure()):
490
+ # Back under the line: the normal grace holds again.
491
+ result.fresh += 1
492
+ continue
493
+ if not bounded:
494
+ taken = len(result.would_remove if dry_run else result.removed)
495
+ bounded = taken >= max_removals or clock() - started >= max_seconds
496
+ if bounded:
497
+ result.deferred += 1
498
+ continue
499
+ if not _contained(candidate):
500
+ result.failed.append((candidate.path, "not contained in its hub"))
501
+ continue
502
+ size = dir_bytes(candidate.path)
366
503
  if dry_run:
504
+ log.info(
505
+ "[dry-run] janitor would remove %s (%s, %.1f h old%s)",
506
+ candidate.path, human(size), age / 3600,
507
+ ", under pressure" if early else "",
508
+ )
509
+ result.would_remove.append((candidate.path, size))
510
+ if early:
511
+ result.pressure_removed += 1
512
+ continue
513
+ try:
514
+ _remove(candidate, runner)
515
+ except OSError as exc:
516
+ log.warning("janitor: could not remove %s (%s)", candidate.path, exc)
517
+ result.failed.append((candidate.path, str(exc)))
367
518
  continue
519
+ log.debug("janitor: removed %s (%s)", candidate.path, human(size))
520
+ result.removed.append((candidate.path, size))
521
+ if early:
522
+ result.pressure_removed += 1
523
+ if dry_run:
524
+ return result
525
+ for hub in hub_list:
368
526
  try:
369
527
  prune_worktrees(hub, runner)
370
528
  result.pruned += 1
@@ -109,6 +109,7 @@ from .loop_events import (
109
109
  round_verdict_prefix,
110
110
  ROUND_VERDICT_PREFIX,
111
111
  task_number_of,
112
+ volume_pressure_kind,
112
113
  )
113
114
  from .proc import CommandError
114
115
  from .state import State
@@ -1123,14 +1124,23 @@ _SHELL_RULE = (
1123
1124
  # checkout the review skill lets a reviewer make, and nothing a reviewer
1124
1125
  # writes there needs deleting. The daemon's workspace janitor (janitor.py,
1125
1126
  # issue #167) reclaims it: every `janitor_interval_minutes` it removes each
1126
- # hub's `main/.review-scratch-*` and `REVIEW-*` folders older than
1127
- # `janitor_grace_minutes` that no live session or open round holds, then
1128
- # prunes `.source`'s worktree records. The rule's text does not name the
1129
- # janitor: the reviewer needs only to know it must not delete, and the plan
1130
- # directives that embed this rule are pinned by digest.
1127
+ # hub's `main/.review-scratch-*`, `REVIEW-*` and `main/REVIEW-*` folders older
1128
+ # than `janitor_grace_minutes` (`janitor_pressure_grace_minutes` while the
1129
+ # volume is at or above `janitor_warn_pct`) that no live session or open round
1130
+ # holds, then prunes `.source`'s worktree records. The rule's text does not
1131
+ # name the janitor: the reviewer needs only to know it must not delete -- and,
1132
+ # since issue #177, WHERE its checkout goes. A reviewer starts in `<hub>/main`,
1133
+ # so a bare `git worktree add REVIEW-<task>` lands INSIDE `main/`; 121 of
1134
+ # those filled the fleet's volume on 2026-10-18. The janitor reaps both
1135
+ # places, and the rule names the one beside `main/` (`../REVIEW-<task>`),
1136
+ # which is where the trust seeding, the prompt policy and the reaper look.
1137
+ # The plan directives that embed this rule are pinned by digest (re-pinned
1138
+ # for issue #177 in test_directives.py).
1131
1139
  _REVIEWER_RULE = (
1132
1140
  "You never push and never delete: a reviewer that needs scratch files "
1133
- "writes them under its own checkout and leaves them. "
1141
+ "writes them under its own checkout and leaves them. A checkout you must "
1142
+ "make to run code goes BESIDE `main/`, never inside it: from your start "
1143
+ "directory, `git worktree add ../REVIEW-<review task ref> <the PR head>`. "
1134
1144
  )
1135
1145
 
1136
1146
  ROUND_1_DIRECTIVE = (
@@ -2619,6 +2629,15 @@ class ReviewWatcher:
2619
2629
  # When the workspace janitor last swept (monotonic), or None before the
2620
2630
  # first sweep -- which therefore runs at the end of the first pass.
2621
2631
  self._janitor_last: "float | None" = None
2632
+ # Whether the janitor has said, once for this process, that it will
2633
+ # not sweep under this `hub_template` (issue #175).
2634
+ self._janitor_layout_warned = False
2635
+ # When the open volume-pressure episode began (wall clock), or None
2636
+ # while the volume is under `janitor_warn_pct` (issue #177): one
2637
+ # `janitor.volume_pressure` event per episode, and a reading back
2638
+ # under the line closes it. In memory, so a restart under pressure
2639
+ # opens (and reports) a new episode.
2640
+ self._janitor_pressure_since: "float | None" = None
2622
2641
  # The (repo, number, round) whose alive session the stale-round probe
2623
2642
  # just killed as `wedged:first-run-dialog` (issue #136), so the
2624
2643
  # respawn site logs its re-enqueue at INFO instead of a second
@@ -7946,8 +7965,9 @@ class ReviewWatcher:
7946
7965
  One INFO line per sweep (folders removed and bytes freed, or what a
7947
7966
  dry run WOULD remove), and one WARNING when the workspace volume is
7948
7967
  at or above `janitor_warn_pct`. The disk read runs even when the
7949
- roster cannot be listed and the removal half is skipped: a full
7950
- volume is worth saying whatever else went wrong."""
7968
+ roster cannot be listed, or the hub layout is not the one the janitor
7969
+ reads, and the removal half is skipped: a full volume is worth saying
7970
+ whatever else went wrong."""
7951
7971
  if not self.config.janitor_enabled:
7952
7972
  return None
7953
7973
  mono = time.monotonic()
@@ -7956,8 +7976,10 @@ class ReviewWatcher:
7956
7976
  return None
7957
7977
  self._janitor_last = mono
7958
7978
  stamp = time.time() if now is None else now
7979
+ pct = self._volume_used_pct()
7980
+ pressed = pct is not None and pct >= self.config.janitor_warn_pct
7959
7981
  try:
7960
- result = self._sweep_workspace(stamp)
7982
+ result = self._sweep_workspace(stamp, pressed=pressed)
7961
7983
  except Exception as exc:
7962
7984
  log.warning(
7963
7985
  "janitor: sweep failed unexpectedly (%s: %s) — nothing more is "
@@ -7965,10 +7987,43 @@ class ReviewWatcher:
7965
7987
  type(exc).__name__, exc, exc_info=True,
7966
7988
  )
7967
7989
  result = None
7968
- self._warn_disk()
7990
+ self._note_volume(pct, result, stamp)
7969
7991
  return result
7970
7992
 
7971
- def _sweep_workspace(self, now: float) -> "janitor_mod.SweepResult | None":
7993
+ def _volume_used_pct(self) -> "float | None":
7994
+ """The workspace volume's used percentage, or None when
7995
+ `janitor_warn_pct` is 0 (never warn, never pressed) or the volume
7996
+ cannot be read."""
7997
+ if not self.config.janitor_warn_pct:
7998
+ return None
7999
+ return janitor_mod.disk_used_pct(self.config.workspace_root)
8000
+
8001
+ def _volume_pressed(self) -> bool:
8002
+ """Whether the workspace volume is, right now, at or above
8003
+ `janitor_warn_pct` -- asked by the sweep before each removal only
8004
+ the pressure grace allows (issue #177)."""
8005
+ pct = self._volume_used_pct()
8006
+ return pct is not None and pct >= self.config.janitor_warn_pct
8007
+
8008
+ def _sweep_workspace(
8009
+ self, now: float, *, pressed: bool = False
8010
+ ) -> "janitor_mod.SweepResult | None":
8011
+ if not janitor_mod.layout_supported(
8012
+ self.config.hub_for, self.config.workspace_root
8013
+ ):
8014
+ # Fail closed (issue #175): under another layout the hubs on disk
8015
+ # are not keyed by the slug the Guard holds (or are not found at
8016
+ # all), so a sweep could remove a live round's folders. Nothing is
8017
+ # removed or pruned; said once per process, not once per interval.
8018
+ if not self._janitor_layout_warned:
8019
+ self._janitor_layout_warned = True
8020
+ log.warning(
8021
+ "janitor: hub_template %r is not supported — the janitor "
8022
+ "supports only %s, so it removes and prunes nothing in "
8023
+ "this process",
8024
+ self.config.hub_template, janitor_mod.SUPPORTED_TEMPLATE,
8025
+ )
8026
+ return None
7972
8027
  guard = self._janitor_guard(now)
7973
8028
  if guard is None:
7974
8029
  log.warning(
@@ -7982,21 +8037,37 @@ class ReviewWatcher:
7982
8037
  grace_seconds=self.config.janitor_grace_minutes * 60,
7983
8038
  now=now,
7984
8039
  dry_run=self.config.dry_run,
8040
+ pressure_grace_seconds=(
8041
+ self.config.janitor_pressure_grace_minutes * 60 if pressed else None
8042
+ ),
8043
+ under_pressure=self._volume_pressed if pressed else None,
7985
8044
  )
8045
+ if result.pressure:
8046
+ log.info(
8047
+ "janitor: volume at or above %d%% — the grace dropped to %d "
8048
+ "minute(s) this sweep; %d folder(s) %s only under it",
8049
+ self.config.janitor_warn_pct,
8050
+ self.config.janitor_pressure_grace_minutes,
8051
+ result.pressure_removed,
8052
+ "would go" if self.config.dry_run else "went",
8053
+ )
7986
8054
  if self.config.dry_run:
7987
8055
  log.info(
7988
8056
  "[dry-run] janitor: would remove %d folder(s) (%s) across %d "
7989
- "hub(s); %d held by a live round, %d inside the grace",
8057
+ "hub(s); %d held by a live round, %d inside the grace, %d "
8058
+ "deferred to the next interval",
7990
8059
  len(result.would_remove),
7991
8060
  janitor_mod.human(sum(size for _, size in result.would_remove)),
7992
- result.hubs, len(result.held), result.fresh,
8061
+ result.hubs, len(result.held), result.fresh, result.deferred,
7993
8062
  )
7994
8063
  else:
7995
8064
  log.info(
7996
8065
  "janitor: removed %d folder(s), freed %s across %d hub(s); "
7997
- "%d held by a live round, %d inside the grace, %d failed",
8066
+ "%d held by a live round, %d inside the grace, %d failed, %d "
8067
+ "deferred to the next interval",
7998
8068
  len(result.removed), janitor_mod.human(result.freed),
7999
8069
  result.hubs, len(result.held), result.fresh, len(result.failed),
8070
+ result.deferred,
8000
8071
  )
8001
8072
  return result
8002
8073
 
@@ -8047,18 +8118,62 @@ class ReviewWatcher:
8047
8118
  )
8048
8119
  return guard
8049
8120
 
8050
- def _warn_disk(self) -> None:
8121
+ def _note_volume(
8122
+ self,
8123
+ pct: "float | None",
8124
+ result: "janitor_mod.SweepResult | None",
8125
+ stamp: float,
8126
+ ) -> None:
8051
8127
  """One WARNING when the workspace volume is at or above
8052
- `janitor_warn_pct` (0 = never)."""
8128
+ `janitor_warn_pct` (0 = never), and the pressure EPISODE (issue
8129
+ #177): the first sweep at or above the line records ONE
8130
+ `volume-pressure:` ping -- what the sweep freed and left folded in --
8131
+ which loop_events derives into one `janitor.volume_pressure` event;
8132
+ the first reading back under the line (after this sweep, or at a
8133
+ later one) closes the episode. Dry-run records nothing: it shares the
8134
+ ledger with production, so it only says what it would record. Never
8135
+ raises."""
8053
8136
  threshold = self.config.janitor_warn_pct
8054
- if not threshold:
8137
+ if not threshold or pct is None:
8055
8138
  return
8056
- pct = janitor_mod.disk_used_pct(self.config.workspace_root)
8057
- if pct is not None and pct >= threshold:
8058
- log.warning(
8059
- "janitor: workspace volume %s is %.1f%% used (warn at %d%%)",
8060
- self.config.workspace_root, pct, threshold,
8139
+ if pct < threshold:
8140
+ self._close_pressure_episode(pct)
8141
+ return
8142
+ log.warning(
8143
+ "janitor: workspace volume %s is %.1f%% used (warn at %d%%)",
8144
+ self.config.workspace_root, pct, threshold,
8145
+ )
8146
+ if self._janitor_pressure_since is None:
8147
+ kind = volume_pressure_kind(
8148
+ stamp, pct, threshold,
8149
+ result.freed if result is not None else 0,
8150
+ result.left if result is not None else None,
8061
8151
  )
8152
+ if self.config.dry_run:
8153
+ log.info("[dry-run] janitor: would record %s", kind)
8154
+ self._janitor_pressure_since = stamp
8155
+ else:
8156
+ try:
8157
+ self.state.record_ping("", 0, kind)
8158
+ self._janitor_pressure_since = stamp
8159
+ except Exception as exc:
8160
+ # The episode stays unopened, so the next sweep retries.
8161
+ log.warning(
8162
+ "janitor: could not record the volume-pressure episode "
8163
+ "(%s: %s)", type(exc).__name__, exc,
8164
+ )
8165
+ after = self._volume_used_pct()
8166
+ if after is not None and after < threshold:
8167
+ self._close_pressure_episode(after)
8168
+
8169
+ def _close_pressure_episode(self, pct: float) -> None:
8170
+ if self._janitor_pressure_since is None:
8171
+ return
8172
+ self._janitor_pressure_since = None
8173
+ log.info(
8174
+ "janitor: volume-pressure episode closed — the workspace is %.1f%% "
8175
+ "used, under the %d%% line", pct, self.config.janitor_warn_pct,
8176
+ )
8062
8177
 
8063
8178
  def _vitals_config(self) -> dict:
8064
8179
  """The snapshot's `config` block (issue #159), read off what this
@@ -102,6 +102,17 @@ loop's `pr_origin_task_ref`). Both are read once, at spawn, onto the round's
102
102
  PR's newest spawn row (a pre-spawn `checks.held`, a `round.capped` no spawn
103
103
  explains). Each field is omitted when unknown -- never null -- so a PR whose
104
104
  body names neither emits the same payload as before, byte for byte.
105
+
106
+ ``janitor.volume_pressure`` (issue #177) is the workspace volume at or above
107
+ `janitor_warn_pct` -- until then a WARNING line nobody saw while 119 dead
108
+ review checkouts filled a 46 GB volume. Again a ledger ROW: the watcher
109
+ records ONE `volume-pressure:` ping per pressure EPISODE (the first sweep at
110
+ or above the line opens it; the first reading back under the line closes it),
111
+ under the anchor-less `('', 0)` an `auth-rejected:` row without a PR uses,
112
+ with what that sweep saw folded into the kind: `data: { usedPct, warnPct,
113
+ freedBytes, foldersLeft }` (`foldersLeft` null when the sweep did not run).
114
+ Keyed on the episode's start second, so a backfill re-derives the same one
115
+ event. No repo and no PR: the volume is the seat's, not a PR's.
105
116
  """
106
117
 
107
118
  from __future__ import annotations
@@ -205,6 +216,33 @@ _AUTH_REJECTED_RE = re.compile(
205
216
  r"(?P<matched>[a-z0-9_]+):(?P<env>[A-Z_]*):(?P<suffix>.{0,4})$"
206
217
  )
207
218
 
219
+ # The `janitor.volume_pressure` row (issue #177): one per pressure EPISODE,
220
+ # `volume-pressure:<started>:<usedPct>:<warnPct>:<freedBytes>:<foldersLeft>`.
221
+ # `started` (epoch seconds) is the episode's identity and its dedupe key;
222
+ # `foldersLeft` is empty when the sweep did not run (roster unlistable,
223
+ # unsupported layout).
224
+ VOLUME_PRESSURE_PREFIX = "volume-pressure:"
225
+ VOLUME_PRESSURE_EVENT = "janitor.volume_pressure"
226
+ _VOLUME_PRESSURE_RE = re.compile(
227
+ r"^volume-pressure:(?P<started>\d+):(?P<pct>\d+(?:\.\d+)?):(?P<warn>\d+):"
228
+ r"(?P<freed>\d+):(?P<left>\d*)$"
229
+ )
230
+
231
+
232
+ def volume_pressure_kind(
233
+ started: "int | float",
234
+ used_pct: float,
235
+ warn_pct: int,
236
+ freed: int,
237
+ left: "int | None",
238
+ ) -> str:
239
+ """The ledger kind of ONE pressure episode (see VOLUME_PRESSURE_PREFIX)."""
240
+ return (
241
+ f"{VOLUME_PRESSURE_PREFIX}{int(started)}:{round(float(used_pct), 1)}:"
242
+ f"{int(warn_pct)}:{max(0, int(freed))}:{'' if left is None else int(left)}"
243
+ )
244
+
245
+
208
246
  # The credential variables a container may carry, in Claude Code's own
209
247
  # precedence order (design §1.4: `ANTHROPIC_API_KEY` outranks
210
248
  # `CLAUDE_CODE_OAUTH_TOKEN`; both outrank the persisted login). Read by NAME
@@ -578,7 +616,8 @@ def _ping_events(
578
616
  rows: "list[dict]", notices: "dict[tuple[str, int], dict]"
579
617
  ) -> "list[tuple[int, dict]]":
580
618
  """`stalled`, `stability.hold`, `checks.held`, the prompt responder's
581
- two families and `auth.rejected` out of the ping ledger.
619
+ two families, `auth.rejected` and `janitor.volume_pressure` out of the
620
+ ping ledger.
582
621
 
583
622
  `kind` is free-form text carrying the episode identity, so it is parsed
584
623
  by prefix; kinds this module does not report (`activity-deferred:`,
@@ -693,6 +732,25 @@ def _ping_events(
693
732
  ),
694
733
  },
695
734
  ))
735
+ elif kind.startswith(VOLUME_PRESSURE_PREFIX):
736
+ match = _VOLUME_PRESSURE_RE.match(kind)
737
+ if match is None:
738
+ continue
739
+ pct, warn = float(match.group("pct")), int(match.group("warn"))
740
+ out.append(_event(
741
+ VOLUME_PRESSURE_EVENT,
742
+ at,
743
+ f"{SEAT}:{VOLUME_PRESSURE_EVENT}:{match.group('started')}",
744
+ reason=f"workspace volume {pct:.1f}% used (warn at {warn}%)",
745
+ data={
746
+ "usedPct": pct,
747
+ "warnPct": warn,
748
+ "freedBytes": int(match.group("freed")),
749
+ "foldersLeft": (
750
+ int(match.group("left")) if match.group("left") else None
751
+ ),
752
+ },
753
+ ))
696
754
  elif kind.startswith(PROMPT_PREFIX):
697
755
  match = _PROMPT_RE.match(kind)
698
756
  if match is None:
@@ -16,8 +16,11 @@ developer's `<hub>/TASK-*` worktree; here `SessionContext.worktree` is the
16
16
  reviewer's own REVIEW CHECKOUT -- the throwaway `<hub>/REVIEW-<task>`
17
17
  worktree the alissa-code-review skill lets a reviewer that must run code
18
18
  make beside `main/`. A `dangerous_rm` target inside it is `accept`; anything
19
- else -- `main/`, the hub, another round's checkout, `/tmp`, an unresolvable
20
- target, a session whose checkout the loop could not name -- is `decline`.
19
+ else -- `main/` (a `main/REVIEW-*` checkout included: the reviewer rule
20
+ places the checkout beside `main/`, and the janitor, not the reviewer, reaps
21
+ one made inside it -- issue #177), the hub, another round's checkout, `/tmp`,
22
+ an unresolvable target, a session whose checkout the loop could not name --
23
+ is `decline`.
21
24
  The reason strings are devloop's verbatim ("target inside worktree", "lane
22
25
  worktree unknown") so the sentinel reads one vocabulary across the seats.
23
26
 
@@ -90,6 +90,7 @@ TRUST_KEY = "hasTrustDialogAccepted"
90
90
  # The throwaway worktree the alissa-code-review skill tells a reviewer to
91
91
  # create beside `main/` when it must run code: `REVIEW-TASK-<id>`. Listed by
92
92
  # prefix so a checkout already on disk is trusted whatever task it was for.
93
+ # The janitor also reaps one made inside `main/` (issue #177).
93
94
  REVIEW_CHECKOUT_PREFIX = "REVIEW-"
94
95
 
95
96
  # What a pane PARKED on one of Claude Code's first-run gates shows -- and
@@ -195,7 +196,15 @@ def hub_root(cwd: "Path | str") -> Path:
195
196
  def review_checkout_name(task_ref: "str | None") -> "str | None":
196
197
  """The throwaway worktree the review skill creates for a round that must
197
198
  run code: `REVIEW-<task ref>` (`REVIEW-TASK-<id>`), or None without a
198
- review task to name it after."""
199
+ review task to name it after.
200
+
201
+ Its place is BESIDE `main/` -- `<hub>/REVIEW-<task ref>`, which the
202
+ reviewer rule in loop.py tells the session to make with `git worktree
203
+ add ../REVIEW-<task ref>` from its `<hub>/main` start directory, and
204
+ where this module seeds trust. One made INSIDE `main/` by a bare
205
+ `git worktree add REVIEW-<task ref>` (issue #177: 121 of them on
206
+ 2026-10-18) is not trusted or policed as the round's checkout, but the
207
+ janitor reaps it exactly like one beside `main/`."""
199
208
  ref = (task_ref or "").strip()
200
209
  return f"{REVIEW_CHECKOUT_PREFIX}{ref}" if ref else None
201
210
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.18
3
+ Version: 0.31.20
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera