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