alissa-tools-github-revloop 0.31.19__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.
- {alissa_tools_github_revloop-0.31.19/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.20}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/config.py +29 -1
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/janitor.py +141 -47
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/loop.py +109 -17
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/loop_events.py +59 -1
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/prompts.py +5 -2
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/trust.py +10 -1
- alissa_tools_github_revloop-0.31.20/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.31.19/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/README.md +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/setup.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/parking.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/state.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -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,12 +31,17 @@ 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
|
|
30
|
-
else under a hub -- `main/`'s own files
|
|
31
|
-
operator's clone -- is ever
|
|
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.
|
|
@@ -184,11 +194,22 @@ class SweepResult:
|
|
|
184
194
|
pruned: int = 0
|
|
185
195
|
# Unheld stale candidates the per-sweep bound left for the next interval.
|
|
186
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
|
|
187
202
|
|
|
188
203
|
@property
|
|
189
204
|
def freed(self) -> int:
|
|
190
205
|
return sum(size for _, size in self.removed)
|
|
191
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
|
+
|
|
192
213
|
|
|
193
214
|
def layout_supported(
|
|
194
215
|
hub_for: "Callable[[str, str], Path]", workspace_root: "Path | str"
|
|
@@ -255,8 +276,9 @@ def _dirs(parent: Path, prefix: str) -> "list[Path]":
|
|
|
255
276
|
|
|
256
277
|
|
|
257
278
|
def scan_hub(hub: Path) -> "list[Candidate]":
|
|
258
|
-
"""The janitor's candidates in one hub: `main/.review-scratch
|
|
259
|
-
`REVIEW
|
|
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."""
|
|
260
282
|
found = []
|
|
261
283
|
for path in _dirs(hub / "main", SCRATCH_PREFIX):
|
|
262
284
|
match = _SCRATCH_PR_RE.match(path.name[len(SCRATCH_PREFIX):])
|
|
@@ -264,7 +286,9 @@ def scan_hub(hub: Path) -> "list[Candidate]":
|
|
|
264
286
|
path=path, hub=hub, kind=KIND_SCRATCH,
|
|
265
287
|
number=int(match["number"]) if match else None,
|
|
266
288
|
))
|
|
267
|
-
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
|
+
):
|
|
268
292
|
found.append(Candidate(
|
|
269
293
|
path=path, hub=hub, kind=KIND_REVIEW,
|
|
270
294
|
task_ref=path.name[len(REVIEW_CHECKOUT_PREFIX):],
|
|
@@ -342,17 +366,53 @@ def disk_used_pct(path: "Path | str") -> "float | None":
|
|
|
342
366
|
|
|
343
367
|
def _contained(candidate: Candidate) -> bool:
|
|
344
368
|
"""The containment rule, re-asserted at the removal site: the folder is
|
|
345
|
-
a direct child of
|
|
346
|
-
|
|
347
|
-
|
|
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)
|
|
348
373
|
try:
|
|
349
374
|
if candidate.path.is_symlink():
|
|
350
375
|
return False
|
|
351
|
-
|
|
376
|
+
parent = candidate.path.resolve().parent
|
|
377
|
+
return any(parent == allowed.resolve() for allowed in parents)
|
|
378
|
+
except OSError:
|
|
379
|
+
return False
|
|
380
|
+
|
|
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()
|
|
352
388
|
except OSError:
|
|
353
389
|
return False
|
|
354
390
|
|
|
355
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
|
+
|
|
356
416
|
def prune_worktrees(hub: Path, runner: Runner = run) -> None:
|
|
357
417
|
"""`git worktree prune` in the hub's `.source`, as this process's user
|
|
358
418
|
(the seat's own): drops the records of worktrees whose folders are gone
|
|
@@ -372,9 +432,20 @@ def sweep(
|
|
|
372
432
|
max_removals: int = MAX_REMOVALS,
|
|
373
433
|
max_seconds: float = MAX_SWEEP_SECONDS,
|
|
374
434
|
clock: "Callable[[], float]" = time.monotonic,
|
|
435
|
+
pressure_grace_seconds: "float | None" = None,
|
|
436
|
+
under_pressure: "Callable[[], bool] | None" = None,
|
|
375
437
|
) -> SweepResult:
|
|
376
438
|
"""Remove every candidate under `hubs` older than `grace_seconds` that
|
|
377
|
-
`guard` does not hold, then prune each hub's worktree
|
|
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.
|
|
378
449
|
|
|
379
450
|
Bounded (issue #175): before each removal, a sweep that has already
|
|
380
451
|
removed `max_removals` folders, or has run `max_seconds` by `clock`,
|
|
@@ -384,51 +455,74 @@ def sweep(
|
|
|
384
455
|
|
|
385
456
|
Dry-run removes nothing and prunes nothing: it logs each folder it WOULD
|
|
386
457
|
remove (with its size) and reports them in `would_remove`, under the
|
|
387
|
-
same bound -- so it reports what one live sweep would do
|
|
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)."""
|
|
388
460
|
result = SweepResult()
|
|
389
461
|
started = clock()
|
|
390
462
|
bounded = False
|
|
391
|
-
|
|
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:
|
|
392
471
|
result.hubs += 1
|
|
393
472
|
for candidate in scan_hub(hub):
|
|
394
473
|
mtime = newest_mtime(candidate.path)
|
|
395
474
|
if mtime is None:
|
|
396
475
|
continue # vanished mid-sweep
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if dry_run
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
except OSError as exc:
|
|
425
|
-
log.warning("janitor: could not remove %s (%s)", candidate.path, exc)
|
|
426
|
-
result.failed.append((candidate.path, str(exc)))
|
|
427
|
-
continue
|
|
428
|
-
log.debug("janitor: removed %s (%s)", candidate.path, human(size))
|
|
429
|
-
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)
|
|
430
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)))
|
|
431
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:
|
|
432
526
|
try:
|
|
433
527
|
prune_worktrees(hub, runner)
|
|
434
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
|
|
1127
|
-
# `janitor_grace_minutes`
|
|
1128
|
-
#
|
|
1129
|
-
#
|
|
1130
|
-
#
|
|
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 = (
|
|
@@ -2622,6 +2632,12 @@ class ReviewWatcher:
|
|
|
2622
2632
|
# Whether the janitor has said, once for this process, that it will
|
|
2623
2633
|
# not sweep under this `hub_template` (issue #175).
|
|
2624
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
|
|
2625
2641
|
# The (repo, number, round) whose alive session the stale-round probe
|
|
2626
2642
|
# just killed as `wedged:first-run-dialog` (issue #136), so the
|
|
2627
2643
|
# respawn site logs its re-enqueue at INFO instead of a second
|
|
@@ -7960,8 +7976,10 @@ class ReviewWatcher:
|
|
|
7960
7976
|
return None
|
|
7961
7977
|
self._janitor_last = mono
|
|
7962
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
|
|
7963
7981
|
try:
|
|
7964
|
-
result = self._sweep_workspace(stamp)
|
|
7982
|
+
result = self._sweep_workspace(stamp, pressed=pressed)
|
|
7965
7983
|
except Exception as exc:
|
|
7966
7984
|
log.warning(
|
|
7967
7985
|
"janitor: sweep failed unexpectedly (%s: %s) — nothing more is "
|
|
@@ -7969,10 +7987,27 @@ class ReviewWatcher:
|
|
|
7969
7987
|
type(exc).__name__, exc, exc_info=True,
|
|
7970
7988
|
)
|
|
7971
7989
|
result = None
|
|
7972
|
-
self.
|
|
7990
|
+
self._note_volume(pct, result, stamp)
|
|
7973
7991
|
return result
|
|
7974
7992
|
|
|
7975
|
-
def
|
|
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":
|
|
7976
8011
|
if not janitor_mod.layout_supported(
|
|
7977
8012
|
self.config.hub_for, self.config.workspace_root
|
|
7978
8013
|
):
|
|
@@ -8002,7 +8037,20 @@ class ReviewWatcher:
|
|
|
8002
8037
|
grace_seconds=self.config.janitor_grace_minutes * 60,
|
|
8003
8038
|
now=now,
|
|
8004
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,
|
|
8005
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
|
+
)
|
|
8006
8054
|
if self.config.dry_run:
|
|
8007
8055
|
log.info(
|
|
8008
8056
|
"[dry-run] janitor: would remove %d folder(s) (%s) across %d "
|
|
@@ -8070,18 +8118,62 @@ class ReviewWatcher:
|
|
|
8070
8118
|
)
|
|
8071
8119
|
return guard
|
|
8072
8120
|
|
|
8073
|
-
def
|
|
8121
|
+
def _note_volume(
|
|
8122
|
+
self,
|
|
8123
|
+
pct: "float | None",
|
|
8124
|
+
result: "janitor_mod.SweepResult | None",
|
|
8125
|
+
stamp: float,
|
|
8126
|
+
) -> None:
|
|
8074
8127
|
"""One WARNING when the workspace volume is at or above
|
|
8075
|
-
`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."""
|
|
8076
8136
|
threshold = self.config.janitor_warn_pct
|
|
8077
|
-
if not threshold:
|
|
8137
|
+
if not threshold or pct is None:
|
|
8078
8138
|
return
|
|
8079
|
-
pct
|
|
8080
|
-
|
|
8081
|
-
|
|
8082
|
-
|
|
8083
|
-
|
|
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,
|
|
8084
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
|
+
)
|
|
8085
8177
|
|
|
8086
8178
|
def _vitals_config(self) -> dict:
|
|
8087
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
|
|
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
|
|
20
|
-
|
|
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
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.31.20
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.31.19
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{alissa_tools_github_revloop-0.31.19 → alissa_tools_github_revloop-0.31.20}/requirements.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|