alissa-tools-github-devloop 0.8.12__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.
- {alissa_tools_github_devloop-0.8.12/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.13}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/config.py +41 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/janitor.py +79 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/loop.py +342 -35
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/state.py +116 -0
- alissa_tools_github_devloop-0.8.13/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/webui/page.py +23 -2
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/webui/sources.py +36 -3
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_devloop-0.8.12/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.13}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -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
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
6558
|
-
|
|
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
|
|
6588
|
-
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
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
|
-
|
|
6651
|
-
|
|
6652
|
-
|
|
6653
|
-
|
|
6654
|
-
|
|
6655
|
-
|
|
6656
|
-
|
|
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
|
-
) -> "
|
|
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(
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.8.13
|
|
@@ -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
|
+
· <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
|
|
523
|
-
# column; everything else is an issue
|
|
524
|
-
|
|
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 +0,0 @@
|
|
|
1
|
-
0.8.12
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|