alissa-tools-github-revloop 0.31.14__tar.gz → 0.31.16__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {alissa_tools_github_revloop-0.31.14/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.16}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/__main__.py +55 -0
  3. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/config.py +103 -1
  4. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/fleet_vitals.py +11 -1
  5. alissa_tools_github_revloop-0.31.16/src/main/alissa/tools/github/revloop/janitor.py +373 -0
  6. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/loop.py +137 -1
  7. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/proc.py +20 -0
  8. alissa_tools_github_revloop-0.31.16/src/main/alissa/tools/github/revloop/version +1 -0
  9. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  10. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
  11. alissa_tools_github_revloop-0.31.14/src/main/alissa/tools/github/revloop/version +0 -1
  12. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/LICENSE +0 -0
  13. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/MANIFEST.in +0 -0
  14. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/NOTICE +0 -0
  15. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/README.md +0 -0
  16. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/requirements.txt +0 -0
  17. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/setup.cfg +0 -0
  18. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/setup.py +0 -0
  19. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  20. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  21. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  22. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  23. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  24. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  25. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/parking.py +0 -0
  26. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
  27. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  28. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/state.py +0 -0
  29. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  30. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/version.py +0 -0
  31. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  32. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  33. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  34. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  35. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  36. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  37. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  38. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  39. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  40. {alissa_tools_github_revloop-0.31.14 → alissa_tools_github_revloop-0.31.16}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.14
3
+ Version: 0.31.16
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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
- OPTIONAL_BLOCKS = ("idle", "config")
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 -- the workspace prune reclaims it.
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
@@ -8,10 +8,18 @@ inherited -- which is how a reviewer's verdict ended up on GitHub under the
8
8
  IMPLEMENTER's login (issue #51). Callers that must be sure which identity a
9
9
  `gh` call carries pass a fully-resolved environment here; everything else
10
10
  passes None and inherits, exactly as before.
11
+
12
+ A child that cannot even be STARTED is a CommandError too (issue #169): the
13
+ container's cgroup pids.max counts threads, so a busy fleet can make `fork`
14
+ fail with EAGAIN, and an OSError escaping here reads as an unexplained crash
15
+ class rather than "gh could not start". Folding it keeps one failure type for
16
+ every caller (each already handles a CommandError the way it handles a
17
+ timeout) and puts the errno name in the message the poll firewall logs.
11
18
  """
12
19
 
13
20
  from __future__ import annotations
14
21
 
22
+ import errno
15
23
  import json
16
24
  import logging
17
25
  import os
@@ -64,12 +72,24 @@ def run(
64
72
  )
65
73
  except subprocess.TimeoutExpired as exc:
66
74
  raise CommandError(argv, -1, f"timed out after {timeout}s") from exc
75
+ except OSError as exc:
76
+ raise CommandError(argv, -1, f"could not start {argv[0]}: {_os_error_text(exc)}") from exc
67
77
 
68
78
  if check and proc.returncode != 0:
69
79
  raise CommandError(argv, proc.returncode, proc.stderr)
70
80
  return proc.stdout
71
81
 
72
82
 
83
+ def _os_error_text(exc: OSError) -> str:
84
+ """`EAGAIN: Resource temporarily unavailable` -- the errno's symbolic name
85
+ first, since that is what an operator greps for; the bare message when the
86
+ error carries no errno."""
87
+ name = errno.errorcode.get(exc.errno) if exc.errno is not None else None
88
+ if name is None:
89
+ return str(exc)
90
+ return f"{name}: {exc.strerror or exc}"
91
+
92
+
73
93
  def run_json(
74
94
  argv: Sequence[str],
75
95
  *,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.14
3
+ Version: 0.31.16
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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