alissa-tools-github-devloop 0.8.11__tar.gz → 0.8.13__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 (34) hide show
  1. {alissa_tools_github_devloop-0.8.11/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.13}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/config.py +41 -0
  3. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/janitor.py +79 -0
  4. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/loop.py +342 -35
  5. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/state.py +116 -0
  6. alissa_tools_github_devloop-0.8.13/src/main/alissa/tools/github/devloop/version +1 -0
  7. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/webui/page.py +23 -2
  8. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/webui/sources.py +36 -3
  9. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  10. alissa_tools_github_devloop-0.8.11/src/main/alissa/tools/github/devloop/version +0 -1
  11. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/LICENSE +0 -0
  12. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/MANIFEST.in +0 -0
  13. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/NOTICE +0 -0
  14. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/README.md +0 -0
  15. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/requirements.txt +0 -0
  16. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/setup.cfg +0 -0
  17. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/setup.py +0 -0
  18. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  19. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  20. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  21. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  22. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  23. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  24. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  25. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/version.py +0 -0
  26. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  27. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  28. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  29. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  30. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  31. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  32. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  33. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  34. {alissa_tools_github_devloop-0.8.11 → alissa_tools_github_devloop-0.8.13}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.11
3
+ Version: 0.8.13
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -64,6 +64,17 @@ FOREIGN_IGNORE = "ignore" # today's behavior: never even considered
64
64
 
65
65
  _FOREIGN_POLICIES = {FOREIGN_REAP, FOREIGN_WARN, FOREIGN_IGNORE}
66
66
 
67
+ # What the workspace janitor does about a dirty HOLDOUT -- a task workspace
68
+ # whose anchor PR is terminal but which carries uncommitted or unpushed work
69
+ # (issue #99). `warn` is the invariant as it has always been read: never
70
+ # delete unpushed work, say so once per episode. `archive` keeps the invariant
71
+ # and adds a way out: the work is committed, pushed to a NEW
72
+ # `refs/heads/holdout/<branch>` ref, verified there, and only then reclaimed.
73
+ HOLDOUT_WARN = "warn" # track and warn (once per 24 h); never touch the tree
74
+ HOLDOUT_ARCHIVE = "archive" # commit, push to holdout/<branch>, verify, reclaim
75
+
76
+ _HOLDOUT_POLICIES = {HOLDOUT_WARN, HOLDOUT_ARCHIVE}
77
+
67
78
  CONFIG_FILENAME = "devloop.config.json"
68
79
 
69
80
  # Comma-separated reviewer logins; authoritative when set, even if empty.
@@ -130,6 +141,7 @@ CONFIG_KEYS = (
130
141
  "janitor_enabled",
131
142
  "janitor_grace_minutes",
132
143
  "janitor_interval_minutes",
144
+ "janitor_holdout_policy",
133
145
  "cache_cap_mb",
134
146
  "cache_paths",
135
147
  "disk_warn_percent",
@@ -518,6 +530,21 @@ class Config:
518
530
  # so the telemetry line never goes quiet. 0 means every poll.
519
531
  janitor_interval_minutes: int = 10
520
532
 
533
+ # What the janitor does about a dirty holdout (issue #99). Default `warn`:
534
+ # the workspace is tracked in the `janitor_holdouts` ledger table, warned
535
+ # about on first sighting and at most once per 24 h after that (DEBUG in
536
+ # between), and never touched -- the "never delete unpushed work"
537
+ # invariant exactly as before, minus the ~3,000 identical WARNING lines a
538
+ # day the fleet measured. `archive` is opt-in: a holdout whose anchor is
539
+ # terminal and whose branch is a task branch has its work committed (when
540
+ # dirty), pushed to a NEW `refs/heads/holdout/<branch>` ref (never the
541
+ # task branch, never force), verified there by `ls-remote`, and only then
542
+ # reclaimed like a clean workspace. Any failure leaves the workspace in
543
+ # place -- at worst with its work committed locally, never removed -- and
544
+ # pages `janitor-archive-failed` once per workspace. The commit stages
545
+ # EVERY untracked, non-ignored file, scratch directories included.
546
+ janitor_holdout_policy: str = HOLDOUT_WARN
547
+
521
548
  # The cache bound, in MiB: package-manager caches over this are trimmed
522
549
  # oldest-entry-first back under it. 0 disables the trim entirely. A cache
523
550
  # is a legitimate speed tradeoff -- the default is generous on purpose
@@ -853,6 +880,19 @@ class Config:
853
880
  f"got {janitor_interval}"
854
881
  )
855
882
 
883
+ # The holdout policy. An enum validated by NAME like
884
+ # foreign_session_policy: a typo would otherwise be "not archive",
885
+ # i.e. silently the default, for an operator who set the key to opt
886
+ # INTO a git push -- the one outcome they cannot have wanted.
887
+ holdout_policy = str(
888
+ raw.get("janitor_holdout_policy", cls.janitor_holdout_policy)
889
+ ).strip()
890
+ if holdout_policy not in _HOLDOUT_POLICIES:
891
+ raise ValueError(
892
+ f"janitor_holdout_policy must be one of "
893
+ f"{sorted(_HOLDOUT_POLICIES)}, got {holdout_policy!r}"
894
+ )
895
+
856
896
  cache_cap = int(raw.get("cache_cap_mb", cls.cache_cap_mb))
857
897
  if cache_cap < 0:
858
898
  raise ValueError(
@@ -924,6 +964,7 @@ class Config:
924
964
  janitor_enabled=bool(raw.get("janitor_enabled", cls.janitor_enabled)),
925
965
  janitor_grace_minutes=janitor_grace,
926
966
  janitor_interval_minutes=janitor_interval,
967
+ janitor_holdout_policy=holdout_policy,
927
968
  cache_cap_mb=cache_cap,
928
969
  cache_paths=_string_list(
929
970
  raw.get("cache_paths", cls.cache_paths), "cache_paths"
@@ -53,6 +53,18 @@ log = logging.getLogger(__name__)
53
53
  # one of ours.
54
54
  TASK_WORKSPACE_RE = re.compile(r"TASK-(?P<task>\d+)(?:-(?P<desc>[A-Za-z0-9._-]+))?")
55
55
 
56
+ # Where the `archive` holdout policy pushes unpushed work (issue #99): a NEW
57
+ # ref under this prefix, named after the task branch. Never the task branch
58
+ # itself -- its PR is closed, and a push there would resurrect a dead lane --
59
+ # and never `--force`, so a ref that already exists at a different sha refuses
60
+ # the push and the workspace stays. An operator recovers the work with
61
+ # `git fetch origin holdout/<branch>`.
62
+ HOLDOUT_REF_PREFIX = "refs/heads/holdout/"
63
+
64
+ # The remote the archive pushes to. The dev identity already pushes task
65
+ # branches to it; the archive needs no new permission.
66
+ HOLDOUT_REMOTE = "origin"
67
+
56
68
  # Package-manager caches, as they land in this image and every other
57
69
  # Debian-ish agent container. Used only when the operator configures no
58
70
  # `cache_paths` of their own; entries that do not exist are skipped silently,
@@ -117,6 +129,21 @@ def parse_task_workspace(path: Path, hub: Path) -> "TaskWorkspace | None":
117
129
  )
118
130
 
119
131
 
132
+ def is_task_branch(name: str) -> bool:
133
+ """Is `name` one of devloop's own task branches (`TASK-<n>-<DESC>`)?
134
+
135
+ The same full-match grammar `parse_task_workspace` admits directories
136
+ with, re-asserted by name at the one place that pushes: the archive path
137
+ writes a ref to the remote, and its name is derived from this one, so a
138
+ branch the daemon did not create must never get a `holdout/` twin."""
139
+ return TASK_WORKSPACE_RE.fullmatch(name) is not None
140
+
141
+
142
+ def holdout_ref(branch: str) -> str:
143
+ """The full ref the archive pushes `branch`'s work to."""
144
+ return f"{HOLDOUT_REF_PREFIX}{branch}"
145
+
146
+
120
147
  def scan_hub(hub: Path) -> "list[TaskWorkspace]":
121
148
  """Every task workspace directly under `hub`, sorted by name.
122
149
 
@@ -375,6 +402,58 @@ def delete_branch(common: Path, branch: str, runner: "Runner" = run) -> None:
375
402
  _git(["branch", "-D", branch], common, runner)
376
403
 
377
404
 
405
+ # -- the archive's git plumbing (issue #99) --------------------------------
406
+ #
407
+ # Three commands, each one step of the `archive` holdout policy, each doing
408
+ # exactly what its name says and nothing else: the ORDER (commit only when
409
+ # dirty, push to the holdout ref, verify by ls-remote, and only then remove)
410
+ # and every refusal live in `loop.DevWatcher._archive_holdout`. All three
411
+ # raise CommandError on failure, which the caller turns into "the workspace
412
+ # stays" -- nothing here decides.
413
+
414
+
415
+ def commit_all(path: Path, message: str, runner: "Runner" = run) -> None:
416
+ """`git add -A && git commit --allow-empty -m <message>` in `path`.
417
+
418
+ `-A` stages every uncommitted change `dirty_entries` reported, untracked
419
+ files included, because that is precisely the work the holdout exists to
420
+ keep; ignored build output stays ignored. That is BROADER than "the
421
+ work": a `.harness/` or `.fu…prep/` scratch directory a session left
422
+ behind is untracked and not ignored, so it goes into the commit and onto
423
+ the remote ref too. The caller logs the entry count before this runs and
424
+ the docs say so -- an operator opting into `archive` opts into that. `--allow-empty` so a status that
425
+ went clean between the check and the commit still yields a commit that
426
+ carries the message -- the archive's provenance line on the ref."""
427
+ _git(["add", "-A"], path, runner)
428
+ _git(["commit", "--allow-empty", "-m", message], path, runner)
429
+
430
+
431
+ def push_ref(path: Path, ref: str, runner: "Runner" = run) -> None:
432
+ """`git push origin HEAD:<ref>` from `path`. Deliberately WITHOUT
433
+ `--force`, ever: a ref that already exists at another sha rejects the
434
+ push, and the caller keeps the workspace. `ref` is a full ref name
435
+ (`refs/heads/holdout/<branch>`), so git cannot resolve it to anything but
436
+ a branch under `holdout/`."""
437
+ _git(["push", HOLDOUT_REMOTE, f"HEAD:{ref}"], path, runner)
438
+
439
+
440
+ def remote_ref_sha(path: Path, ref: str, runner: "Runner" = run) -> "str | None":
441
+ """The sha `ref` points at on the remote, per `git ls-remote origin
442
+ <ref>`, or None when the remote has no such ref.
443
+
444
+ THE verification step: the archive deletes only when this equals the
445
+ local HEAD. A remote that cannot be asked raises (the caller keeps the
446
+ workspace and says why); a remote that answers "no such ref" is the same
447
+ verdict spelled quietly, hence None rather than a raise -- the caller
448
+ compares it to a real sha and refuses."""
449
+ out = _git(["ls-remote", HOLDOUT_REMOTE, ref], path, runner)
450
+ for line in out.splitlines():
451
+ sha, _, name = line.strip().partition("\t")
452
+ if name == ref and sha:
453
+ return sha
454
+ return None
455
+
456
+
378
457
  def remove_tree(path: Path) -> None:
379
458
  """Delete a standalone checkout's directory outright.
380
459
 
@@ -357,11 +357,26 @@ deletes) AND a workspace holding nothing that is not already on GitHub: a
357
357
  clean `git status --porcelain` and a local tip that matches a head GitHub saw
358
358
  on the branch. That last pair is the one that matters -- unpushed work is
359
359
  exactly what the approve-race taught this fleet can exist after a merge -- and
360
- failing it is not a quiet skip: the workspace is logged as a HOLDOUT at
361
- warning level on EVERY pass, because nothing in this daemon will ever clear
362
- that state on its own. Removal is `git worktree remove` (no `--force`, so
363
- git's own refusal to delete untracked work is a second, independent guard) +
364
- `prune` + `branch -D`, or a plain directory removal for a standalone checkout.
360
+ failing it is not a quiet skip: the workspace is a HOLDOUT, tracked in the
361
+ `janitor_holdouts` ledger table and logged at WARNING with its anchor evidence
362
+ on first sighting and at most once per 24 h after that (DEBUG in between --
363
+ the fleet measured ~3,000 identical WARNING lines a day from 21 holdouts
364
+ warned on every 10-minute sweep, issue #99), listed by name on every pass's
365
+ summary line and in the console, and cleared with one INFO line the sweep
366
+ that no longer finds the directory. Under the default `janitor_holdout_policy`
367
+ of `warn` that is all: nothing in this daemon touches the tree. Under the
368
+ opt-in `archive` policy a holdout whose anchor is terminal and whose branch is
369
+ a task branch is given a way out that keeps the invariant -- its work is
370
+ committed (when dirty), pushed to a NEW `refs/heads/holdout/<branch>` ref
371
+ (never the task branch, never force), VERIFIED there by `ls-remote` against
372
+ the local HEAD, and only then reclaimed like a clean workspace; any failure
373
+ leaves the workspace in place -- at worst with its work committed locally,
374
+ never removed -- and pages `janitor-archive-failed` once. What the commit
375
+ captures is EVERY untracked, non-ignored file (scratch directories included):
376
+ the ref is a durable copy of the whole tree, which the docs say plainly.
377
+ Removal is `git worktree remove` (no `--force`, so git's own refusal to delete
378
+ untracked work is a second, independent guard) + `prune` + `branch -D`, or a
379
+ plain directory removal for a standalone checkout.
365
380
  Only direct `TASK-<n>-<DESC>` children of the hubs of ALLOWLISTED repos are
366
381
  ever candidates; a removal that fails logs and skips, and the walk continues.
367
382
 
@@ -395,6 +410,7 @@ from .bows import BowRepoSource
395
410
  from .config import (
396
411
  FOREIGN_IGNORE,
397
412
  FOREIGN_REAP,
413
+ HOLDOUT_ARCHIVE,
398
414
  HUB_ADD,
399
415
  ON_MISSING_SKIP,
400
416
  ON_MISSING_WARN,
@@ -1182,6 +1198,25 @@ ESCALATION_ORPHAN_REJECTED = "orphan-rejected"
1182
1198
  # number, like every other PR-side kind here.
1183
1199
  ESCALATION_STACKED_BASE = "stacked-base"
1184
1200
 
1201
+ # The `archive` holdout policy could not archive a workspace (issue #99): the
1202
+ # commit, the push to the holdout ref, or the ls-remote verification failed,
1203
+ # or the remote sha did not equal the local HEAD. Keyed per WORKSPACE slug
1204
+ # (`archive_failed_kind`) so a fleet with 21 holdouts pages once for each,
1205
+ # and rows carry the anchor PR's coordinates so the console can link them.
1206
+ ESCALATION_ARCHIVE_FAILED = "janitor-archive-failed"
1207
+
1208
+ # How often a dirty holdout is re-warned about after its first sighting. The
1209
+ # sweeps in between log the same evidence at DEBUG. A day, not the janitor
1210
+ # interval: the state is permanent by construction, so the line's job is to
1211
+ # stay findable in a day's log, not to be the log.
1212
+ HOLDOUT_WARN_INTERVAL = 24 * 3600
1213
+
1214
+
1215
+ def archive_failed_kind(slug: str) -> str:
1216
+ """The ledger kind that dedupes ONE workspace's archive-failure page."""
1217
+ return f"{ESCALATION_ARCHIVE_FAILED}:{slug}"
1218
+
1219
+
1185
1220
  # CR6 verdict words, spelled exactly as reviewloop's alissa.py spells them --
1186
1221
  # the envelope tie-breaker (see DevWatcher.envelope_verdict) must return
1187
1222
  # these strings, so the two daemons read one vocabulary.
@@ -1764,6 +1799,28 @@ class Reclaim:
1764
1799
  freed: int = 0
1765
1800
  dirty: "tuple[str, ...]" = ()
1766
1801
  trimmed: int = 0
1802
+ # How many of `removed` were dirty holdouts the `archive` policy pushed to
1803
+ # a `holdout/<branch>` ref first (issue #99). A subset, not a sibling:
1804
+ # an archived workspace IS reclaimed and its bytes ARE in `freed`; the
1805
+ # count is kept so the summary line can say which reclaims took a push.
1806
+ archived: int = 0
1807
+
1808
+
1809
+ @dataclass(frozen=True, slots=True)
1810
+ class Holdout:
1811
+ """WHY one workspace with a terminal anchor must be kept (issue #99).
1812
+
1813
+ `reason` is the sentence the WARNING carries. `uncommitted` says `git
1814
+ status` showed work (the archive commits before it pushes); `head` is the
1815
+ local tip when it could be read. `archivable` is the archive policy's
1816
+ admission test, and it is False for exactly the verdicts that are not
1817
+ evidence -- a status or a HEAD that could not be READ. "I could not
1818
+ check" licenses keeping the directory; it licenses nothing else."""
1819
+
1820
+ reason: str
1821
+ uncommitted: bool = False
1822
+ head: "str | None" = None
1823
+ archivable: bool = False
1767
1824
 
1768
1825
 
1769
1826
  @dataclass(frozen=True, slots=True)
@@ -6515,14 +6572,16 @@ class DevWatcher:
6515
6572
  then judged by `_reclaim_workspace`, whose failures are caught per
6516
6573
  workspace: one unremovable directory must not stop the walk that would
6517
6574
  have freed the next twelve."""
6518
- scanned = removed = freed = 0
6575
+ scanned = removed = freed = archived = 0
6519
6576
  dirty: list[str] = []
6520
6577
  for owner, repo in self._watched_repos():
6521
6578
  hub = self.config.hub_for(owner, repo)
6522
6579
  if not hub.is_dir():
6523
6580
  continue
6581
+ found: set[str] = set()
6524
6582
  for workspace in janitor.scan_hub(hub):
6525
6583
  scanned += 1
6584
+ found.add(workspace.slug)
6526
6585
  try:
6527
6586
  outcome = self._reclaim_workspace(
6528
6587
  workspace, owner, repo, roster
@@ -6537,14 +6596,31 @@ class DevWatcher:
6537
6596
  continue
6538
6597
  if outcome is None:
6539
6598
  continue
6540
- gone, size = outcome
6599
+ gone, size, pushed = outcome
6541
6600
  if gone:
6542
6601
  removed += 1
6543
6602
  freed += size
6603
+ archived += int(pushed)
6604
+ # Reclaimed (archived included): its directory is gone,
6605
+ # so its ledger row -- if it had one -- clears on THIS
6606
+ # sweep rather than the next.
6607
+ found.discard(workspace.slug)
6544
6608
  else:
6545
6609
  dirty.append(workspace.slug)
6610
+ # The holdout ledger is pruned per hub WALKED, against every
6611
+ # directory the walk found (held out or not): a row goes only
6612
+ # when its directory is provably gone -- pushed and reclaimed,
6613
+ # archived, or removed by hand -- never because this pass
6614
+ # skipped a hub. One INFO line per cleared episode, the first
6615
+ # and last thing the log says about it after the warning. Not
6616
+ # under dry-run: a dry pass writes no gate (see `_note_holdout`).
6617
+ if self.config.dry_run:
6618
+ continue
6619
+ for slug in self.state.prune_janitor_holdouts(str(hub), found):
6620
+ log.info("janitor: holdout cleared %s", slug)
6546
6621
  return Reclaim(
6547
- scanned=scanned, removed=removed, freed=freed, dirty=tuple(dirty)
6622
+ scanned=scanned, removed=removed, freed=freed, dirty=tuple(dirty),
6623
+ archived=archived,
6548
6624
  )
6549
6625
 
6550
6626
  def _reclaim_workspace(
@@ -6553,9 +6629,11 @@ class DevWatcher:
6553
6629
  owner: str,
6554
6630
  repo: str,
6555
6631
  roster: "SessionLiveness | None",
6556
- ) -> "tuple[bool, int] | None":
6557
- """Judge ONE task workspace: `(removed, bytes)` when it acted, or None
6558
- when the workspace was spared.
6632
+ ) -> "tuple[bool, int, bool] | None":
6633
+ """Judge ONE task workspace: `(removed, bytes, archived)` when it
6634
+ acted -- `removed` False for a holdout it kept, `archived` True for a
6635
+ holdout the `archive` policy pushed before reclaiming -- or None when
6636
+ the workspace was spared.
6559
6637
 
6560
6638
  The gates, cheapest and most conservative first -- every one of them
6561
6639
  SPARES, and only a workspace that clears all of them is touched:
@@ -6584,11 +6662,18 @@ class DevWatcher:
6584
6662
  what stands between this sweep and someone's unpushed work, and they
6585
6663
  WARN rather than skipping quietly (see below).
6586
6664
 
6587
- The dirty gates warn on EVERY pass, by design and not by oversight. A
6588
- workspace with a terminal anchor and unpushed commits is a permanent
6589
- holdout -- nothing in this daemon will ever clear it, and its disk is
6590
- never coming back -- so it is exactly the state that must not decay
6591
- into a debug line nobody greps for.
6665
+ The dirty gates do not skip quietly, by design: a workspace with a
6666
+ terminal anchor and unpushed work is a permanent HOLDOUT -- nothing in
6667
+ the `warn` policy will ever clear it, and its disk is never coming
6668
+ back -- so it is exactly the state that must not decay into a debug
6669
+ line nobody greps for. But it must not become the log either (issue
6670
+ #99: 21 holdouts x 144 sweeps a day), so the WARNING is once per
6671
+ episode and once per 24 h after that, with the sweeps in between at
6672
+ DEBUG (`_note_holdout`); the per-pass summary line names every holdout
6673
+ every pass regardless. Under the `archive` policy a holdout the
6674
+ evidence supports is then handed to `_archive_holdout`, which either
6675
+ reclaims it behind a verified remote copy or leaves it exactly as it
6676
+ was.
6592
6677
  """
6593
6678
  path = workspace.path
6594
6679
  branch = janitor.current_branch(path)
@@ -6647,13 +6732,15 @@ class DevWatcher:
6647
6732
 
6648
6733
  holdout = self._dirty_holdout(workspace, newest, pulls)
6649
6734
  if holdout is not None:
6650
- log.warning(
6651
- "janitor: HOLDOUT %s — anchor #%d is terminal (%s) but the "
6652
- "workspace %s; never deleting unpushed work. This will not "
6653
- "clear on its own: push it, or remove the directory by hand",
6654
- workspace.slug, newest.number, reason, holdout,
6655
- )
6656
- return False, 0
6735
+ self._note_holdout(workspace, newest, reason, holdout)
6736
+ if (
6737
+ self.config.janitor_holdout_policy == HOLDOUT_ARCHIVE
6738
+ and holdout.archivable
6739
+ ):
6740
+ freed = self._archive_holdout(workspace, newest, reason, holdout)
6741
+ if freed is not None:
6742
+ return True, freed, True
6743
+ return False, 0, False
6657
6744
 
6658
6745
  size = janitor.dir_size(path)
6659
6746
  if self.config.dry_run:
@@ -6661,14 +6748,14 @@ class DevWatcher:
6661
6748
  "[dry-run] would reclaim %s (anchor #%d: %s; %s)",
6662
6749
  workspace.slug, newest.number, reason, janitor.human(size),
6663
6750
  )
6664
- return True, size
6751
+ return True, size, False
6665
6752
 
6666
6753
  self._remove_workspace(workspace)
6667
6754
  log.info(
6668
6755
  "janitor: reclaimed %s (anchor #%d: %s; %s freed)",
6669
6756
  workspace.slug, newest.number, reason, janitor.human(size),
6670
6757
  )
6671
- return True, size
6758
+ return True, size, False
6672
6759
 
6673
6760
  def _terminal_for(
6674
6761
  self, pull: PullRequest, path: Path
@@ -6697,7 +6784,7 @@ class DevWatcher:
6697
6784
  workspace: "janitor.TaskWorkspace",
6698
6785
  newest: PullRequest,
6699
6786
  pulls: "tuple[PullRequest, ...]",
6700
- ) -> "str | None":
6787
+ ) -> "Holdout | None":
6701
6788
  """Why this workspace must be kept despite a terminal anchor, or None
6702
6789
  when it holds nothing that is not already on GitHub.
6703
6790
 
@@ -6713,29 +6800,248 @@ class DevWatcher:
6713
6800
  it merged forever.
6714
6801
 
6715
6802
  A status or a HEAD that cannot be READ is a holdout too: "I could not
6716
- check" and "there is nothing to lose" are not the same sentence."""
6803
+ check" and "there is nothing to lose" are not the same sentence. Those
6804
+ two verdicts are the ones the `archive` policy refuses
6805
+ (`Holdout.archivable` False): they are the absence of evidence, and
6806
+ the archive acts on evidence only."""
6717
6807
  try:
6718
6808
  dirty = janitor.dirty_entries(workspace.path)
6719
6809
  except CommandError as exc:
6720
- return f"status could not be read ({exc})"
6810
+ return Holdout(f"status could not be read ({exc})")
6811
+ head = janitor.head_sha(workspace.path)
6721
6812
  if dirty:
6722
- return (
6813
+ return Holdout(
6723
6814
  f"has {len(dirty)} uncommitted change(s), e.g. "
6724
- f"{dirty[0].strip()!r}"
6815
+ f"{dirty[0].strip()!r}",
6816
+ uncommitted=True, head=head, archivable=head is not None,
6725
6817
  )
6726
6818
 
6727
- head = janitor.head_sha(workspace.path)
6728
6819
  if head is None:
6729
- return "HEAD could not be read"
6820
+ return Holdout("HEAD could not be read")
6730
6821
  published = {pull.head_sha for pull in pulls if pull.head_sha}
6731
6822
  if head not in published:
6732
- return (
6823
+ return Holdout(
6733
6824
  f"is at {head[:8]}, which is not a head GitHub saw on this "
6734
6825
  f"branch (newest PR #{newest.number} merged "
6735
- f"{(newest.head_sha or '?')[:8]}) — it carries unpushed commits"
6826
+ f"{(newest.head_sha or '?')[:8]}) — it carries unpushed commits",
6827
+ head=head, archivable=True,
6736
6828
  )
6737
6829
  return None
6738
6830
 
6831
+ def _note_holdout(
6832
+ self,
6833
+ workspace: "janitor.TaskWorkspace",
6834
+ newest: PullRequest,
6835
+ reason: str,
6836
+ holdout: Holdout,
6837
+ ) -> None:
6838
+ """Record one sighting of a holdout and log it at the level its
6839
+ episode has earned (issue #99).
6840
+
6841
+ WARNING with the full anchor evidence on the FIRST sighting -- the
6842
+ ledger has no row -- and again once `HOLDOUT_WARN_INTERVAL` has
6843
+ passed since the last warning; DEBUG, same evidence, on every sweep
6844
+ in between. The per-pass summary line (`_log_disk`) still names the
6845
+ holdout on every pass, so the console and the sentinel see the count
6846
+ whatever this logs. The row is upserted either way so its `reason`
6847
+ tracks the tree (a holdout that was dirty and is now merely unpushed
6848
+ reads that way in the console), and `last_warned` moves only on a
6849
+ warning -- that is what "at most once per 24 h" means.
6850
+
6851
+ Wall-clock, like every ledger timestamp: a restart must not re-page
6852
+ 21 holdouts, and a monotonic clock would.
6853
+
6854
+ Under dry-run the row is READ but never written (review round 1):
6855
+ `janitor_holdouts` is a dedupe gate, not only an observation table,
6856
+ and this file's convention is that a dry pass consumes no once-only
6857
+ gate -- otherwise `--dry-run --once` against the production ledger
6858
+ would silence the live WARNING for a day."""
6859
+ hub = str(workspace.hub)
6860
+ row = self.state.janitor_holdout(hub, workspace.slug)
6861
+ now = time.time()
6862
+ if row is None:
6863
+ level, since = logging.WARNING, ""
6864
+ elif now - int(row["last_warned"]) >= HOLDOUT_WARN_INTERVAL:
6865
+ level = logging.WARNING
6866
+ since = (
6867
+ f" (held out for {int((now - int(row['first_seen'])) // 3600)} h; "
6868
+ f"this is the daily reminder)"
6869
+ )
6870
+ else:
6871
+ level, since = logging.DEBUG, ""
6872
+ log.log(
6873
+ level,
6874
+ "janitor: HOLDOUT %s — anchor #%d is terminal (%s) but the "
6875
+ "workspace %s; never deleting unpushed work. This will not clear "
6876
+ "on its own: push it, remove the directory by hand, or set "
6877
+ "janitor_holdout_policy: archive%s",
6878
+ workspace.slug, newest.number, reason, holdout.reason, since,
6879
+ )
6880
+ if self.config.dry_run:
6881
+ return
6882
+ self.state.record_janitor_holdout(
6883
+ hub, workspace.slug, holdout.reason, warned=level == logging.WARNING
6884
+ )
6885
+
6886
+ def _archive_holdout(
6887
+ self,
6888
+ workspace: "janitor.TaskWorkspace",
6889
+ newest: PullRequest,
6890
+ reason: str,
6891
+ holdout: Holdout,
6892
+ ) -> "int | None":
6893
+ """The `archive` policy for ONE holdout: the bytes freed when the
6894
+ workspace was archived and reclaimed, or None when it stays.
6895
+
6896
+ Four steps, every one logged, every failure leaving the workspace in
6897
+ place -- at worst with its work committed locally, since step 1
6898
+ precedes the push -- and never removed:
6899
+
6900
+ 1. `git add -A && git commit --allow-empty` in the worktree -- only
6901
+ when the tree is dirty; an unpushed-commits-only holdout has
6902
+ nothing to commit and goes straight to 2. `-A` stages EVERY
6903
+ untracked, non-ignored file -- a `.harness/` or `.fu…prep/`
6904
+ scratch directory included -- and the log says how many entries
6905
+ are about to become a durable remote copy;
6906
+ 2. `git push origin HEAD:refs/heads/holdout/<branch>` -- a NEW ref,
6907
+ never the task branch (its PR is closed; a push there would
6908
+ resurrect a dead lane), never `--force`;
6909
+ 3. `git ls-remote origin refs/heads/holdout/<branch>` must equal the
6910
+ local HEAD. THE gate: this is the only condition under which
6911
+ deletion is allowed, and it is checked against the remote's
6912
+ answer, not the push's exit code;
6913
+ 4. `_remove_workspace`, exactly as the clean-reclaim path does.
6914
+
6915
+ Two refusals precede them. The branch must parse as a task branch
6916
+ (`janitor.is_task_branch`): the scan already admitted the directory
6917
+ by that grammar, but this is the step that names a ref on the remote
6918
+ after it, so it is re-asserted here, at the push. And the holdout
6919
+ must be `archivable` -- a verdict made of evidence, not of a status
6920
+ or a HEAD that could not be read; that refusal is the caller's.
6921
+
6922
+ A push or verification failure pages `janitor-archive-failed` once
6923
+ per workspace (`_escalate_archive_failed`) with git's stderr, and the
6924
+ holdout keeps warning on its 24 h cadence. Dry-run narrates every
6925
+ step and performs none. Failures of step 4 raise to the walk, which
6926
+ logs and continues -- the remote copy exists by then, so the
6927
+ directory is only disk."""
6928
+ slug = workspace.slug
6929
+ branch = workspace.branch
6930
+ if not janitor.is_task_branch(branch):
6931
+ log.warning(
6932
+ "janitor: archive refused for %s — %r is not a task branch, "
6933
+ "and only devloop's own TASK-<n>-… branches get a holdout/ "
6934
+ "ref; the workspace stays", slug, branch,
6935
+ )
6936
+ return None
6937
+ ref = janitor.holdout_ref(branch)
6938
+ path = workspace.path
6939
+ commit = holdout.uncommitted
6940
+ # The message claims a commit "for archiving", not an archive: the
6941
+ # archive is only real once step 3 verified it, and this commit is
6942
+ # what an operator finds on the task branch when a push failed.
6943
+ message = (
6944
+ f"holdout: unpushed work committed by devloop janitor for "
6945
+ f"archiving (anchor #{newest.number} {reason})"
6946
+ )
6947
+ if self.config.dry_run:
6948
+ log.info(
6949
+ "[dry-run] would archive %s: %spush HEAD to %s, verify the "
6950
+ "remote sha equals HEAD by ls-remote, then reclaim the "
6951
+ "workspace (anchor #%d: %s)",
6952
+ slug,
6953
+ f"commit {holdout.reason} as {message!r}, " if commit else "",
6954
+ ref, newest.number, reason,
6955
+ )
6956
+ return None
6957
+ committed = False
6958
+ try:
6959
+ if commit:
6960
+ entries = janitor.dirty_entries(path)
6961
+ log.info(
6962
+ "janitor: archive %s — committing %d untracked/modified "
6963
+ "entr(ies) (every non-ignored file, scratch directories "
6964
+ "included), e.g. %s",
6965
+ slug, len(entries),
6966
+ ", ".join(repr(e.strip()) for e in entries[:3]) or "nothing",
6967
+ )
6968
+ janitor.commit_all(path, message)
6969
+ committed = True
6970
+ log.info(
6971
+ "janitor: archive %s — committed the uncommitted work "
6972
+ "(%s)", slug, holdout.reason,
6973
+ )
6974
+ head = janitor.head_sha(path)
6975
+ if head is None:
6976
+ raise CommandError(
6977
+ ["git", "rev-parse", "HEAD"], 1,
6978
+ "HEAD could not be read before the push",
6979
+ )
6980
+ janitor.push_ref(path, ref)
6981
+ log.info("janitor: archive %s — pushed %s to %s", slug, head[:8], ref)
6982
+ remote = janitor.remote_ref_sha(path, ref)
6983
+ if remote != head:
6984
+ raise CommandError(
6985
+ ["git", "ls-remote", janitor.HOLDOUT_REMOTE, ref], 1,
6986
+ f"{ref} on {janitor.HOLDOUT_REMOTE} is at "
6987
+ f"{remote or 'no such ref'}, local HEAD is {head} — "
6988
+ f"the remote copy is not verified",
6989
+ )
6990
+ except CommandError as exc:
6991
+ self._escalate_archive_failed(workspace, newest, exc, committed)
6992
+ return None
6993
+ log.info(
6994
+ "janitor: archive %s — verified %s on %s equals HEAD %s; "
6995
+ "reclaiming", slug, ref, janitor.HOLDOUT_REMOTE, head[:8],
6996
+ )
6997
+ size = janitor.dir_size(path)
6998
+ self._remove_workspace(workspace)
6999
+ log.info(
7000
+ "janitor: archived %s to %s and reclaimed it (anchor #%d: %s; %s "
7001
+ "freed). Recover the work with `git fetch %s %s`",
7002
+ slug, ref, newest.number, reason, janitor.human(size),
7003
+ janitor.HOLDOUT_REMOTE, ref[len("refs/heads/"):],
7004
+ )
7005
+ return size
7006
+
7007
+ def _escalate_archive_failed(
7008
+ self,
7009
+ workspace: "janitor.TaskWorkspace",
7010
+ newest: PullRequest,
7011
+ exc: CommandError,
7012
+ committed: bool,
7013
+ ) -> None:
7014
+ """Page the operator ONCE per workspace that the archive could not
7015
+ push or verify, with git's stderr. The page is the ERROR line plus a
7016
+ ledger row the console inbox renders (linked to the anchor PR --
7017
+ there is no comment: the PR is closed, and a closed PR is not where
7018
+ anyone is reading). Later failures of the same workspace log at DEBUG;
7019
+ the holdout itself keeps warning on its own 24 h cadence.
7020
+
7021
+ `committed` says whether step 1 ran before the failure: the workspace
7022
+ is then in place with its work as a LOCAL commit, not "untouched",
7023
+ and the line says so (review round 1) -- it is the one place an
7024
+ operator reads when something went wrong."""
7025
+ kind = archive_failed_kind(workspace.slug)
7026
+ stderr = (exc.stderr or "").strip()
7027
+ state = (
7028
+ "in place with its work committed locally (not pushed)" if committed
7029
+ else "in place, unchanged"
7030
+ )
7031
+ if self.state.escalated(newest.full_name, newest.number, kind):
7032
+ log.debug(
7033
+ "janitor: archive of %s failed again (%s); already escalated",
7034
+ workspace.slug, exc,
7035
+ )
7036
+ return
7037
+ log.error(
7038
+ "janitor: ARCHIVE FAILED for %s — %s; the workspace stays a "
7039
+ "holdout, %s (git said: %s). Escalating to operator (once per "
7040
+ "workspace)",
7041
+ workspace.slug, exc, state, stderr or "nothing",
7042
+ )
7043
+ self.state.record_escalation(newest.full_name, newest.number, kind)
7044
+
6739
7045
  def _remove_workspace(self, workspace: "janitor.TaskWorkspace") -> None:
6740
7046
  """Remove one proven-reclaimable workspace and its local branch.
6741
7047
 
@@ -6884,7 +7190,7 @@ class DevWatcher:
6884
7190
  log.log(
6885
7191
  logging.WARNING if over else logging.INFO,
6886
7192
  "disk: %s of %s used (%s%%%s) on %s%s; hubs: %s%s; reclaimed %d "
6887
- "workspace(s) freeing %s this pass (%d scanned, %d dirty "
7193
+ "workspace(s)%s freeing %s this pass (%d scanned, %d dirty "
6888
7194
  "holdout(s)%s)",
6889
7195
  janitor.human(usage["used_bytes"]),
6890
7196
  janitor.human(usage["total_bytes"]),
@@ -6895,6 +7201,7 @@ class DevWatcher:
6895
7201
  breakdown,
6896
7202
  f"; top: {offenders}" if over else "",
6897
7203
  reclaim.removed,
7204
+ f" ({reclaim.archived} archived)" if reclaim.archived else "",
6898
7205
  janitor.human(reclaim.freed),
6899
7206
  reclaim.scanned,
6900
7207
  len(reclaim.dirty),
@@ -59,6 +59,18 @@ one line per poll -- keyed (repo_slug, number, kind) where kind folds the
59
59
  prospective session name (loop.deferral_activity_kind). Spawn lines self-
60
60
  dedupe on their own spawn ledger row and take no activity_log row.
61
61
 
62
+ The `janitor_holdouts` table is the workspace janitor's memory of its dirty
63
+ HOLDOUTS (issue #99): task workspaces whose anchor PR is terminal but which
64
+ carry uncommitted or unpushed work, so the janitor will never delete them.
65
+ One row per (hub, slug), upserted on every sweep that finds the workspace
66
+ still held out: `first_seen` dates the episode, `last_warned` is the clock
67
+ the once-per-24h WARNING cadence runs on (the sweeps in between log the same
68
+ evidence at DEBUG), and `reason` is the sentence the warning carries. Rows are
69
+ NOT permanent -- the janitor prunes, per hub it walked, every row whose
70
+ directory it no longer found (pushed and reclaimed, archived, or removed by
71
+ hand), logging one `holdout cleared` line; a directory that comes back starts
72
+ a fresh episode. `read_janitor_holdouts` is the console's reader.
73
+
62
74
  The `poll_snapshots` table is a different animal from the ledgers above: it
63
75
  records what each poll pass OBSERVED, not what the daemon must remember to
64
76
  avoid double-work. One row per pass carries the timing, the per-edge
@@ -143,6 +155,15 @@ CREATE TABLE IF NOT EXISTS activity_log (
143
155
  PRIMARY KEY (repo_slug, number, kind)
144
156
  );
145
157
 
158
+ CREATE TABLE IF NOT EXISTS janitor_holdouts (
159
+ hub TEXT NOT NULL,
160
+ slug TEXT NOT NULL,
161
+ first_seen INTEGER NOT NULL,
162
+ last_warned INTEGER NOT NULL,
163
+ reason TEXT NOT NULL,
164
+ PRIMARY KEY (hub, slug)
165
+ );
166
+
146
167
  CREATE TABLE IF NOT EXISTS poll_snapshots (
147
168
  id INTEGER PRIMARY KEY AUTOINCREMENT,
148
169
  ts INTEGER NOT NULL,
@@ -899,6 +920,101 @@ class State:
899
920
  f"record_activity({repo_slug}#{number}, {kind})", write),
900
921
  )
901
922
 
923
+ # -- janitor holdouts (issue #99) ---------------------------------------
924
+ #
925
+ # The workspace janitor's once-per-episode memory. Every method here is
926
+ # keyed (hub, slug): `hub` is the hub directory the workspace lives under
927
+ # and `slug` is `janitor.TaskWorkspace.slug` (`<hub name>/<branch>`), the
928
+ # name the log lines and the console use. Timestamps are wall-clock
929
+ # seconds like every other row in this ledger -- `last_warned` outlives a
930
+ # daemon restart on purpose, so a restart does not re-page every holdout.
931
+
932
+ def janitor_holdout(self, hub: str, slug: str) -> "dict | None":
933
+ """The row for one holdout, or None when the janitor has not recorded
934
+ it (a first sighting, or an episode that was cleared)."""
935
+ row = self._db.execute(
936
+ "SELECT hub, slug, first_seen, last_warned, reason "
937
+ "FROM janitor_holdouts WHERE hub=? AND slug=?",
938
+ (hub, slug),
939
+ ).fetchone()
940
+ return None if row is None else dict(row)
941
+
942
+ def record_janitor_holdout(
943
+ self, hub: str, slug: str, reason: str, *, warned: bool
944
+ ) -> None:
945
+ """Upsert one holdout sighting. A NEW row carries `first_seen` and
946
+ `last_warned` both stamped now (the first sighting is always warned);
947
+ an existing row keeps `first_seen`, takes the fresh `reason`, and
948
+ moves `last_warned` only when the caller says this sweep WARNED --
949
+ that is what makes the cadence "at most once per 24 h" rather than
950
+ "24 h after the last sweep".
951
+
952
+ BEST-EFFORT (see _best_effort): this is cadence bookkeeping. A row
953
+ that does not land costs the pre-#99 behaviour for that holdout --
954
+ the WARNING repeats next sweep -- which on a wedged store is the
955
+ right failure: the store's own warnings are already saying why."""
956
+ ts = int(time.time())
957
+
958
+ def write() -> None:
959
+ self._db.execute(
960
+ "INSERT INTO janitor_holdouts "
961
+ "(hub, slug, first_seen, last_warned, reason) "
962
+ "VALUES (?,?,?,?,?) "
963
+ "ON CONFLICT(hub, slug) DO UPDATE SET "
964
+ "reason=excluded.reason, "
965
+ "last_warned=CASE WHEN ? THEN excluded.last_warned "
966
+ "ELSE janitor_holdouts.last_warned END",
967
+ (hub, slug, ts, ts, reason, 1 if warned else 0),
968
+ )
969
+ self._db.commit()
970
+
971
+ self._best_effort(f"record_janitor_holdout({slug})", write)
972
+
973
+ def prune_janitor_holdouts(self, hub: str, keep: "set[str]") -> "list[str]":
974
+ """Drop the rows of ONE hub whose slug is not in `keep`, returning
975
+ the slugs dropped (empty when nothing was dropped, or when the store
976
+ refused the write). The janitor calls this once per hub it actually
977
+ WALKED, passing every workspace it found there -- so a row goes only
978
+ when its directory is provably gone, never because a sweep skipped
979
+ that hub or could not list it.
980
+
981
+ BEST-EFFORT like `prune_escalations`, and with its SELECT-inside-the-
982
+ closure shape for the same reason: the ordinary case deletes nothing,
983
+ and a closure that only read must not be taken as proof that the
984
+ store accepts writes."""
985
+ dropped: "list[str]" = []
986
+
987
+ def write() -> "bool | None":
988
+ del dropped[:]
989
+ rows = self._db.execute(
990
+ "SELECT slug FROM janitor_holdouts WHERE hub=?", (hub,)
991
+ ).fetchall()
992
+ dropped.extend(
993
+ row["slug"] for row in rows if row["slug"] not in keep
994
+ )
995
+ if not dropped:
996
+ return False
997
+ for slug in dropped:
998
+ self._db.execute(
999
+ "DELETE FROM janitor_holdouts WHERE hub=? AND slug=?",
1000
+ (hub, slug),
1001
+ )
1002
+ self._db.commit()
1003
+ return True
1004
+
1005
+ wrote = self._best_effort(f"prune_janitor_holdouts({hub})", write)
1006
+ return list(dropped) if wrote else []
1007
+
1008
+ def read_janitor_holdouts(self) -> "list[dict]":
1009
+ """Every holdout row, oldest episode first -- the console's
1010
+ "Holdouts (N)" list. No GitHub call: the janitor already wrote down
1011
+ everything the panel shows."""
1012
+ rows = self._db.execute(
1013
+ "SELECT hub, slug, first_seen, last_warned, reason "
1014
+ "FROM janitor_holdouts ORDER BY first_seen ASC, slug ASC"
1015
+ ).fetchall()
1016
+ return [dict(row) for row in rows]
1017
+
902
1018
  # -- poll snapshots (the UI sidecar's exhaust buffer) ------------------
903
1019
 
904
1020
  def record_snapshot(
@@ -317,6 +317,10 @@ _DASHBOARD = """<!doctype html>
317
317
  <section class="panel"><p class="overline">Worker Tasks</p><div id="tasks"></div></section>
318
318
  </div>
319
319
 
320
+ <section class="panel"><p class="overline">Holdouts (<span id="holdouts-count">0</span>)
321
+ &middot; <span class="muted">dirty task workspaces the janitor keeps</span></p>
322
+ <div id="holdouts"></div></section>
323
+
320
324
  <section class="panel"><p class="overline">Sessions</p><div id="sessions"></div></section>
321
325
 
322
326
  <section class="panel">
@@ -406,10 +410,14 @@ _JS = r"""
406
410
  var pct = t.rate.limit ? Math.round(100 * (t.rate.limit - t.rate.remaining) / t.rate.limit) : 0;
407
411
  out += tile('GH Rate', t.rate.remaining + ' / ' + t.rate.limit, 'used ' + pct + '%', pct);
408
412
  } else { out += tile('GH Rate', '--', 'unavailable'); }
413
+ // The holdout count rides the disk tile's sub-line: a holdout is disk
414
+ // the janitor will never get back on its own, so it belongs next to the
415
+ // number it explains. The list itself is the Holdouts panel below.
416
+ var hsub = t.holdouts ? ' \u00b7 ' + t.holdouts + ' holdout' + (t.holdouts === 1 ? '' : 's') : '';
409
417
  if (t.volume) {
410
- var vsub = bytes(t.volume.used_bytes) + ' / ' + bytes(t.volume.total_bytes);
418
+ var vsub = bytes(t.volume.used_bytes) + ' / ' + bytes(t.volume.total_bytes) + hsub;
411
419
  out += tile('Volume', t.volume.percent + '%', vsub, t.volume.percent);
412
- } else { out += tile('Volume', '--', 'unavailable'); }
420
+ } else { out += tile('Volume', '--', 'unavailable' + hsub); }
413
421
  out += tile('Queue Depth', t.queue_depth, 'candidates last poll');
414
422
  el('tiles').innerHTML = out;
415
423
  }
@@ -460,6 +468,18 @@ _JS = r"""
460
468
  }).join('');
461
469
  }
462
470
 
471
+ function renderHoldouts(items) {
472
+ // Read from the janitor_holdouts ledger table, never from GitHub: the
473
+ // daemon already wrote down why each one is kept. Oldest episode first.
474
+ el('holdouts-count').textContent = items.length;
475
+ if (!items.length) { el('holdouts').innerHTML = '<div class="empty">No holdouts.</div>'; return; }
476
+ el('holdouts').innerHTML = items.map(function (h) {
477
+ return '<div class="inbox-item"><span class="mono">' + esc(h.slug) + '</span>' +
478
+ '<span class="muted task-title" title="' + esc(h.reason) + '">' + esc(h.reason) + '</span>' +
479
+ '<span class="muted num">' + dur(h.age_seconds) + '</span></div>';
480
+ }).join('');
481
+ }
482
+
463
483
  function renderTasks(tasks) {
464
484
  if (!tasks.length) { el('tasks').innerHTML = '<div class="empty">No worker tasks listed.</div>'; return; }
465
485
  el('tasks').innerHTML = tasks.map(function (t) {
@@ -538,6 +558,7 @@ _JS = r"""
538
558
  sparkline(el('spark-active'), d.sparklines.active_sessions);
539
559
  renderPipeline(d.pipeline);
540
560
  renderInbox(d.inbox);
561
+ renderHoldouts(d.holdouts || []);
541
562
  renderTasks(d.tasks);
542
563
  renderSessions(d.sessions);
543
564
  renderLog(d.log);
@@ -52,6 +52,12 @@ from . import sysinfo
52
52
  # touches none of it. The agreement with `loop.ESCALATION_FOREIGN_SESSION` is
53
53
  # pinned by a test instead.
54
54
  FOREIGN_SESSION_KIND = "foreign-session"
55
+ # The archive-failure page of the workspace janitor (issue #99). Its rows
56
+ # carry the anchor PULL REQUEST's coordinates in the `issue` column, like the
57
+ # fix-cap/maintain/orphan families, so the inbox links them as PRs. A literal
58
+ # for FOREIGN_SESSION_KIND's reason; agreement with
59
+ # `loop.ESCALATION_ARCHIVE_FAILED` is pinned by a test.
60
+ ARCHIVE_FAILED_KIND = "janitor-archive-failed"
55
61
 
56
62
  # How many recent snapshots feed the sparklines / pipeline board.
57
63
  SPARK_POINTS = 60
@@ -192,6 +198,27 @@ class Sources:
192
198
  def snapshots(self, limit: int = SPARK_POINTS) -> "list[dict]":
193
199
  return self._read_state([], lambda st: st.read_snapshots(limit))
194
200
 
201
+ def holdouts(self) -> "list[dict]":
202
+ """The janitor's dirty holdouts from the `janitor_holdouts` ledger
203
+ table (issue #99), oldest episode first, each with its age -- the
204
+ console's "Holdouts (N)" list beside the disk tile. Read-only and
205
+ zero GitHub cost: the janitor already wrote down the anchor verdict
206
+ and the reason, and a panel that re-derived them would spend one
207
+ `pulls?head=` call per holdout per refresh."""
208
+ rows = self._read_state([], lambda st: st.read_janitor_holdouts())
209
+ now = int(self._wall())
210
+ return [
211
+ {
212
+ "hub": row["hub"],
213
+ "slug": row["slug"],
214
+ "reason": row["reason"],
215
+ "first_seen": row["first_seen"],
216
+ "last_warned": row["last_warned"],
217
+ "age_seconds": max(0, now - int(row["first_seen"])),
218
+ }
219
+ for row in rows
220
+ ]
221
+
195
222
  def ledgers(self) -> dict:
196
223
  empty: "dict[str, list]" = {
197
224
  "spawns": [], "fix_spawns": [],
@@ -439,6 +466,7 @@ class Sources:
439
466
  sessions = self.sessions()
440
467
  rate = self.rate_limit()
441
468
  disk = sysinfo.disk_usage(self.config.workspace_root)
469
+ holdouts = self.holdouts()
442
470
 
443
471
  # Sparklines want oldest -> newest for left-to-right drawing.
444
472
  chrono = list(reversed(snaps))
@@ -499,6 +527,7 @@ class Sources:
499
527
  "active_sessions": active,
500
528
  "rate": rate,
501
529
  "volume": disk,
530
+ "holdouts": len(holdouts),
502
531
  "queue_depth": candidates,
503
532
  },
504
533
  "sparklines": sparklines,
@@ -508,6 +537,7 @@ class Sources:
508
537
  "edges": pipeline,
509
538
  },
510
539
  "inbox": inbox,
540
+ "holdouts": holdouts,
511
541
  "sessions": sessions,
512
542
  "tasks": self.worker_tasks(),
513
543
  "log": self.log_tail(),
@@ -519,9 +549,12 @@ class Sources:
519
549
  for row in escalations:
520
550
  kind = row["kind"]
521
551
  number = row["issue"]
522
- # fix-*/maintain-*/orphan-* rows store a PR number in the `issue`
523
- # column; everything else is an issue number.
524
- is_pr = kind.startswith(("fix-cap", "maintain", "orphan"))
552
+ # fix-*/maintain-*/orphan-*/janitor-archive-failed rows store a
553
+ # PR number in the `issue` column; everything else is an issue
554
+ # number.
555
+ is_pr = kind.startswith(
556
+ ("fix-cap", "maintain", "orphan", ARCHIVE_FAILED_KIND)
557
+ )
525
558
  leaf = "pull" if is_pr else "issues"
526
559
  # foreign-session rows have NO GitHub artifact behind them (issue
527
560
  # #95): the subject is a tmux session, and their repo_slug/issue
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.11
3
+ Version: 0.8.13
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera