alissa-tools-github-revloop 0.31.23__tar.gz → 0.31.25__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.23/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.25}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/janitor.py +206 -12
  3. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/loop.py +40 -13
  4. alissa_tools_github_revloop-0.31.25/src/main/alissa/tools/github/revloop/version +1 -0
  5. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  6. alissa_tools_github_revloop-0.31.23/src/main/alissa/tools/github/revloop/version +0 -1
  7. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/LICENSE +0 -0
  8. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/MANIFEST.in +0 -0
  9. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/NOTICE +0 -0
  10. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/README.md +0 -0
  11. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/requirements.txt +0 -0
  12. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/setup.cfg +0 -0
  13. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/setup.py +0 -0
  14. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  15. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  16. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  17. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  18. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  19. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/config.py +0 -0
  20. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  21. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  22. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  23. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/parking.py +0 -0
  24. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  25. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
  26. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  27. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/state.py +0 -0
  28. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  29. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/version.py +0 -0
  30. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  31. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  32. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  33. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  34. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  35. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  36. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  37. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  38. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  39. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  40. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.25}/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.23
3
+ Version: 0.31.25
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -42,6 +42,9 @@ Three properties everything here is written around:
42
42
  and the grace is a day by default. A held folder is kept whatever its age.
43
43
  Under disk PRESSURE (issue #177) the grace drops -- to two hours by default
44
44
  -- oldest first, only while the volume is still at or above its warn line.
45
+ A round that names no review task yet holds only its hub's checkouts made
46
+ since it spawned (issue #185), and at or above `BACKSTOP_PCT` no such
47
+ hub-wide hold reaches a checkout older than the stale-round window.
45
48
  * **Nothing raises past its own concern.** A folder that vanishes mid-sweep,
46
49
  an unreadable hub, a failed removal: logged and skipped, because this runs
47
50
  at the end of a poll pass that must complete.
@@ -104,6 +107,31 @@ SUPPORTED_TEMPLATE = "{root}/{repo}/main"
104
107
  _PROBE_OWNERS = ("janitor-probe-owner-a", "janitor-probe-owner-b")
105
108
  _PROBE_REPO = "janitor-probe-repo"
106
109
 
110
+ # How far before a round's spawn time a checkout may be stamped and still
111
+ # be held by that round when its review-task ref is unknown (issue #185):
112
+ # clock skew between the ledger's stamp and the filesystem's.
113
+ HOLD_SKEW_SECONDS = 5 * 60
114
+
115
+ # The volume used percentage at or above which the pressure backstop applies
116
+ # (issue #185): a hub-wide hold is void for checkouts older than the stale-
117
+ # round window, and one WARNING names what holds the most bytes.
118
+ BACKSTOP_PCT = 95.0
119
+
120
+ # How long a held folder's cached size is trusted (issue #187). The cache is
121
+ # keyed on the folder's newest mtime, which only sees the folder and its
122
+ # direct children: a write deeper down (an `npm ci` filling `node_modules`)
123
+ # leaves it unchanged. So an entry is re-walked once it is this old, mtime
124
+ # or not.
125
+ SIZE_CACHE_SECONDS = 60 * 60
126
+
127
+ # One held folder's cached size: (newest mtime, bytes, the sweep `now` it
128
+ # was walked at).
129
+ SizeCache = dict[Path, tuple[float, int, float]]
130
+
131
+ # The two reasons a hub-wide hold gives, so the sweep summary groups them.
132
+ HUB_HOLD_BOUNDED = "a live round in this hub names no review task and spawned before this checkout"
133
+ HUB_HOLD_UNBOUNDED = "a live round in this hub names no review task or spawn time"
134
+
107
135
  Runner = Callable[..., str]
108
136
 
109
137
 
@@ -138,25 +166,43 @@ class Guard:
138
166
  `review-pr-<n>` session names no repo, and holds its PR number anywhere.
139
167
 
140
168
  A round whose review-task ref is unknown cannot be mapped to a single
141
- REVIEW- checkout, so it holds every checkout in its hub (`all_checkouts`).
169
+ REVIEW- checkout -- every fresh round starts that way, since the reviewer
170
+ creates its review task after it spawns. Such a round holds the checkouts
171
+ in its hub that are NEWER than its spawn time less `HOLD_SKEW_SECONDS`
172
+ (issue #185): a checkout older than every unknown-ref round in the hub
173
+ cannot belong to any of them. `all_checkouts` keeps, per hub, the
174
+ earliest such bound; None there means a round whose spawn time is not
175
+ known either, and holds every checkout in the hub, as before issue #185
176
+ -- the hold that, with studio reviews running near-continuously, kept 78
177
+ dead checkouts (~44 GB) and filled the fleet's volume on 2026-10-21.
142
178
  """
143
179
 
144
180
  numbers: "dict[str | None, set[int]]" = field(default_factory=dict)
145
181
  tasks: "dict[str | None, set[str]]" = field(default_factory=dict)
146
- all_checkouts: "set[str | None]" = field(default_factory=set)
182
+ all_checkouts: "dict[str | None, float | None]" = field(default_factory=dict)
147
183
 
148
184
  def hold(
149
- self, repo: "str | None", number: int, task_ref: "str | None" = None
185
+ self,
186
+ repo: "str | None",
187
+ number: int,
188
+ task_ref: "str | None" = None,
189
+ since: "float | None" = None,
150
190
  ) -> None:
151
191
  """Hold PR `number` of `repo` (`owner/name`, a bare name, or a
152
- session-name slug; None = any hub) and the checkout its task names."""
192
+ session-name slug; None = any hub) and the checkout its task names.
193
+ With no task ref, `since` (the round's spawn time, epoch seconds)
194
+ bounds the checkouts held in the hub; without it, every one is."""
153
195
  slug = None if repo is None else session_repo_slug(repo.rpartition("/")[2])
154
196
  self.numbers.setdefault(slug, set()).add(int(number))
155
197
  ref = (task_ref or "").strip()
156
198
  if ref:
157
199
  self.tasks.setdefault(slug, set()).add(ref)
158
- else:
159
- self.all_checkouts.add(slug)
200
+ return
201
+ bound = None if since is None else float(since) - HOLD_SKEW_SECONDS
202
+ if slug in self.all_checkouts:
203
+ prev = self.all_checkouts[slug]
204
+ bound = None if prev is None or bound is None else min(prev, bound)
205
+ self.all_checkouts[slug] = bound
160
206
 
161
207
  def _in(self, table: "dict[str | None, set]", slug: str, value: object) -> bool:
162
208
  return value in table.get(slug, ()) or value in table.get(None, ())
@@ -164,8 +210,19 @@ class Guard:
164
210
  def _any(self, slug: str) -> bool:
165
211
  return bool(self.numbers.get(slug) or self.numbers.get(None))
166
212
 
167
- def holds(self, candidate: Candidate) -> "str | None":
168
- """Why `candidate` must stay, or None when nothing holds it."""
213
+ def holds(
214
+ self,
215
+ candidate: Candidate,
216
+ mtime: "float | None" = None,
217
+ void_before: "float | None" = None,
218
+ ) -> "str | None":
219
+ """Why `candidate` must stay, or None when nothing holds it.
220
+
221
+ `mtime` is the candidate's age stamp (`newest_mtime`); a review
222
+ checkout with none is held by every hub-wide hold, bounded or not.
223
+ `void_before` is the pressure backstop (issue #185): a hub-wide hold
224
+ does not reach a checkout whose stamp is older than it. A known task
225
+ ref's hold and a scratch folder's are never voided."""
169
226
  slug = candidate.hub_slug
170
227
  if candidate.kind == KIND_SCRATCH:
171
228
  if candidate.number is None:
@@ -173,10 +230,18 @@ class Guard:
173
230
  if self._in(self.numbers, slug, candidate.number):
174
231
  return f"PR #{candidate.number} has a live session or open round"
175
232
  return None
176
- if slug in self.all_checkouts or None in self.all_checkouts:
177
- return "a live round in this hub names no review task"
178
233
  if candidate.task_ref and self._in(self.tasks, slug, candidate.task_ref):
179
234
  return f"{candidate.task_ref} has a live session or open round"
235
+ if mtime is not None and void_before is not None and mtime < void_before:
236
+ return None
237
+ for key in (slug, None):
238
+ if key not in self.all_checkouts:
239
+ continue
240
+ bound = self.all_checkouts[key]
241
+ if bound is None:
242
+ return HUB_HOLD_UNBOUNDED
243
+ if mtime is None or mtime >= bound:
244
+ return HUB_HOLD_BOUNDED
180
245
  return None
181
246
 
182
247
 
@@ -199,11 +264,31 @@ class SweepResult:
199
264
  # in a dry run, would-be removals) only that shorter grace allowed.
200
265
  pressure: bool = False
201
266
  pressure_removed: int = 0
267
+ # Issue #185: bytes held per "<hub slug>: <reason>", so held growth is
268
+ # visible before it fills the disk; held folders not sized because the
269
+ # sweep's time budget ran out; and, when the backstop applied, how many
270
+ # checkouts only it released from a hub-wide hold.
271
+ held_bytes: "dict[str, int]" = field(default_factory=dict)
272
+ held_unsized: int = 0
273
+ # Issue #187: held folders whose size came from the cache, not a walk
274
+ # this sweep, so the summary can say its figure may be up to
275
+ # SIZE_CACHE_SECONDS old.
276
+ held_cached: int = 0
277
+ backstop: bool = False
278
+ backstop_released: int = 0
202
279
 
203
280
  @property
204
281
  def freed(self) -> int:
205
282
  return sum(size for _, size in self.removed)
206
283
 
284
+ @property
285
+ def held_total(self) -> int:
286
+ return sum(self.held_bytes.values())
287
+
288
+ def top_held(self, limit: int = 5) -> "list[tuple[str, int]]":
289
+ """The reasons holding the most bytes, largest first."""
290
+ return sorted(self.held_bytes.items(), key=lambda kv: (-kv[1], kv[0]))[:limit]
291
+
207
292
  @property
208
293
  def left(self) -> int:
209
294
  """Candidate folders still on disk after a live sweep: inside the
@@ -211,6 +296,34 @@ class SweepResult:
211
296
  return self.fresh + len(self.held) + self.deferred + len(self.failed)
212
297
 
213
298
 
299
+ def held_reasons(pairs: "Iterable[tuple[str, int]]") -> str:
300
+ """`; <hub>: <reason> = <size>, ...` for the log lines, or '' when none."""
301
+ parts = [f"{key} = {human(size)}" for key, size in pairs]
302
+ return ("; " + ", ".join(parts)) if parts else ""
303
+
304
+
305
+ # How many held reasons the sweep summary names before folding the rest.
306
+ HELD_REASONS_LOGGED = 10
307
+
308
+
309
+ def held_summary(result: SweepResult) -> str:
310
+ """The sweep summary's held-bytes tail (issue #185): every hub/reason
311
+ holding bytes, largest first, at most HELD_REASONS_LOGGED of them, and
312
+ how many held folders were sized from the cache or went unsized."""
313
+ tail = held_reasons(result.top_held(HELD_REASONS_LOGGED))
314
+ rest = len(result.held_bytes) - HELD_REASONS_LOGGED
315
+ if rest > 0:
316
+ tail += f", +{rest} more reason(s)"
317
+ if result.held_cached:
318
+ tail += (
319
+ f"; {result.held_cached} held size(s) cached, up to "
320
+ f"{SIZE_CACHE_SECONDS // 60} min old"
321
+ )
322
+ if result.held_unsized:
323
+ tail += f"; {result.held_unsized} held folder(s) not sized this sweep"
324
+ return tail
325
+
326
+
214
327
  def layout_supported(
215
328
  hub_for: "Callable[[str, str], Path]", workspace_root: "Path | str"
216
329
  ) -> bool:
@@ -322,9 +435,23 @@ def dir_bytes(path: "Path | str") -> int:
322
435
  """Bytes allocated under `path` (st_blocks, the figure `du` reports and a
323
436
  removal frees), following no symlinks. Iterative, and every per-entry
324
437
  error is skipped -- devloop's `dir_size` walk, counting blocks."""
438
+ return _walk_bytes(path, None) or 0
439
+
440
+
441
+ def held_bytes_of(path: "Path | str", stop: "Callable[[], bool]") -> "int | None":
442
+ """`dir_bytes` for a held folder (issue #187): `stop` is asked before
443
+ each directory is read, and the walk gives up -- None, never a partial
444
+ figure -- the moment it answers True, so one large tree cannot run the
445
+ sweep past its time budget."""
446
+ return _walk_bytes(path, stop)
447
+
448
+
449
+ def _walk_bytes(path: "Path | str", stop: "Callable[[], bool] | None") -> "int | None":
325
450
  total = 0
326
451
  stack = [os.fspath(path)]
327
452
  while stack:
453
+ if stop is not None and stop():
454
+ return None
328
455
  current = stack.pop()
329
456
  try:
330
457
  with os.scandir(current) as entries:
@@ -434,6 +561,8 @@ def sweep(
434
561
  clock: "Callable[[], float]" = time.monotonic,
435
562
  pressure_grace_seconds: "float | None" = None,
436
563
  under_pressure: "Callable[[], bool] | None" = None,
564
+ stale_round_seconds: "float | None" = None,
565
+ sizes: "SizeCache | None" = None,
437
566
  ) -> SweepResult:
438
567
  """Remove every candidate under `hubs` older than `grace_seconds` that
439
568
  `guard` does not hold, oldest first, then prune each hub's worktree
@@ -456,7 +585,22 @@ def sweep(
456
585
  Dry-run removes nothing and prunes nothing: it logs each folder it WOULD
457
586
  remove (with its size) and reports them in `would_remove`, under the
458
587
  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)."""
588
+ nothing, so under pressure it reports every pressure-eligible folder).
589
+
590
+ The pressure backstop (issue #185): when `stale_round_seconds` is given
591
+ (the caller passes STALE_ROUND_SECONDS at or above `BACKSTOP_PCT`), a
592
+ hub-wide hold -- a round with no review-task ref -- is void for a
593
+ checkout older than that window: no live round can own a checkout older
594
+ than the stale window. `backstop_released` counts what only it freed.
595
+
596
+ Every held folder is sized into `held_bytes` per hub and reason, AFTER
597
+ the removal loop and on whatever is left of the time budget (issue
598
+ #187): removal never pays for observability. A walk that reaches the
599
+ budget mid-tree is abandoned, and every folder left unsized is counted in
600
+ `held_unsized`. `sizes` (a `SizeCache`), when given, caches those walks
601
+ across sweeps and is re-keyed to this sweep's held folders; an entry is
602
+ reused while the folder's mtime is unchanged AND it is younger than
603
+ SIZE_CACHE_SECONDS by `now`, and each reuse is counted in `held_cached`."""
460
604
  result = SweepResult()
461
605
  started = clock()
462
606
  bounded = False
@@ -466,6 +610,11 @@ def sweep(
466
610
  result.pressure = under_pressure()
467
611
  if result.pressure:
468
612
  shrunk = min(grace_seconds, pressure_grace_seconds)
613
+ void_before = None
614
+ if stale_round_seconds is not None:
615
+ result.backstop = True
616
+ void_before = now - stale_round_seconds
617
+ held_queue: "list[tuple[float, Candidate, str]]" = []
469
618
  aged: "list[tuple[float, Candidate]]" = []
470
619
  for hub in hub_list:
471
620
  result.hubs += 1
@@ -480,10 +629,19 @@ def sweep(
480
629
  if age < shrunk:
481
630
  result.fresh += 1
482
631
  continue
483
- why = guard.holds(candidate)
632
+ why = guard.holds(candidate, mtime)
633
+ if why is not None and void_before is not None:
634
+ if guard.holds(candidate, mtime, void_before) is None:
635
+ result.backstop_released += 1
636
+ log.info(
637
+ "janitor: backstop releases %s (%.1f h old) from a hub-wide "
638
+ "hold — %s", candidate.path, age / 3600, why,
639
+ )
640
+ why = None
484
641
  if why is not None:
485
642
  log.debug("janitor: keeping %s — %s", candidate.path, why)
486
643
  result.held.append((candidate.path, why))
644
+ held_queue.append((mtime, candidate, f"{candidate.hub_slug}: {why}"))
487
645
  continue
488
646
  early = age < grace_seconds
489
647
  if early and not dry_run and not (under_pressure and under_pressure()):
@@ -520,6 +678,8 @@ def sweep(
520
678
  result.removed.append((candidate.path, size))
521
679
  if early:
522
680
  result.pressure_removed += 1
681
+ _size_held(held_queue, result, sizes, now=now,
682
+ out_of_time=lambda: clock() - started >= max_seconds)
523
683
  if dry_run:
524
684
  return result
525
685
  for hub in hub_list:
@@ -529,3 +689,37 @@ def sweep(
529
689
  except (CommandError, OSError) as exc:
530
690
  log.warning("janitor: git worktree prune failed in %s (%s)", hub, exc)
531
691
  return result
692
+
693
+
694
+ def _size_held(
695
+ queue: "list[tuple[float, Candidate, str]]",
696
+ result: SweepResult,
697
+ sizes: "SizeCache | None",
698
+ *,
699
+ now: float,
700
+ out_of_time: "Callable[[], bool]",
701
+ ) -> None:
702
+ """Size the sweep's held folders into `result.held_bytes`, after the
703
+ removals (issue #187). A fresh cache entry costs nothing and is always
704
+ used; anything else is walked only while `out_of_time()` answers False,
705
+ and a walk is abandoned the moment it does. `sizes` is then re-keyed to
706
+ what this sweep sized, so entries for gone or unsized folders drop out."""
707
+ seen: SizeCache = {}
708
+ for mtime, candidate, key in queue:
709
+ cached = (sizes or {}).get(candidate.path)
710
+ if (cached is not None and cached[0] == mtime
711
+ and now - cached[2] < SIZE_CACHE_SECONDS):
712
+ size = cached[1]
713
+ seen[candidate.path] = cached
714
+ result.held_cached += 1
715
+ else:
716
+ walked = None if out_of_time() else held_bytes_of(candidate.path, out_of_time)
717
+ if walked is None:
718
+ result.held_unsized += 1
719
+ continue
720
+ size = walked
721
+ seen[candidate.path] = (mtime, size, now)
722
+ result.held_bytes[key] = result.held_bytes.get(key, 0) + size
723
+ if sizes is not None:
724
+ sizes.clear()
725
+ sizes.update(seen)
@@ -2847,6 +2847,10 @@ class ReviewWatcher:
2847
2847
  # under the line closes it. In memory, so a restart under pressure
2848
2848
  # opens (and reports) a new episode.
2849
2849
  self._janitor_pressure_since: "float | None" = None
2850
+ # Held folders' sizes from the last sweep, path -> (mtime, bytes,
2851
+ # sized at) (issues #185, #187): the held-bytes summary re-walks only
2852
+ # what changed or is older than janitor_mod.SIZE_CACHE_SECONDS.
2853
+ self._janitor_sizes: "janitor_mod.SizeCache" = {}
2850
2854
  # The (repo, number, round) whose alive session the stale-round probe
2851
2855
  # just killed as `wedged:first-run-dialog` (issue #136), so the
2852
2856
  # respawn site logs its re-enqueue at INFO instead of a second
@@ -8363,8 +8367,9 @@ class ReviewWatcher:
8363
8367
  stamp = time.time() if now is None else now
8364
8368
  pct = self._volume_used_pct()
8365
8369
  pressed = pct is not None and pct >= self.config.janitor_warn_pct
8370
+ backstop = pct is not None and pct >= janitor_mod.BACKSTOP_PCT
8366
8371
  try:
8367
- result = self._sweep_workspace(stamp, pressed=pressed)
8372
+ result = self._sweep_workspace(stamp, pressed=pressed, backstop=backstop)
8368
8373
  except Exception as exc:
8369
8374
  log.warning(
8370
8375
  "janitor: sweep failed unexpectedly (%s: %s) — nothing more is "
@@ -8391,7 +8396,7 @@ class ReviewWatcher:
8391
8396
  return pct is not None and pct >= self.config.janitor_warn_pct
8392
8397
 
8393
8398
  def _sweep_workspace(
8394
- self, now: float, *, pressed: bool = False
8399
+ self, now: float, *, pressed: bool = False, backstop: bool = False
8395
8400
  ) -> "janitor_mod.SweepResult | None":
8396
8401
  if not janitor_mod.layout_supported(
8397
8402
  self.config.hub_for, self.config.workspace_root
@@ -8426,7 +8431,17 @@ class ReviewWatcher:
8426
8431
  self.config.janitor_pressure_grace_minutes * 60 if pressed else None
8427
8432
  ),
8428
8433
  under_pressure=self._volume_pressed if pressed else None,
8434
+ stale_round_seconds=STALE_ROUND_SECONDS if backstop else None,
8435
+ sizes=self._janitor_sizes,
8429
8436
  )
8437
+ if result.backstop:
8438
+ log.warning(
8439
+ "janitor: volume at or above %d%% — hub-wide holds are void for "
8440
+ "checkouts older than %d minute(s); %d released; %s held%s",
8441
+ int(janitor_mod.BACKSTOP_PCT), STALE_ROUND_SECONDS // 60,
8442
+ result.backstop_released, janitor_mod.human(result.held_total),
8443
+ janitor_mod.held_reasons(result.top_held()),
8444
+ )
8430
8445
  if result.pressure:
8431
8446
  log.info(
8432
8447
  "janitor: volume at or above %d%% — the grace dropped to %d "
@@ -8439,20 +8454,22 @@ class ReviewWatcher:
8439
8454
  if self.config.dry_run:
8440
8455
  log.info(
8441
8456
  "[dry-run] janitor: would remove %d folder(s) (%s) across %d "
8442
- "hub(s); %d held by a live round, %d inside the grace, %d "
8443
- "deferred to the next interval",
8457
+ "hub(s); %d held by a live round (%s), %d inside the grace, %d "
8458
+ "deferred to the next interval%s",
8444
8459
  len(result.would_remove),
8445
8460
  janitor_mod.human(sum(size for _, size in result.would_remove)),
8446
- result.hubs, len(result.held), result.fresh, result.deferred,
8461
+ result.hubs, len(result.held), janitor_mod.human(result.held_total),
8462
+ result.fresh, result.deferred, janitor_mod.held_summary(result),
8447
8463
  )
8448
8464
  else:
8449
8465
  log.info(
8450
8466
  "janitor: removed %d folder(s), freed %s across %d hub(s); "
8451
- "%d held by a live round, %d inside the grace, %d failed, %d "
8452
- "deferred to the next interval",
8467
+ "%d held by a live round (%s), %d inside the grace, %d failed, %d "
8468
+ "deferred to the next interval%s",
8453
8469
  len(result.removed), janitor_mod.human(result.freed),
8454
- result.hubs, len(result.held), result.fresh, len(result.failed),
8455
- result.deferred,
8470
+ result.hubs, len(result.held), janitor_mod.human(result.held_total),
8471
+ result.fresh, len(result.failed), result.deferred,
8472
+ janitor_mod.held_summary(result),
8456
8473
  )
8457
8474
  return result
8458
8475
 
@@ -8465,7 +8482,11 @@ class ReviewWatcher:
8465
8482
  Open rounds are the ledger's own definition (`in_flight_repos`): a
8466
8483
  spawn younger than the stale-round window whose session was not
8467
8484
  reaped, and a verdict post still owed. The roster is the pass's
8468
- post-reap listing when the sweep produced one."""
8485
+ post-reap listing when the sweep produced one.
8486
+
8487
+ Every hold carries its round's spawn time (issue #185), so a round
8488
+ whose review-task ref is not known yet holds only the checkouts in
8489
+ its hub made since it spawned."""
8469
8490
  sessions = self._pass_roster
8470
8491
  if sessions is None:
8471
8492
  try:
@@ -8480,17 +8501,22 @@ class ReviewWatcher:
8480
8501
  continue
8481
8502
  row = self.state.find_spawn_by_session(ses.name)
8482
8503
  if row is not None:
8483
- guard.hold(str(row["repo"]), int(row["number"]), row["task_ref"])
8504
+ guard.hold(
8505
+ str(row["repo"]), int(row["number"]), row["task_ref"],
8506
+ since=row["spawned_at"],
8507
+ )
8484
8508
  else:
8485
8509
  # No ledger row (a skill-shaped `review-pr-<n>`): the name is
8486
8510
  # the only evidence, and a name without a repo holds its PR
8487
- # number in every hub.
8511
+ # number in every hub. The roster reports no start time, so
8512
+ # its hold on the hub's checkouts stays unbounded (issue
8513
+ # #185); the >=95% backstop is what reaches past it.
8488
8514
  guard.hold(ref.repo, ref.number)
8489
8515
  for spawn_row in self.state.read_spawns(since=now - STALE_ROUND_SECONDS):
8490
8516
  if not self.state.is_reaped(str(spawn_row["session"])):
8491
8517
  guard.hold(
8492
8518
  str(spawn_row["repo"]), int(spawn_row["number"]),
8493
- spawn_row["task_ref"],
8519
+ spawn_row["task_ref"], since=spawn_row["spawned_at"],
8494
8520
  )
8495
8521
  for post in self.state.read_verdict_posts():
8496
8522
  if post["posted_at"] is None and post["abandoned_at"] is None:
@@ -8500,6 +8526,7 @@ class ReviewWatcher:
8500
8526
  guard.hold(
8501
8527
  str(post["repo"]), int(post["number"]),
8502
8528
  spawn["task_ref"] if spawn is not None else None,
8529
+ since=spawn["spawned_at"] if spawn is not None else None,
8503
8530
  )
8504
8531
  return guard
8505
8532
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.23
3
+ Version: 0.31.25
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera