alissa-tools-github-revloop 0.31.23__tar.gz → 0.31.24__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.24}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/src/main/alissa/tools/github/revloop/janitor.py +145 -12
  3. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/src/main/alissa/tools/github/revloop/loop.py +39 -13
  4. alissa_tools_github_revloop-0.31.24/src/main/alissa/tools/github/revloop/version +1 -0
  5. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24/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.24}/LICENSE +0 -0
  8. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/MANIFEST.in +0 -0
  9. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/NOTICE +0 -0
  10. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/README.md +0 -0
  11. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/requirements.txt +0 -0
  12. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/setup.cfg +0 -0
  13. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/setup.py +0 -0
  14. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  15. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  16. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  17. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/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.24}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  19. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/src/main/alissa/tools/github/revloop/config.py +0 -0
  20. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/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.24}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  22. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/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.24}/src/main/alissa/tools/github/revloop/parking.py +0 -0
  24. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  25. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
  26. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  27. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/src/main/alissa/tools/github/revloop/state.py +0 -0
  28. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  29. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/src/main/alissa/tools/github/revloop/version.py +0 -0
  30. {alissa_tools_github_revloop-0.31.23 → alissa_tools_github_revloop-0.31.24}/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.24}/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.24}/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.24}/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.24}/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.24}/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.24}/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.24}/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.24}/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.24}/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.24}/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.24
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,20 @@ 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
+ # The two reasons a hub-wide hold gives, so the sweep summary groups them.
121
+ HUB_HOLD_BOUNDED = "a live round in this hub names no review task and spawned before this checkout"
122
+ HUB_HOLD_UNBOUNDED = "a live round in this hub names no review task or spawn time"
123
+
107
124
  Runner = Callable[..., str]
108
125
 
109
126
 
@@ -138,25 +155,43 @@ class Guard:
138
155
  `review-pr-<n>` session names no repo, and holds its PR number anywhere.
139
156
 
140
157
  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`).
158
+ REVIEW- checkout -- every fresh round starts that way, since the reviewer
159
+ creates its review task after it spawns. Such a round holds the checkouts
160
+ in its hub that are NEWER than its spawn time less `HOLD_SKEW_SECONDS`
161
+ (issue #185): a checkout older than every unknown-ref round in the hub
162
+ cannot belong to any of them. `all_checkouts` keeps, per hub, the
163
+ earliest such bound; None there means a round whose spawn time is not
164
+ known either, and holds every checkout in the hub, as before issue #185
165
+ -- the hold that, with studio reviews running near-continuously, kept 78
166
+ dead checkouts (~44 GB) and filled the fleet's volume on 2026-10-21.
142
167
  """
143
168
 
144
169
  numbers: "dict[str | None, set[int]]" = field(default_factory=dict)
145
170
  tasks: "dict[str | None, set[str]]" = field(default_factory=dict)
146
- all_checkouts: "set[str | None]" = field(default_factory=set)
171
+ all_checkouts: "dict[str | None, float | None]" = field(default_factory=dict)
147
172
 
148
173
  def hold(
149
- self, repo: "str | None", number: int, task_ref: "str | None" = None
174
+ self,
175
+ repo: "str | None",
176
+ number: int,
177
+ task_ref: "str | None" = None,
178
+ since: "float | None" = None,
150
179
  ) -> None:
151
180
  """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."""
181
+ session-name slug; None = any hub) and the checkout its task names.
182
+ With no task ref, `since` (the round's spawn time, epoch seconds)
183
+ bounds the checkouts held in the hub; without it, every one is."""
153
184
  slug = None if repo is None else session_repo_slug(repo.rpartition("/")[2])
154
185
  self.numbers.setdefault(slug, set()).add(int(number))
155
186
  ref = (task_ref or "").strip()
156
187
  if ref:
157
188
  self.tasks.setdefault(slug, set()).add(ref)
158
- else:
159
- self.all_checkouts.add(slug)
189
+ return
190
+ bound = None if since is None else float(since) - HOLD_SKEW_SECONDS
191
+ if slug in self.all_checkouts:
192
+ prev = self.all_checkouts[slug]
193
+ bound = None if prev is None or bound is None else min(prev, bound)
194
+ self.all_checkouts[slug] = bound
160
195
 
161
196
  def _in(self, table: "dict[str | None, set]", slug: str, value: object) -> bool:
162
197
  return value in table.get(slug, ()) or value in table.get(None, ())
@@ -164,8 +199,19 @@ class Guard:
164
199
  def _any(self, slug: str) -> bool:
165
200
  return bool(self.numbers.get(slug) or self.numbers.get(None))
166
201
 
167
- def holds(self, candidate: Candidate) -> "str | None":
168
- """Why `candidate` must stay, or None when nothing holds it."""
202
+ def holds(
203
+ self,
204
+ candidate: Candidate,
205
+ mtime: "float | None" = None,
206
+ void_before: "float | None" = None,
207
+ ) -> "str | None":
208
+ """Why `candidate` must stay, or None when nothing holds it.
209
+
210
+ `mtime` is the candidate's age stamp (`newest_mtime`); a review
211
+ checkout with none is held by every hub-wide hold, bounded or not.
212
+ `void_before` is the pressure backstop (issue #185): a hub-wide hold
213
+ does not reach a checkout whose stamp is older than it. A known task
214
+ ref's hold and a scratch folder's are never voided."""
169
215
  slug = candidate.hub_slug
170
216
  if candidate.kind == KIND_SCRATCH:
171
217
  if candidate.number is None:
@@ -173,10 +219,18 @@ class Guard:
173
219
  if self._in(self.numbers, slug, candidate.number):
174
220
  return f"PR #{candidate.number} has a live session or open round"
175
221
  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
222
  if candidate.task_ref and self._in(self.tasks, slug, candidate.task_ref):
179
223
  return f"{candidate.task_ref} has a live session or open round"
224
+ if mtime is not None and void_before is not None and mtime < void_before:
225
+ return None
226
+ for key in (slug, None):
227
+ if key not in self.all_checkouts:
228
+ continue
229
+ bound = self.all_checkouts[key]
230
+ if bound is None:
231
+ return HUB_HOLD_UNBOUNDED
232
+ if mtime is None or mtime >= bound:
233
+ return HUB_HOLD_BOUNDED
180
234
  return None
181
235
 
182
236
 
@@ -199,11 +253,27 @@ class SweepResult:
199
253
  # in a dry run, would-be removals) only that shorter grace allowed.
200
254
  pressure: bool = False
201
255
  pressure_removed: int = 0
256
+ # Issue #185: bytes held per "<hub slug>: <reason>", so held growth is
257
+ # visible before it fills the disk; held folders not sized because the
258
+ # sweep's time budget ran out; and, when the backstop applied, how many
259
+ # checkouts only it released from a hub-wide hold.
260
+ held_bytes: "dict[str, int]" = field(default_factory=dict)
261
+ held_unsized: int = 0
262
+ backstop: bool = False
263
+ backstop_released: int = 0
202
264
 
203
265
  @property
204
266
  def freed(self) -> int:
205
267
  return sum(size for _, size in self.removed)
206
268
 
269
+ @property
270
+ def held_total(self) -> int:
271
+ return sum(self.held_bytes.values())
272
+
273
+ def top_held(self, limit: int = 5) -> "list[tuple[str, int]]":
274
+ """The reasons holding the most bytes, largest first."""
275
+ return sorted(self.held_bytes.items(), key=lambda kv: (-kv[1], kv[0]))[:limit]
276
+
207
277
  @property
208
278
  def left(self) -> int:
209
279
  """Candidate folders still on disk after a live sweep: inside the
@@ -211,6 +281,29 @@ class SweepResult:
211
281
  return self.fresh + len(self.held) + self.deferred + len(self.failed)
212
282
 
213
283
 
284
+ def held_reasons(pairs: "Iterable[tuple[str, int]]") -> str:
285
+ """`; <hub>: <reason> = <size>, ...` for the log lines, or '' when none."""
286
+ parts = [f"{key} = {human(size)}" for key, size in pairs]
287
+ return ("; " + ", ".join(parts)) if parts else ""
288
+
289
+
290
+ # How many held reasons the sweep summary names before folding the rest.
291
+ HELD_REASONS_LOGGED = 10
292
+
293
+
294
+ def held_summary(result: SweepResult) -> str:
295
+ """The sweep summary's held-bytes tail (issue #185): every hub/reason
296
+ holding bytes, largest first, at most HELD_REASONS_LOGGED of them, and
297
+ how many held folders went unsized."""
298
+ tail = held_reasons(result.top_held(HELD_REASONS_LOGGED))
299
+ rest = len(result.held_bytes) - HELD_REASONS_LOGGED
300
+ if rest > 0:
301
+ tail += f", +{rest} more reason(s)"
302
+ if result.held_unsized:
303
+ tail += f"; {result.held_unsized} held folder(s) not sized this sweep"
304
+ return tail
305
+
306
+
214
307
  def layout_supported(
215
308
  hub_for: "Callable[[str, str], Path]", workspace_root: "Path | str"
216
309
  ) -> bool:
@@ -434,6 +527,8 @@ def sweep(
434
527
  clock: "Callable[[], float]" = time.monotonic,
435
528
  pressure_grace_seconds: "float | None" = None,
436
529
  under_pressure: "Callable[[], bool] | None" = None,
530
+ stale_round_seconds: "float | None" = None,
531
+ sizes: "dict[Path, tuple[float, int]] | None" = None,
437
532
  ) -> SweepResult:
438
533
  """Remove every candidate under `hubs` older than `grace_seconds` that
439
534
  `guard` does not hold, oldest first, then prune each hub's worktree
@@ -456,7 +551,18 @@ def sweep(
456
551
  Dry-run removes nothing and prunes nothing: it logs each folder it WOULD
457
552
  remove (with its size) and reports them in `would_remove`, under the
458
553
  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)."""
554
+ nothing, so under pressure it reports every pressure-eligible folder).
555
+
556
+ The pressure backstop (issue #185): when `stale_round_seconds` is given
557
+ (the caller passes STALE_ROUND_SECONDS at or above `BACKSTOP_PCT`), a
558
+ hub-wide hold -- a round with no review-task ref -- is void for a
559
+ checkout older than that window: no live round can own a checkout older
560
+ than the stale window. `backstop_released` counts what only it freed.
561
+
562
+ Every held folder is sized into `held_bytes` per hub and reason. `sizes`
563
+ (path -> (mtime, bytes)), when given, caches those walks across sweeps
564
+ and is re-keyed to this sweep's held folders; sizing stops at the time
565
+ budget too, and what was left unsized is counted in `held_unsized`."""
460
566
  result = SweepResult()
461
567
  started = clock()
462
568
  bounded = False
@@ -466,6 +572,11 @@ def sweep(
466
572
  result.pressure = under_pressure()
467
573
  if result.pressure:
468
574
  shrunk = min(grace_seconds, pressure_grace_seconds)
575
+ void_before = None
576
+ if stale_round_seconds is not None:
577
+ result.backstop = True
578
+ void_before = now - stale_round_seconds
579
+ seen_sizes: "dict[Path, tuple[float, int]]" = {}
469
580
  aged: "list[tuple[float, Candidate]]" = []
470
581
  for hub in hub_list:
471
582
  result.hubs += 1
@@ -480,10 +591,29 @@ def sweep(
480
591
  if age < shrunk:
481
592
  result.fresh += 1
482
593
  continue
483
- why = guard.holds(candidate)
594
+ why = guard.holds(candidate, mtime)
595
+ if why is not None and void_before is not None:
596
+ if guard.holds(candidate, mtime, void_before) is None:
597
+ result.backstop_released += 1
598
+ log.info(
599
+ "janitor: backstop releases %s (%.1f h old) from a hub-wide "
600
+ "hold — %s", candidate.path, age / 3600, why,
601
+ )
602
+ why = None
484
603
  if why is not None:
485
604
  log.debug("janitor: keeping %s — %s", candidate.path, why)
486
605
  result.held.append((candidate.path, why))
606
+ key = f"{candidate.hub_slug}: {why}"
607
+ cached = (sizes or {}).get(candidate.path)
608
+ if cached is not None and cached[0] == mtime:
609
+ size = cached[1]
610
+ elif clock() - started >= max_seconds:
611
+ result.held_unsized += 1
612
+ continue
613
+ else:
614
+ size = dir_bytes(candidate.path)
615
+ seen_sizes[candidate.path] = (mtime, size)
616
+ result.held_bytes[key] = result.held_bytes.get(key, 0) + size
487
617
  continue
488
618
  early = age < grace_seconds
489
619
  if early and not dry_run and not (under_pressure and under_pressure()):
@@ -520,6 +650,9 @@ def sweep(
520
650
  result.removed.append((candidate.path, size))
521
651
  if early:
522
652
  result.pressure_removed += 1
653
+ if sizes is not None:
654
+ sizes.clear()
655
+ sizes.update(seen_sizes)
523
656
  if dry_run:
524
657
  return result
525
658
  for hub in hub_list:
@@ -2847,6 +2847,9 @@ 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
+ # (issue #185): the held-bytes summary re-walks only what changed.
2852
+ self._janitor_sizes: "dict[Path, tuple[float, int]]" = {}
2850
2853
  # The (repo, number, round) whose alive session the stale-round probe
2851
2854
  # just killed as `wedged:first-run-dialog` (issue #136), so the
2852
2855
  # respawn site logs its re-enqueue at INFO instead of a second
@@ -8363,8 +8366,9 @@ class ReviewWatcher:
8363
8366
  stamp = time.time() if now is None else now
8364
8367
  pct = self._volume_used_pct()
8365
8368
  pressed = pct is not None and pct >= self.config.janitor_warn_pct
8369
+ backstop = pct is not None and pct >= janitor_mod.BACKSTOP_PCT
8366
8370
  try:
8367
- result = self._sweep_workspace(stamp, pressed=pressed)
8371
+ result = self._sweep_workspace(stamp, pressed=pressed, backstop=backstop)
8368
8372
  except Exception as exc:
8369
8373
  log.warning(
8370
8374
  "janitor: sweep failed unexpectedly (%s: %s) — nothing more is "
@@ -8391,7 +8395,7 @@ class ReviewWatcher:
8391
8395
  return pct is not None and pct >= self.config.janitor_warn_pct
8392
8396
 
8393
8397
  def _sweep_workspace(
8394
- self, now: float, *, pressed: bool = False
8398
+ self, now: float, *, pressed: bool = False, backstop: bool = False
8395
8399
  ) -> "janitor_mod.SweepResult | None":
8396
8400
  if not janitor_mod.layout_supported(
8397
8401
  self.config.hub_for, self.config.workspace_root
@@ -8426,7 +8430,17 @@ class ReviewWatcher:
8426
8430
  self.config.janitor_pressure_grace_minutes * 60 if pressed else None
8427
8431
  ),
8428
8432
  under_pressure=self._volume_pressed if pressed else None,
8433
+ stale_round_seconds=STALE_ROUND_SECONDS if backstop else None,
8434
+ sizes=self._janitor_sizes,
8429
8435
  )
8436
+ if result.backstop:
8437
+ log.warning(
8438
+ "janitor: volume at or above %d%% — hub-wide holds are void for "
8439
+ "checkouts older than %d minute(s); %d released; %s held%s",
8440
+ int(janitor_mod.BACKSTOP_PCT), STALE_ROUND_SECONDS // 60,
8441
+ result.backstop_released, janitor_mod.human(result.held_total),
8442
+ janitor_mod.held_reasons(result.top_held()),
8443
+ )
8430
8444
  if result.pressure:
8431
8445
  log.info(
8432
8446
  "janitor: volume at or above %d%% — the grace dropped to %d "
@@ -8439,20 +8453,22 @@ class ReviewWatcher:
8439
8453
  if self.config.dry_run:
8440
8454
  log.info(
8441
8455
  "[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",
8456
+ "hub(s); %d held by a live round (%s), %d inside the grace, %d "
8457
+ "deferred to the next interval%s",
8444
8458
  len(result.would_remove),
8445
8459
  janitor_mod.human(sum(size for _, size in result.would_remove)),
8446
- result.hubs, len(result.held), result.fresh, result.deferred,
8460
+ result.hubs, len(result.held), janitor_mod.human(result.held_total),
8461
+ result.fresh, result.deferred, janitor_mod.held_summary(result),
8447
8462
  )
8448
8463
  else:
8449
8464
  log.info(
8450
8465
  "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",
8466
+ "%d held by a live round (%s), %d inside the grace, %d failed, %d "
8467
+ "deferred to the next interval%s",
8453
8468
  len(result.removed), janitor_mod.human(result.freed),
8454
- result.hubs, len(result.held), result.fresh, len(result.failed),
8455
- result.deferred,
8469
+ result.hubs, len(result.held), janitor_mod.human(result.held_total),
8470
+ result.fresh, len(result.failed), result.deferred,
8471
+ janitor_mod.held_summary(result),
8456
8472
  )
8457
8473
  return result
8458
8474
 
@@ -8465,7 +8481,11 @@ class ReviewWatcher:
8465
8481
  Open rounds are the ledger's own definition (`in_flight_repos`): a
8466
8482
  spawn younger than the stale-round window whose session was not
8467
8483
  reaped, and a verdict post still owed. The roster is the pass's
8468
- post-reap listing when the sweep produced one."""
8484
+ post-reap listing when the sweep produced one.
8485
+
8486
+ Every hold carries its round's spawn time (issue #185), so a round
8487
+ whose review-task ref is not known yet holds only the checkouts in
8488
+ its hub made since it spawned."""
8469
8489
  sessions = self._pass_roster
8470
8490
  if sessions is None:
8471
8491
  try:
@@ -8480,17 +8500,22 @@ class ReviewWatcher:
8480
8500
  continue
8481
8501
  row = self.state.find_spawn_by_session(ses.name)
8482
8502
  if row is not None:
8483
- guard.hold(str(row["repo"]), int(row["number"]), row["task_ref"])
8503
+ guard.hold(
8504
+ str(row["repo"]), int(row["number"]), row["task_ref"],
8505
+ since=row["spawned_at"],
8506
+ )
8484
8507
  else:
8485
8508
  # No ledger row (a skill-shaped `review-pr-<n>`): the name is
8486
8509
  # the only evidence, and a name without a repo holds its PR
8487
- # number in every hub.
8510
+ # number in every hub. The roster reports no start time, so
8511
+ # its hold on the hub's checkouts stays unbounded (issue
8512
+ # #185); the >=95% backstop is what reaches past it.
8488
8513
  guard.hold(ref.repo, ref.number)
8489
8514
  for spawn_row in self.state.read_spawns(since=now - STALE_ROUND_SECONDS):
8490
8515
  if not self.state.is_reaped(str(spawn_row["session"])):
8491
8516
  guard.hold(
8492
8517
  str(spawn_row["repo"]), int(spawn_row["number"]),
8493
- spawn_row["task_ref"],
8518
+ spawn_row["task_ref"], since=spawn_row["spawned_at"],
8494
8519
  )
8495
8520
  for post in self.state.read_verdict_posts():
8496
8521
  if post["posted_at"] is None and post["abandoned_at"] is None:
@@ -8500,6 +8525,7 @@ class ReviewWatcher:
8500
8525
  guard.hold(
8501
8526
  str(post["repo"]), int(post["number"]),
8502
8527
  spawn["task_ref"] if spawn is not None else None,
8528
+ since=spawn["spawned_at"] if spawn is not None else None,
8503
8529
  )
8504
8530
  return guard
8505
8531
 
@@ -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.24
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera