alissa-tools-github-revloop 0.31.14__tar.gz → 0.31.15__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.14/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.15}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/__main__.py +55 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/config.py +103 -1
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/fleet_vitals.py +11 -1
- alissa_tools_github_revloop-0.31.15/src/main/alissa/tools/github/revloop/janitor.py +373 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/loop.py +137 -1
- alissa_tools_github_revloop-0.31.15/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
- alissa_tools_github_revloop-0.31.14/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/README.md +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/setup.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/parking.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/state.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/trust.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -16,6 +16,10 @@ from .config import (
|
|
|
16
16
|
FLEET_VITALS_ENV,
|
|
17
17
|
HUB_ADD,
|
|
18
18
|
HUB_SKIP,
|
|
19
|
+
JANITOR_ENABLED_ENV,
|
|
20
|
+
JANITOR_GRACE_MINUTES_ENV,
|
|
21
|
+
JANITOR_INTERVAL_MINUTES_ENV,
|
|
22
|
+
JANITOR_WARN_PCT_ENV,
|
|
19
23
|
LOOP_EVENTS_ENV,
|
|
20
24
|
ON_MISSING_CREATE,
|
|
21
25
|
ON_MISSING_SKIP,
|
|
@@ -280,6 +284,45 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
280
284
|
action="store_false",
|
|
281
285
|
help="do not push fleet vitals even if the config enables it",
|
|
282
286
|
)
|
|
287
|
+
janitor = over.add_mutually_exclusive_group()
|
|
288
|
+
janitor.add_argument(
|
|
289
|
+
"--janitor",
|
|
290
|
+
dest="janitor_enabled",
|
|
291
|
+
action="store_true",
|
|
292
|
+
default=None,
|
|
293
|
+
help="remove each hub's stale main/.review-scratch-* and REVIEW-* "
|
|
294
|
+
"folders that no live session or open round holds, prune the "
|
|
295
|
+
"worktree records, and warn on a full volume (the default). "
|
|
296
|
+
f"Overridden by ${JANITOR_ENABLED_ENV}",
|
|
297
|
+
)
|
|
298
|
+
janitor.add_argument(
|
|
299
|
+
"--no-janitor",
|
|
300
|
+
dest="janitor_enabled",
|
|
301
|
+
action="store_false",
|
|
302
|
+
help="never sweep the workspace, even if the config enables it",
|
|
303
|
+
)
|
|
304
|
+
over.add_argument(
|
|
305
|
+
"--janitor-grace-minutes",
|
|
306
|
+
type=int,
|
|
307
|
+
metavar="MINUTES",
|
|
308
|
+
help="how old (by mtime) a scratch folder or REVIEW- checkout must be "
|
|
309
|
+
"before the janitor may remove it (default: 1440). "
|
|
310
|
+
f"Overridden by ${JANITOR_GRACE_MINUTES_ENV}",
|
|
311
|
+
)
|
|
312
|
+
over.add_argument(
|
|
313
|
+
"--janitor-interval-minutes",
|
|
314
|
+
type=int,
|
|
315
|
+
metavar="MINUTES",
|
|
316
|
+
help="how often the janitor sweeps, at the end of a pass; 0 = every "
|
|
317
|
+
f"pass (default: 10). Overridden by ${JANITOR_INTERVAL_MINUTES_ENV}",
|
|
318
|
+
)
|
|
319
|
+
over.add_argument(
|
|
320
|
+
"--janitor-warn-pct",
|
|
321
|
+
type=int,
|
|
322
|
+
metavar="PERCENT",
|
|
323
|
+
help="workspace volume usage at or above which a sweep logs a "
|
|
324
|
+
f"WARNING; 0 = never (default: 80). Overridden by ${JANITOR_WARN_PCT_ENV}",
|
|
325
|
+
)
|
|
283
326
|
over.add_argument(
|
|
284
327
|
"--alissa-endpoint",
|
|
285
328
|
dest="alissa_endpoint",
|
|
@@ -336,6 +379,10 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
336
379
|
"task_list_bow_id": args.task_list_bow_id,
|
|
337
380
|
"loop_events_enabled": args.loop_events_enabled,
|
|
338
381
|
"fleet_vitals_enabled": args.fleet_vitals_enabled,
|
|
382
|
+
"janitor_enabled": args.janitor_enabled,
|
|
383
|
+
"janitor_grace_minutes": args.janitor_grace_minutes,
|
|
384
|
+
"janitor_interval_minutes": args.janitor_interval_minutes,
|
|
385
|
+
"janitor_warn_pct": args.janitor_warn_pct,
|
|
339
386
|
"alissa_endpoint": args.alissa_endpoint,
|
|
340
387
|
"dry_run": args.dry_run,
|
|
341
388
|
}
|
|
@@ -422,6 +469,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
422
469
|
config = resolve_config(args)
|
|
423
470
|
log.info("workspace: %s", config.workspace_root)
|
|
424
471
|
log.info("repos_source: %s", config.repos_source)
|
|
472
|
+
log.info(
|
|
473
|
+
"janitor: %s (grace %d min, every %s, warn at %s)",
|
|
474
|
+
"on" if config.janitor_enabled else "off",
|
|
475
|
+
config.janitor_grace_minutes,
|
|
476
|
+
f"{config.janitor_interval_minutes} min"
|
|
477
|
+
if config.janitor_interval_minutes else "pass",
|
|
478
|
+
f"{config.janitor_warn_pct}%" if config.janitor_warn_pct else "never",
|
|
479
|
+
)
|
|
425
480
|
config = resolve_feed_authority(config)
|
|
426
481
|
|
|
427
482
|
watcher = ReviewWatcher(config)
|
|
@@ -206,6 +206,10 @@ CONFIG_KEYS = (
|
|
|
206
206
|
"prompt_kill_minutes",
|
|
207
207
|
"prompt_max_answers",
|
|
208
208
|
"waiting_dir",
|
|
209
|
+
"janitor_enabled",
|
|
210
|
+
"janitor_grace_minutes",
|
|
211
|
+
"janitor_interval_minutes",
|
|
212
|
+
"janitor_warn_pct",
|
|
209
213
|
"alissa_endpoint",
|
|
210
214
|
"dry_run",
|
|
211
215
|
)
|
|
@@ -233,6 +237,14 @@ REPOS_SOURCE_ENV = "ALISSA_REVIEW_REPOS_SOURCE"
|
|
|
233
237
|
BOWS_REFRESH_POLLS_ENV = "ALISSA_REVIEW_BOWS_REFRESH_POLLS"
|
|
234
238
|
BOW_OWNERS_ENV = "ALISSA_REVIEW_BOW_OWNERS"
|
|
235
239
|
|
|
240
|
+
# The workspace janitor's four rails (issue #167). Same precedence and the
|
|
241
|
+
# same blank-falls-through rule as the bows rails, so a container can tune
|
|
242
|
+
# (or switch off) the janitor without a file edit on its volume.
|
|
243
|
+
JANITOR_ENABLED_ENV = "ALISSA_REVIEW_JANITOR_ENABLED"
|
|
244
|
+
JANITOR_GRACE_MINUTES_ENV = "ALISSA_REVIEW_JANITOR_GRACE_MINUTES"
|
|
245
|
+
JANITOR_INTERVAL_MINUTES_ENV = "ALISSA_REVIEW_JANITOR_INTERVAL_MINUTES"
|
|
246
|
+
JANITOR_WARN_PCT_ENV = "ALISSA_REVIEW_JANITOR_WARN_PCT"
|
|
247
|
+
|
|
236
248
|
# The plan directive's two allowlists (issue #148; genloop design §6.1, D12).
|
|
237
249
|
# A PR gets the PLAN directive only when all four signals hold, and two of
|
|
238
250
|
# them are fleet configuration: the repository is one of `plan_repos` and the
|
|
@@ -408,6 +420,36 @@ def env_bows_refresh_polls(
|
|
|
408
420
|
) from None
|
|
409
421
|
|
|
410
422
|
|
|
423
|
+
def _env_int(name: str, what: str, environ: "Mapping[str, str] | None") -> "int | None":
|
|
424
|
+
"""One integer env rail, or None when unset/blank. A non-integer raises,
|
|
425
|
+
naming the variable -- the startup phase turns that into `config error`."""
|
|
426
|
+
raw = _env_text(name, environ)
|
|
427
|
+
if raw is None:
|
|
428
|
+
return None
|
|
429
|
+
try:
|
|
430
|
+
return int(raw)
|
|
431
|
+
except ValueError:
|
|
432
|
+
raise ValueError(f"{name} must be an integer {what}, got {raw!r}") from None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def env_janitor(environ: "Mapping[str, str] | None" = None) -> "dict[str, Any]":
|
|
436
|
+
"""The janitor keys the environment sets (issue #167), as config-key ->
|
|
437
|
+
value; a key whose variable is unset or blank is absent."""
|
|
438
|
+
out: "dict[str, Any]" = {}
|
|
439
|
+
enabled = _env_bool(JANITOR_ENABLED_ENV, environ)
|
|
440
|
+
if enabled is not None:
|
|
441
|
+
out["janitor_enabled"] = enabled
|
|
442
|
+
for key, name, what in (
|
|
443
|
+
("janitor_grace_minutes", JANITOR_GRACE_MINUTES_ENV, "number of minutes"),
|
|
444
|
+
("janitor_interval_minutes", JANITOR_INTERVAL_MINUTES_ENV, "number of minutes"),
|
|
445
|
+
("janitor_warn_pct", JANITOR_WARN_PCT_ENV, "percentage"),
|
|
446
|
+
):
|
|
447
|
+
value = _env_int(name, what, environ)
|
|
448
|
+
if value is not None:
|
|
449
|
+
out[key] = value
|
|
450
|
+
return out
|
|
451
|
+
|
|
452
|
+
|
|
411
453
|
def env_bow_owners(environ: "Mapping[str, str] | None" = None) -> "str | None":
|
|
412
454
|
"""`ALISSA_REVIEW_BOW_OWNERS` verbatim, or None when unset/blank. The
|
|
413
455
|
`|`/`,` splitting and the actor-id check happen in `normalize_bow_owners`,
|
|
@@ -663,6 +705,23 @@ DEFAULT_VERDICT_COOLDOWN_SECONDS = 120
|
|
|
663
705
|
DEFAULT_REVIEW_TASK_MISS_TTL_POLLS = 10
|
|
664
706
|
|
|
665
707
|
|
|
708
|
+
# -- the workspace janitor (issue #167) ---------------------------------------
|
|
709
|
+
#
|
|
710
|
+
# How old a `.review-scratch-*` or `REVIEW-*` folder must be (by mtime) before
|
|
711
|
+
# the janitor may remove it. A day: far past any round's life (the stale-round
|
|
712
|
+
# window is 90 minutes), so the janitor only ever takes what every round that
|
|
713
|
+
# could want it has long finished with -- and the live-session / open-round
|
|
714
|
+
# guard holds anything a slow round is still using regardless.
|
|
715
|
+
DEFAULT_JANITOR_GRACE_MINUTES = 24 * 60
|
|
716
|
+
|
|
717
|
+
# How often the janitor sweeps, at the end of a pass. 0 = every pass.
|
|
718
|
+
DEFAULT_JANITOR_INTERVAL_MINUTES = 10
|
|
719
|
+
|
|
720
|
+
# The workspace volume's used percentage at or above which a sweep logs one
|
|
721
|
+
# WARNING. Devloop's threshold; 0 = never warn.
|
|
722
|
+
DEFAULT_JANITOR_WARN_PCT = 80
|
|
723
|
+
|
|
724
|
+
|
|
666
725
|
# -- the product-stability guard (issue #105) ---------------------------------
|
|
667
726
|
#
|
|
668
727
|
# How many consecutive `request_changes` rounds must move NOTHING outside
|
|
@@ -978,6 +1037,18 @@ class Config:
|
|
|
978
1037
|
# writes the same value here so the daemon looks where the hooks wrote.
|
|
979
1038
|
waiting_dir: str = DEFAULT_WAITING_DIR
|
|
980
1039
|
|
|
1040
|
+
# The workspace janitor (issue #167; janitor.py): every
|
|
1041
|
+
# `janitor_interval_minutes` (0 = every pass), at the end of a pass, remove
|
|
1042
|
+
# each hub's `main/.review-scratch-*` and `REVIEW-*` folders older than
|
|
1043
|
+
# `janitor_grace_minutes` that no live session or open ledger round holds,
|
|
1044
|
+
# prune `.source`'s worktree records, and WARN once when the workspace
|
|
1045
|
+
# volume is at or above `janitor_warn_pct` (0 = never). ON by default: the
|
|
1046
|
+
# disk it reclaims fills a shared volume within weeks without it.
|
|
1047
|
+
janitor_enabled: bool = True
|
|
1048
|
+
janitor_grace_minutes: int = DEFAULT_JANITOR_GRACE_MINUTES
|
|
1049
|
+
janitor_interval_minutes: int = DEFAULT_JANITOR_INTERVAL_MINUTES
|
|
1050
|
+
janitor_warn_pct: int = DEFAULT_JANITOR_WARN_PCT
|
|
1051
|
+
|
|
981
1052
|
dry_run: bool = False
|
|
982
1053
|
|
|
983
1054
|
def __post_init__(self) -> None:
|
|
@@ -1112,7 +1183,7 @@ class Config:
|
|
|
1112
1183
|
|
|
1113
1184
|
`environ` is the fourth layer and applies to the env-backed keys
|
|
1114
1185
|
(`task_list_bow_id`, `loop_events_enabled`, `fleet_vitals_enabled`,
|
|
1115
|
-
`repos_source`, `bows_refresh_polls`, `bow_owners`); it wins over both of the others
|
|
1186
|
+
`repos_source`, `bows_refresh_polls`, `bow_owners`, the `janitor_*` keys); it wins over both of the others
|
|
1116
1187
|
(see the module docstring). Defaults to the real environment, so
|
|
1117
1188
|
callers that do not care pass nothing.
|
|
1118
1189
|
"""
|
|
@@ -1187,6 +1258,8 @@ class Config:
|
|
|
1187
1258
|
env_owners = env_bow_owners(environ)
|
|
1188
1259
|
if env_owners is not None:
|
|
1189
1260
|
raw["bow_owners"] = env_owners
|
|
1261
|
+
# The janitor's rails (issue #167), same layer, same reason.
|
|
1262
|
+
raw.update(env_janitor(environ))
|
|
1190
1263
|
# The plan allowlists (issue #148), same rail, same reason.
|
|
1191
1264
|
env_plan_repos = env_plan_list(PLAN_REPOS_ENV, environ)
|
|
1192
1265
|
if env_plan_repos is not None:
|
|
@@ -1439,6 +1512,31 @@ class Config:
|
|
|
1439
1512
|
if not waiting_dir:
|
|
1440
1513
|
raise ValueError("waiting_dir must be a non-empty path")
|
|
1441
1514
|
|
|
1515
|
+
janitor_grace = int(
|
|
1516
|
+
raw.get("janitor_grace_minutes", cls.janitor_grace_minutes)
|
|
1517
|
+
)
|
|
1518
|
+
if janitor_grace < 1:
|
|
1519
|
+
# No 0-means-off here: `janitor_enabled` is the off switch, and a
|
|
1520
|
+
# zero grace would make a scratch folder removable the minute a
|
|
1521
|
+
# guard misses it.
|
|
1522
|
+
raise ValueError(
|
|
1523
|
+
f"janitor_grace_minutes must be >= 1, got {janitor_grace}"
|
|
1524
|
+
)
|
|
1525
|
+
janitor_interval = int(
|
|
1526
|
+
raw.get("janitor_interval_minutes", cls.janitor_interval_minutes)
|
|
1527
|
+
)
|
|
1528
|
+
if janitor_interval < 0:
|
|
1529
|
+
raise ValueError(
|
|
1530
|
+
f"janitor_interval_minutes must be >= 0 (0 = every pass), "
|
|
1531
|
+
f"got {janitor_interval}"
|
|
1532
|
+
)
|
|
1533
|
+
janitor_warn = int(raw.get("janitor_warn_pct", cls.janitor_warn_pct))
|
|
1534
|
+
if not 0 <= janitor_warn <= 100:
|
|
1535
|
+
raise ValueError(
|
|
1536
|
+
f"janitor_warn_pct must be between 0 and 100 (0 = never warn), "
|
|
1537
|
+
f"got {janitor_warn}"
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1442
1540
|
state_path = raw.get("state_path")
|
|
1443
1541
|
return cls(
|
|
1444
1542
|
workspace_root=Path(workspace_root),
|
|
@@ -1477,6 +1575,10 @@ class Config:
|
|
|
1477
1575
|
prompt_kill_minutes=prompt_kill,
|
|
1478
1576
|
prompt_max_answers=prompt_answers,
|
|
1479
1577
|
waiting_dir=waiting_dir,
|
|
1578
|
+
janitor_enabled=bool(raw.get("janitor_enabled", cls.janitor_enabled)),
|
|
1579
|
+
janitor_grace_minutes=janitor_grace,
|
|
1580
|
+
janitor_interval_minutes=janitor_interval,
|
|
1581
|
+
janitor_warn_pct=janitor_warn,
|
|
1480
1582
|
alissa_endpoint=endpoint,
|
|
1481
1583
|
dry_run=bool(raw.get("dry_run", False)),
|
|
1482
1584
|
)
|
|
@@ -86,6 +86,7 @@ from .alissa_client import (
|
|
|
86
86
|
AlissaError,
|
|
87
87
|
)
|
|
88
88
|
from .config import REPOS_BOWS, Config
|
|
89
|
+
from .janitor import disk_used_pct as volume_used_pct
|
|
89
90
|
from .proc import CommandError, run as proc_run
|
|
90
91
|
|
|
91
92
|
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
@@ -117,7 +118,10 @@ VITALS_FAILED = "failed"
|
|
|
117
118
|
# The blocks Studio's schema gained after the base contract, each in its own
|
|
118
119
|
# lane: `idle` (seat parking L3) and `config` (provisioner L5). A strict 400
|
|
119
120
|
# naming one of them is an old Studio, not a bad snapshot.
|
|
120
|
-
|
|
121
|
+
#
|
|
122
|
+
# `diskUsedPct` (issue #167) joins them: the workspace volume's used percent,
|
|
123
|
+
# which Studio's schema may not carry yet.
|
|
124
|
+
OPTIONAL_BLOCKS = ("idle", "config", "diskUsedPct")
|
|
121
125
|
|
|
122
126
|
# How long a snapshot leaves an optional block out after Studio refused it as
|
|
123
127
|
# an unknown key, before it offers the block again. An hour: one extra POST
|
|
@@ -296,6 +300,7 @@ def build_snapshot(
|
|
|
296
300
|
repos: "tuple[str, ...]" = (),
|
|
297
301
|
now: "int | float | None" = None,
|
|
298
302
|
config: "Mapping | None" = None,
|
|
303
|
+
disk_used_pct: "float | None" = None,
|
|
299
304
|
) -> dict:
|
|
300
305
|
"""The contract body for this pass, off the console's builders.
|
|
301
306
|
|
|
@@ -314,6 +319,9 @@ def build_snapshot(
|
|
|
314
319
|
|
|
315
320
|
`config` is the seat's `config_block`, or None to leave the key out (the
|
|
316
321
|
console's push, which has no derived answer to give).
|
|
322
|
+
|
|
323
|
+
`diskUsedPct` (issue #167) is the workspace volume's used percent, null
|
|
324
|
+
when it could not be read.
|
|
317
325
|
"""
|
|
318
326
|
stamp = time.time() if now is None else now
|
|
319
327
|
snaps = sources.snapshots(DURATION_POINTS)
|
|
@@ -365,6 +373,7 @@ def build_snapshot(
|
|
|
365
373
|
"queueDepth": _int_or_none(queue_depth),
|
|
366
374
|
"kpis": None,
|
|
367
375
|
"inbox": inbox,
|
|
376
|
+
"diskUsedPct": disk_used_pct,
|
|
368
377
|
"idle": idle,
|
|
369
378
|
}
|
|
370
379
|
if config is not None:
|
|
@@ -519,6 +528,7 @@ class FleetVitalsPusher:
|
|
|
519
528
|
repos=self.config.repos,
|
|
520
529
|
now=self._clock(),
|
|
521
530
|
config=self._config(),
|
|
531
|
+
disk_used_pct=volume_used_pct(self.config.workspace_root),
|
|
522
532
|
))
|
|
523
533
|
|
|
524
534
|
def _config(self) -> "Mapping | None":
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""The workspace janitor: reclaim the disk review rounds leave behind (issue #167).
|
|
2
|
+
|
|
3
|
+
A reviewer seat is read-only (CR6) and never deletes: `_REVIEWER_RULE` in
|
|
4
|
+
loop.py tells every session to write its scratch files under its own checkout
|
|
5
|
+
and LEAVE them. Two kinds of folder accumulate under each repo hub that way,
|
|
6
|
+
and nothing removed either before this module:
|
|
7
|
+
|
|
8
|
+
* `<hub>/main/.review-scratch-pr<N>-r<round>/` -- a round's pr.diff, envelope,
|
|
9
|
+
payload files and, often, a `wt/` git worktree (off `<hub>/.source`) with
|
|
10
|
+
its own `node_modules`: 250-950 MB apiece. On 2026-10-12 the Alissa fleet's
|
|
11
|
+
shared volume held 169 of them, 27 GB of a 46 GB volume.
|
|
12
|
+
* `<hub>/REVIEW-<task ref>/` -- the throwaway checkout the review skill lets a
|
|
13
|
+
reviewer create beside `main/` when it must run code (`trust.
|
|
14
|
+
review_checkout_name`). Another ~5 GB on the same day.
|
|
15
|
+
|
|
16
|
+
Modeled on devloop's janitor.py, but smaller, because what it removes is
|
|
17
|
+
smaller in kind: a reviewer's folders hold no unpushed work by construction
|
|
18
|
+
(the seat never pushes and never commits for keeps), so there is no holdout
|
|
19
|
+
policy and no branch to delete -- only a folder that is old, and that no live
|
|
20
|
+
session and no open ledger round still holds. The HOLD is the caller's
|
|
21
|
+
(`loop.ReviewWatcher._janitor_guard`, which owns the roster and the ledger);
|
|
22
|
+
this module states what a folder is, whether a `Guard` holds it, and removes
|
|
23
|
+
it.
|
|
24
|
+
|
|
25
|
+
Three properties everything here is written around:
|
|
26
|
+
|
|
27
|
+
* **Only the two shapes, only where they are made.** A scratch folder is a
|
|
28
|
+
`.review-scratch-*` DIRECT child of `<hub>/main`, a review checkout a
|
|
29
|
+
`REVIEW-*` direct child of the hub; symlinks are never candidates. Nothing
|
|
30
|
+
else under a hub -- `main/`'s own files, `.source`, a task worktree, an
|
|
31
|
+
operator's clone -- is ever considered, so it is never at risk.
|
|
32
|
+
* **Old AND unheld.** Age is the newest mtime of the folder and its direct
|
|
33
|
+
children (so a round that writes a fresh file into an old folder renews it),
|
|
34
|
+
and the grace is a day by default. A held folder is kept whatever its age.
|
|
35
|
+
* **Nothing raises past its own concern.** A folder that vanishes mid-sweep,
|
|
36
|
+
an unreadable hub, a failed removal: logged and skipped, because this runs
|
|
37
|
+
at the end of a poll pass that must complete.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import logging
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
import shutil
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Callable, Iterable
|
|
49
|
+
|
|
50
|
+
from .alissa import session_repo_slug
|
|
51
|
+
from .proc import CommandError, run
|
|
52
|
+
from .trust import REVIEW_CHECKOUT_PREFIX
|
|
53
|
+
|
|
54
|
+
log = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
# The scratch folder a reviewer writes under its spawn cwd (`<hub>/main`).
|
|
57
|
+
SCRATCH_PREFIX = ".review-scratch-"
|
|
58
|
+
|
|
59
|
+
# What a scratch folder's name says about its round: `pr<N>` and, usually,
|
|
60
|
+
# `-r<round>`. Prefix-matched after SCRATCH_PREFIX, so a suffix a session
|
|
61
|
+
# appended (`-2`, `.old`) still names its PR. A name that does not parse
|
|
62
|
+
# names no PR, and is held whenever anything in its hub is (see Guard).
|
|
63
|
+
_SCRATCH_PR_RE = re.compile(r"pr(?P<number>\d+)(?:-r(?P<round>\d+))?")
|
|
64
|
+
|
|
65
|
+
# The hub's bare repository, which every worktree (`main/`, a REVIEW-
|
|
66
|
+
# checkout, a scratch folder's `wt/`) points into. Its presence is what makes
|
|
67
|
+
# a workspace child a hub.
|
|
68
|
+
SOURCE_DIR = ".source"
|
|
69
|
+
|
|
70
|
+
KIND_SCRATCH = "scratch"
|
|
71
|
+
KIND_REVIEW = "review-checkout"
|
|
72
|
+
|
|
73
|
+
Runner = Callable[..., str]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Candidate:
|
|
78
|
+
"""One folder the janitor may remove: its path, which hub it is under,
|
|
79
|
+
what kind it is, and what its name says it belongs to -- the PR number for
|
|
80
|
+
a scratch folder (None when the name does not parse), the task ref for a
|
|
81
|
+
review checkout."""
|
|
82
|
+
|
|
83
|
+
path: Path
|
|
84
|
+
hub: Path
|
|
85
|
+
kind: str
|
|
86
|
+
number: "int | None" = None
|
|
87
|
+
task_ref: "str | None" = None
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def hub_slug(self) -> str:
|
|
91
|
+
"""The hub's repo as a session name carries it (`session_repo_slug`),
|
|
92
|
+
so a hub, a ledger row and a session name compare on one key."""
|
|
93
|
+
return session_repo_slug(self.hub.name)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class Guard:
|
|
98
|
+
"""What the live roster and the open ledger rounds hold, per hub.
|
|
99
|
+
|
|
100
|
+
Keyed by the repo SLUG (`session_repo_slug` of the repo name) -- the one
|
|
101
|
+
form a hub directory, a ledger row and a session name all reduce to. Two
|
|
102
|
+
repos sharing a name under different owners share a slug, which only ever
|
|
103
|
+
holds MORE than needed. The key None means "every hub": a skill-shaped
|
|
104
|
+
`review-pr-<n>` session names no repo, and holds its PR number anywhere.
|
|
105
|
+
|
|
106
|
+
A round whose review-task ref is unknown cannot be mapped to a single
|
|
107
|
+
REVIEW- checkout, so it holds every checkout in its hub (`all_checkouts`).
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
numbers: "dict[str | None, set[int]]" = field(default_factory=dict)
|
|
111
|
+
tasks: "dict[str | None, set[str]]" = field(default_factory=dict)
|
|
112
|
+
all_checkouts: "set[str | None]" = field(default_factory=set)
|
|
113
|
+
|
|
114
|
+
def hold(
|
|
115
|
+
self, repo: "str | None", number: int, task_ref: "str | None" = None
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Hold PR `number` of `repo` (`owner/name`, a bare name, or a
|
|
118
|
+
session-name slug; None = any hub) and the checkout its task names."""
|
|
119
|
+
slug = None if repo is None else session_repo_slug(repo.rpartition("/")[2])
|
|
120
|
+
self.numbers.setdefault(slug, set()).add(int(number))
|
|
121
|
+
ref = (task_ref or "").strip()
|
|
122
|
+
if ref:
|
|
123
|
+
self.tasks.setdefault(slug, set()).add(ref)
|
|
124
|
+
else:
|
|
125
|
+
self.all_checkouts.add(slug)
|
|
126
|
+
|
|
127
|
+
def _in(self, table: "dict[str | None, set]", slug: str, value: object) -> bool:
|
|
128
|
+
return value in table.get(slug, ()) or value in table.get(None, ())
|
|
129
|
+
|
|
130
|
+
def _any(self, slug: str) -> bool:
|
|
131
|
+
return bool(self.numbers.get(slug) or self.numbers.get(None))
|
|
132
|
+
|
|
133
|
+
def holds(self, candidate: Candidate) -> "str | None":
|
|
134
|
+
"""Why `candidate` must stay, or None when nothing holds it."""
|
|
135
|
+
slug = candidate.hub_slug
|
|
136
|
+
if candidate.kind == KIND_SCRATCH:
|
|
137
|
+
if candidate.number is None:
|
|
138
|
+
return "unnamed scratch in a hub with a live round" if self._any(slug) else None
|
|
139
|
+
if self._in(self.numbers, slug, candidate.number):
|
|
140
|
+
return f"PR #{candidate.number} has a live session or open round"
|
|
141
|
+
return None
|
|
142
|
+
if slug in self.all_checkouts or None in self.all_checkouts:
|
|
143
|
+
return "a live round in this hub names no review task"
|
|
144
|
+
if candidate.task_ref and self._in(self.tasks, slug, candidate.task_ref):
|
|
145
|
+
return f"{candidate.task_ref} has a live session or open round"
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class SweepResult:
|
|
151
|
+
"""What one sweep did. `removed` and `would_remove` carry (path, bytes);
|
|
152
|
+
`held` and `failed` carry (path, reason)."""
|
|
153
|
+
|
|
154
|
+
hubs: int = 0
|
|
155
|
+
removed: "list[tuple[Path, int]]" = field(default_factory=list)
|
|
156
|
+
would_remove: "list[tuple[Path, int]]" = field(default_factory=list)
|
|
157
|
+
held: "list[tuple[Path, str]]" = field(default_factory=list)
|
|
158
|
+
fresh: int = 0
|
|
159
|
+
failed: "list[tuple[Path, str]]" = field(default_factory=list)
|
|
160
|
+
pruned: int = 0
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def freed(self) -> int:
|
|
164
|
+
return sum(size for _, size in self.removed)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def discover_hubs(workspace_root: "Path | str") -> "list[Path]":
|
|
168
|
+
"""Every worktree hub directly under the workspace root: a non-symlink
|
|
169
|
+
directory holding a `.source` directory. Read off the disk rather than the
|
|
170
|
+
allowlist, so a hub whose repo left the allowlist (or a static `repos: []`
|
|
171
|
+
deployment, which names none) is still swept."""
|
|
172
|
+
root = Path(workspace_root)
|
|
173
|
+
try:
|
|
174
|
+
children = sorted(root.iterdir(), key=lambda p: p.name)
|
|
175
|
+
except OSError as exc:
|
|
176
|
+
log.warning("janitor: cannot list workspace %s (%s) — no sweep", root, exc)
|
|
177
|
+
return []
|
|
178
|
+
hubs = []
|
|
179
|
+
for child in children:
|
|
180
|
+
try:
|
|
181
|
+
if child.is_symlink() or not child.is_dir():
|
|
182
|
+
continue
|
|
183
|
+
source = child / SOURCE_DIR
|
|
184
|
+
if source.is_dir() and not source.is_symlink():
|
|
185
|
+
hubs.append(child)
|
|
186
|
+
except OSError:
|
|
187
|
+
continue
|
|
188
|
+
return hubs
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _dirs(parent: Path, prefix: str) -> "list[Path]":
|
|
192
|
+
"""Non-symlink directories directly under `parent` whose name starts
|
|
193
|
+
with `prefix`, sorted; empty when `parent` cannot be listed."""
|
|
194
|
+
try:
|
|
195
|
+
entries = sorted(parent.iterdir(), key=lambda p: p.name)
|
|
196
|
+
except OSError:
|
|
197
|
+
return []
|
|
198
|
+
found = []
|
|
199
|
+
for entry in entries:
|
|
200
|
+
if not entry.name.startswith(prefix) or entry.name == prefix:
|
|
201
|
+
continue
|
|
202
|
+
try:
|
|
203
|
+
if entry.is_symlink() or not entry.is_dir():
|
|
204
|
+
continue
|
|
205
|
+
except OSError:
|
|
206
|
+
continue
|
|
207
|
+
found.append(entry)
|
|
208
|
+
return found
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def scan_hub(hub: Path) -> "list[Candidate]":
|
|
212
|
+
"""The janitor's candidates in one hub: `main/.review-scratch-*` and
|
|
213
|
+
`REVIEW-*`, direct children only, never a walk."""
|
|
214
|
+
found = []
|
|
215
|
+
for path in _dirs(hub / "main", SCRATCH_PREFIX):
|
|
216
|
+
match = _SCRATCH_PR_RE.match(path.name[len(SCRATCH_PREFIX):])
|
|
217
|
+
found.append(Candidate(
|
|
218
|
+
path=path, hub=hub, kind=KIND_SCRATCH,
|
|
219
|
+
number=int(match["number"]) if match else None,
|
|
220
|
+
))
|
|
221
|
+
for path in _dirs(hub, REVIEW_CHECKOUT_PREFIX):
|
|
222
|
+
found.append(Candidate(
|
|
223
|
+
path=path, hub=hub, kind=KIND_REVIEW,
|
|
224
|
+
task_ref=path.name[len(REVIEW_CHECKOUT_PREFIX):],
|
|
225
|
+
))
|
|
226
|
+
return found
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def newest_mtime(path: Path) -> "float | None":
|
|
230
|
+
"""The newest mtime of `path` and its direct children (lstat, so a
|
|
231
|
+
symlink's target is never read), or None when `path` cannot be read.
|
|
232
|
+
One level, not a walk: a round writing into its folder touches the
|
|
233
|
+
folder or a direct child, and a `node_modules` walk every sweep is the
|
|
234
|
+
cost this avoids."""
|
|
235
|
+
try:
|
|
236
|
+
newest = path.lstat().st_mtime
|
|
237
|
+
except OSError:
|
|
238
|
+
return None
|
|
239
|
+
try:
|
|
240
|
+
with os.scandir(path) as entries:
|
|
241
|
+
for entry in entries:
|
|
242
|
+
try:
|
|
243
|
+
newest = max(newest, entry.stat(follow_symlinks=False).st_mtime)
|
|
244
|
+
except OSError:
|
|
245
|
+
continue
|
|
246
|
+
except OSError:
|
|
247
|
+
pass
|
|
248
|
+
return newest
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def dir_bytes(path: "Path | str") -> int:
|
|
252
|
+
"""Bytes allocated under `path` (st_blocks, the figure `du` reports and a
|
|
253
|
+
removal frees), following no symlinks. Iterative, and every per-entry
|
|
254
|
+
error is skipped -- devloop's `dir_size` walk, counting blocks."""
|
|
255
|
+
total = 0
|
|
256
|
+
stack = [os.fspath(path)]
|
|
257
|
+
while stack:
|
|
258
|
+
current = stack.pop()
|
|
259
|
+
try:
|
|
260
|
+
with os.scandir(current) as entries:
|
|
261
|
+
for entry in entries:
|
|
262
|
+
try:
|
|
263
|
+
if entry.is_dir(follow_symlinks=False):
|
|
264
|
+
stack.append(entry.path)
|
|
265
|
+
st = entry.stat(follow_symlinks=False)
|
|
266
|
+
blocks = getattr(st, "st_blocks", None)
|
|
267
|
+
total += blocks * 512 if blocks is not None else st.st_size
|
|
268
|
+
except OSError:
|
|
269
|
+
continue
|
|
270
|
+
except OSError:
|
|
271
|
+
continue
|
|
272
|
+
return total
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def human(size: "int | float") -> str:
|
|
276
|
+
"""Bytes as a short binary-unit string (`1.4 GiB`), as `du -h` prints."""
|
|
277
|
+
value = float(size)
|
|
278
|
+
for unit in ("B", "KiB", "MiB", "GiB"):
|
|
279
|
+
if abs(value) < 1024.0:
|
|
280
|
+
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
|
|
281
|
+
value /= 1024.0
|
|
282
|
+
return f"{value:.1f} TiB"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def disk_used_pct(path: "Path | str") -> "float | None":
|
|
286
|
+
"""Used percent of the volume holding `path` (one statvfs, rounded to a
|
|
287
|
+
tenth), or None when it cannot be read."""
|
|
288
|
+
try:
|
|
289
|
+
usage = shutil.disk_usage(os.fspath(path))
|
|
290
|
+
except OSError:
|
|
291
|
+
return None
|
|
292
|
+
if not usage.total:
|
|
293
|
+
return None
|
|
294
|
+
return round(100.0 * usage.used / usage.total, 1)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _contained(candidate: Candidate) -> bool:
|
|
298
|
+
"""The containment rule, re-asserted at the removal site: the folder is
|
|
299
|
+
a direct child of its hub (review checkout) or of `<hub>/main` (scratch),
|
|
300
|
+
both resolved, and not a symlink."""
|
|
301
|
+
parent = candidate.hub / "main" if candidate.kind == KIND_SCRATCH else candidate.hub
|
|
302
|
+
try:
|
|
303
|
+
if candidate.path.is_symlink():
|
|
304
|
+
return False
|
|
305
|
+
return candidate.path.resolve().parent == parent.resolve()
|
|
306
|
+
except OSError:
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def prune_worktrees(hub: Path, runner: Runner = run) -> None:
|
|
311
|
+
"""`git worktree prune` in the hub's `.source`, as this process's user
|
|
312
|
+
(the seat's own): drops the records of worktrees whose folders are gone
|
|
313
|
+
-- the scratch `wt/`s and REVIEW- checkouts this sweep removed, and any
|
|
314
|
+
an operator removed by hand."""
|
|
315
|
+
runner(["git", "-C", str(hub / SOURCE_DIR), "worktree", "prune"], timeout=120)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def sweep(
|
|
319
|
+
hubs: "Iterable[Path]",
|
|
320
|
+
guard: Guard,
|
|
321
|
+
*,
|
|
322
|
+
grace_seconds: float,
|
|
323
|
+
now: float,
|
|
324
|
+
dry_run: bool = False,
|
|
325
|
+
runner: Runner = run,
|
|
326
|
+
) -> SweepResult:
|
|
327
|
+
"""Remove every candidate under `hubs` older than `grace_seconds` that
|
|
328
|
+
`guard` does not hold, then prune each hub's worktree records.
|
|
329
|
+
|
|
330
|
+
Dry-run removes nothing and prunes nothing: it logs each folder it WOULD
|
|
331
|
+
remove (with its size) and reports them in `would_remove`."""
|
|
332
|
+
result = SweepResult()
|
|
333
|
+
for hub in hubs:
|
|
334
|
+
result.hubs += 1
|
|
335
|
+
for candidate in scan_hub(hub):
|
|
336
|
+
mtime = newest_mtime(candidate.path)
|
|
337
|
+
if mtime is None:
|
|
338
|
+
continue # vanished mid-sweep
|
|
339
|
+
if now - mtime < grace_seconds:
|
|
340
|
+
result.fresh += 1
|
|
341
|
+
continue
|
|
342
|
+
why = guard.holds(candidate)
|
|
343
|
+
if why is not None:
|
|
344
|
+
log.debug("janitor: keeping %s — %s", candidate.path, why)
|
|
345
|
+
result.held.append((candidate.path, why))
|
|
346
|
+
continue
|
|
347
|
+
if not _contained(candidate):
|
|
348
|
+
result.failed.append((candidate.path, "not contained in its hub"))
|
|
349
|
+
continue
|
|
350
|
+
size = dir_bytes(candidate.path)
|
|
351
|
+
if dry_run:
|
|
352
|
+
log.info(
|
|
353
|
+
"[dry-run] janitor would remove %s (%s, %.1f h old)",
|
|
354
|
+
candidate.path, human(size), (now - mtime) / 3600,
|
|
355
|
+
)
|
|
356
|
+
result.would_remove.append((candidate.path, size))
|
|
357
|
+
continue
|
|
358
|
+
try:
|
|
359
|
+
shutil.rmtree(candidate.path)
|
|
360
|
+
except OSError as exc:
|
|
361
|
+
log.warning("janitor: could not remove %s (%s)", candidate.path, exc)
|
|
362
|
+
result.failed.append((candidate.path, str(exc)))
|
|
363
|
+
continue
|
|
364
|
+
log.debug("janitor: removed %s (%s)", candidate.path, human(size))
|
|
365
|
+
result.removed.append((candidate.path, size))
|
|
366
|
+
if dry_run:
|
|
367
|
+
continue
|
|
368
|
+
try:
|
|
369
|
+
prune_worktrees(hub, runner)
|
|
370
|
+
result.pruned += 1
|
|
371
|
+
except (CommandError, OSError) as exc:
|
|
372
|
+
log.warning("janitor: git worktree prune failed in %s (%s)", hub, exc)
|
|
373
|
+
return result
|
|
@@ -24,6 +24,7 @@ from enum import Enum
|
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
from typing import Callable
|
|
26
26
|
|
|
27
|
+
from . import janitor as janitor_mod
|
|
27
28
|
from . import parking as parking_mod
|
|
28
29
|
from . import prompts as prompts_mod
|
|
29
30
|
from .alissa import (
|
|
@@ -1120,7 +1121,13 @@ _SHELL_RULE = (
|
|
|
1120
1121
|
# The reviewer's own line beside it (issue #138): the seat is read-only (CR6),
|
|
1121
1122
|
# so the "worktree" the shell rule names is the throwaway REVIEW-<task>
|
|
1122
1123
|
# checkout the review skill lets a reviewer make, and nothing a reviewer
|
|
1123
|
-
# writes there needs deleting
|
|
1124
|
+
# writes there needs deleting. The daemon's workspace janitor (janitor.py,
|
|
1125
|
+
# issue #167) reclaims it: every `janitor_interval_minutes` it removes each
|
|
1126
|
+
# hub's `main/.review-scratch-*` and `REVIEW-*` folders older than
|
|
1127
|
+
# `janitor_grace_minutes` that no live session or open round holds, then
|
|
1128
|
+
# prunes `.source`'s worktree records. The rule's text does not name the
|
|
1129
|
+
# janitor: the reviewer needs only to know it must not delete, and the plan
|
|
1130
|
+
# directives that embed this rule are pinned by digest.
|
|
1124
1131
|
_REVIEWER_RULE = (
|
|
1125
1132
|
"You never push and never delete: a reviewer that needs scratch files "
|
|
1126
1133
|
"writes them under its own checkout and leaves them. "
|
|
@@ -2564,6 +2571,9 @@ class ReviewWatcher:
|
|
|
2564
2571
|
# has listed this pass (the preflight refresh, or a sweep whose list
|
|
2565
2572
|
# failed), and the refresh lists for itself.
|
|
2566
2573
|
self._pass_roster: list[ManagedSession] | None = None
|
|
2574
|
+
# When the workspace janitor last swept (monotonic), or None before the
|
|
2575
|
+
# first sweep -- which therefore runs at the end of the first pass.
|
|
2576
|
+
self._janitor_last: "float | None" = None
|
|
2567
2577
|
# The (repo, number, round) whose alive session the stale-round probe
|
|
2568
2578
|
# just killed as `wedged:first-run-dialog` (issue #136), so the
|
|
2569
2579
|
# respawn site logs its re-enqueue at INFO instead of a second
|
|
@@ -7697,6 +7707,9 @@ class ReviewWatcher:
|
|
|
7697
7707
|
# heartbeat is the completion time of a pass whose exhaust is already
|
|
7698
7708
|
# written and whose events are already on their way.
|
|
7699
7709
|
vitals = self._push_fleet_vitals(completed_at=time.time())
|
|
7710
|
+
# The workspace janitor (issue #167), last: it is the one part of the
|
|
7711
|
+
# pass that is pure housekeeping, and it never raises.
|
|
7712
|
+
self._run_janitor()
|
|
7700
7713
|
log.info(
|
|
7701
7714
|
"poll summary: %d candidate(s), %d reaped, %d parked on a prompt, "
|
|
7702
7715
|
"%d answered%s, %d ms, vitals: %s",
|
|
@@ -7761,6 +7774,129 @@ class ReviewWatcher:
|
|
|
7761
7774
|
)
|
|
7762
7775
|
return VITALS_FAILED
|
|
7763
7776
|
|
|
7777
|
+
def _run_janitor(self, now: "float | None" = None) -> "janitor_mod.SweepResult | None":
|
|
7778
|
+
"""Sweep the workspace when the janitor is on and its interval has
|
|
7779
|
+
elapsed (issue #167). Returns the sweep's result, or None when no
|
|
7780
|
+
sweep ran. Never raises: a sweep that fails is one WARNING and the
|
|
7781
|
+
next interval tries again.
|
|
7782
|
+
|
|
7783
|
+
One INFO line per sweep (folders removed and bytes freed, or what a
|
|
7784
|
+
dry run WOULD remove), and one WARNING when the workspace volume is
|
|
7785
|
+
at or above `janitor_warn_pct`. The disk read runs even when the
|
|
7786
|
+
roster cannot be listed and the removal half is skipped: a full
|
|
7787
|
+
volume is worth saying whatever else went wrong."""
|
|
7788
|
+
if not self.config.janitor_enabled:
|
|
7789
|
+
return None
|
|
7790
|
+
mono = time.monotonic()
|
|
7791
|
+
interval = self.config.janitor_interval_minutes * 60
|
|
7792
|
+
if self._janitor_last is not None and mono - self._janitor_last < interval:
|
|
7793
|
+
return None
|
|
7794
|
+
self._janitor_last = mono
|
|
7795
|
+
stamp = time.time() if now is None else now
|
|
7796
|
+
try:
|
|
7797
|
+
result = self._sweep_workspace(stamp)
|
|
7798
|
+
except Exception as exc:
|
|
7799
|
+
log.warning(
|
|
7800
|
+
"janitor: sweep failed unexpectedly (%s: %s) — nothing more is "
|
|
7801
|
+
"removed this interval; the pass completes",
|
|
7802
|
+
type(exc).__name__, exc, exc_info=True,
|
|
7803
|
+
)
|
|
7804
|
+
result = None
|
|
7805
|
+
self._warn_disk()
|
|
7806
|
+
return result
|
|
7807
|
+
|
|
7808
|
+
def _sweep_workspace(self, now: float) -> "janitor_mod.SweepResult | None":
|
|
7809
|
+
guard = self._janitor_guard(now)
|
|
7810
|
+
if guard is None:
|
|
7811
|
+
log.warning(
|
|
7812
|
+
"janitor: could not list reviewer sessions — skipping this "
|
|
7813
|
+
"sweep, since a live session's folders cannot be told apart"
|
|
7814
|
+
)
|
|
7815
|
+
return None
|
|
7816
|
+
hubs = janitor_mod.discover_hubs(self.config.workspace_root)
|
|
7817
|
+
result = janitor_mod.sweep(
|
|
7818
|
+
hubs, guard,
|
|
7819
|
+
grace_seconds=self.config.janitor_grace_minutes * 60,
|
|
7820
|
+
now=now,
|
|
7821
|
+
dry_run=self.config.dry_run,
|
|
7822
|
+
)
|
|
7823
|
+
if self.config.dry_run:
|
|
7824
|
+
log.info(
|
|
7825
|
+
"[dry-run] janitor: would remove %d folder(s) (%s) across %d "
|
|
7826
|
+
"hub(s); %d held by a live round, %d inside the grace",
|
|
7827
|
+
len(result.would_remove),
|
|
7828
|
+
janitor_mod.human(sum(size for _, size in result.would_remove)),
|
|
7829
|
+
result.hubs, len(result.held), result.fresh,
|
|
7830
|
+
)
|
|
7831
|
+
else:
|
|
7832
|
+
log.info(
|
|
7833
|
+
"janitor: removed %d folder(s), freed %s across %d hub(s); "
|
|
7834
|
+
"%d held by a live round, %d inside the grace, %d failed",
|
|
7835
|
+
len(result.removed), janitor_mod.human(result.freed),
|
|
7836
|
+
result.hubs, len(result.held), result.fresh, len(result.failed),
|
|
7837
|
+
)
|
|
7838
|
+
return result
|
|
7839
|
+
|
|
7840
|
+
def _janitor_guard(self, now: float) -> "janitor_mod.Guard | None":
|
|
7841
|
+
"""What the janitor must not touch: every PR (and its review task's
|
|
7842
|
+
REVIEW- checkout) with a live reviewer session or an open ledger
|
|
7843
|
+
round, or None when the roster cannot be listed -- a sweep that
|
|
7844
|
+
cannot see the live sessions holds nothing, so it must not run.
|
|
7845
|
+
|
|
7846
|
+
Open rounds are the ledger's own definition (`in_flight_repos`): a
|
|
7847
|
+
spawn younger than the stale-round window whose session was not
|
|
7848
|
+
reaped, and a verdict post still owed. The roster is the pass's
|
|
7849
|
+
post-reap listing when the sweep produced one."""
|
|
7850
|
+
sessions = self._pass_roster
|
|
7851
|
+
if sessions is None:
|
|
7852
|
+
try:
|
|
7853
|
+
sessions = self.alissa.list_review_sessions()
|
|
7854
|
+
except CommandError as exc:
|
|
7855
|
+
log.debug("janitor: roster listing failed (%s)", exc)
|
|
7856
|
+
return None
|
|
7857
|
+
guard = janitor_mod.Guard()
|
|
7858
|
+
for ses in sessions:
|
|
7859
|
+
ref = ses.ref
|
|
7860
|
+
if ref is None:
|
|
7861
|
+
continue
|
|
7862
|
+
row = self.state.find_spawn_by_session(ses.name)
|
|
7863
|
+
if row is not None:
|
|
7864
|
+
guard.hold(str(row["repo"]), int(row["number"]), row["task_ref"])
|
|
7865
|
+
else:
|
|
7866
|
+
# No ledger row (a skill-shaped `review-pr-<n>`): the name is
|
|
7867
|
+
# the only evidence, and a name without a repo holds its PR
|
|
7868
|
+
# number in every hub.
|
|
7869
|
+
guard.hold(ref.repo, ref.number)
|
|
7870
|
+
for spawn_row in self.state.read_spawns(since=now - STALE_ROUND_SECONDS):
|
|
7871
|
+
if not self.state.is_reaped(str(spawn_row["session"])):
|
|
7872
|
+
guard.hold(
|
|
7873
|
+
str(spawn_row["repo"]), int(spawn_row["number"]),
|
|
7874
|
+
spawn_row["task_ref"],
|
|
7875
|
+
)
|
|
7876
|
+
for post in self.state.read_verdict_posts():
|
|
7877
|
+
if post["posted_at"] is None and post["abandoned_at"] is None:
|
|
7878
|
+
spawn = self.state.get_spawn(
|
|
7879
|
+
str(post["repo"]), int(post["number"]), int(post["round"])
|
|
7880
|
+
)
|
|
7881
|
+
guard.hold(
|
|
7882
|
+
str(post["repo"]), int(post["number"]),
|
|
7883
|
+
spawn["task_ref"] if spawn is not None else None,
|
|
7884
|
+
)
|
|
7885
|
+
return guard
|
|
7886
|
+
|
|
7887
|
+
def _warn_disk(self) -> None:
|
|
7888
|
+
"""One WARNING when the workspace volume is at or above
|
|
7889
|
+
`janitor_warn_pct` (0 = never)."""
|
|
7890
|
+
threshold = self.config.janitor_warn_pct
|
|
7891
|
+
if not threshold:
|
|
7892
|
+
return
|
|
7893
|
+
pct = janitor_mod.disk_used_pct(self.config.workspace_root)
|
|
7894
|
+
if pct is not None and pct >= threshold:
|
|
7895
|
+
log.warning(
|
|
7896
|
+
"janitor: workspace volume %s is %.1f%% used (warn at %d%%)",
|
|
7897
|
+
self.config.workspace_root, pct, threshold,
|
|
7898
|
+
)
|
|
7899
|
+
|
|
7764
7900
|
def _vitals_config(self) -> dict:
|
|
7765
7901
|
"""The snapshot's `config` block (issue #159), read off what this
|
|
7766
7902
|
watcher RUNS on: `self.config` is re-bound by every allowlist
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.31.15
|
|
@@ -12,6 +12,7 @@ src/main/alissa/tools/github/revloop/bows.py
|
|
|
12
12
|
src/main/alissa/tools/github/revloop/config.py
|
|
13
13
|
src/main/alissa/tools/github/revloop/fleet_vitals.py
|
|
14
14
|
src/main/alissa/tools/github/revloop/ghclient.py
|
|
15
|
+
src/main/alissa/tools/github/revloop/janitor.py
|
|
15
16
|
src/main/alissa/tools/github/revloop/loop.py
|
|
16
17
|
src/main/alissa/tools/github/revloop/loop_events.py
|
|
17
18
|
src/main/alissa/tools/github/revloop/parking.py
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.31.14
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.15}/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
|