alissa-tools-github-devloop 0.8.1__tar.gz → 0.8.2__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.1/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.2}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/__main__.py +13 -2
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/config.py +34 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/loop.py +438 -85
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/state.py +13 -0
- alissa_tools_github_devloop-0.8.2/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_devloop-0.8.1/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.2}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -98,7 +98,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
98
98
|
metavar="N",
|
|
99
99
|
help="max concurrent worker sessions (develop-*/fix-*/maintain-*) "
|
|
100
100
|
"before actionable items defer to the next poll; 0 = unlimited "
|
|
101
|
-
"(the default)",
|
|
101
|
+
"(the default). Also the reaper's post-sweep alert threshold",
|
|
102
|
+
)
|
|
103
|
+
over.add_argument(
|
|
104
|
+
"--reap-grace-minutes",
|
|
105
|
+
type=int,
|
|
106
|
+
metavar="MINUTES",
|
|
107
|
+
help="how long a worker session must have been quiet before the "
|
|
108
|
+
"reaper may kill it on a terminal anchor; 0 = use stale_minutes "
|
|
109
|
+
"(default: 30)",
|
|
102
110
|
)
|
|
103
111
|
over.add_argument(
|
|
104
112
|
"--on-missing-origin-task",
|
|
@@ -267,6 +275,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
267
275
|
"attempt_cap": args.attempt_cap,
|
|
268
276
|
"stale_minutes": args.stale_minutes,
|
|
269
277
|
"max_sessions": args.max_sessions,
|
|
278
|
+
"reap_grace_minutes": args.reap_grace_minutes,
|
|
270
279
|
"on_missing_origin_task": args.on_missing_origin_task,
|
|
271
280
|
"on_missing_hub": args.on_missing_hub,
|
|
272
281
|
"fix_rounds_enabled": args.fix_rounds_enabled,
|
|
@@ -309,12 +318,14 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
309
318
|
log.info("reviewers: %s", ", ".join(config.reviewers) or "none")
|
|
310
319
|
log.info(
|
|
311
320
|
"poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
|
|
312
|
-
"max_sessions=%s;
|
|
321
|
+
"max_sessions=%s; reap_grace=%s min; fix_rounds=%s; maintain=%s "
|
|
322
|
+
"(label %r); resume=%s; "
|
|
313
323
|
"rerequest=%s (grace %s min); orphan_sweep=%s (grace %s min); "
|
|
314
324
|
"activity_claims=%s",
|
|
315
325
|
config.poll_interval, config.dry_run, config.attempt_cap,
|
|
316
326
|
config.stale_minutes,
|
|
317
327
|
config.max_sessions or "unlimited",
|
|
328
|
+
config.reap_grace_seconds // 60,
|
|
318
329
|
config.fix_rounds_enabled,
|
|
319
330
|
config.maintain_enabled, config.maintain_label,
|
|
320
331
|
config.resume_enabled,
|
|
@@ -57,6 +57,7 @@ CONFIG_KEYS = (
|
|
|
57
57
|
"attempt_cap",
|
|
58
58
|
"stale_minutes",
|
|
59
59
|
"max_sessions",
|
|
60
|
+
"reap_grace_minutes",
|
|
60
61
|
"on_missing_origin_task",
|
|
61
62
|
"on_missing_hub",
|
|
62
63
|
"fix_rounds_enabled",
|
|
@@ -149,6 +150,18 @@ class Config:
|
|
|
149
150
|
# listing, so enabling it costs no extra GitHub or CLI calls.
|
|
150
151
|
max_sessions: int = 0
|
|
151
152
|
|
|
153
|
+
# The reaper's grace: how long a worker session must have been QUIET (no
|
|
154
|
+
# tmux activity) before a terminal anchor licenses killing it. The point
|
|
155
|
+
# is the window right after an anchor goes terminal -- a PR merges while
|
|
156
|
+
# its session is still attaching evidence, answering a triage thread,
|
|
157
|
+
# writing its delivery note -- where the anchor already says "done" and
|
|
158
|
+
# the session is not. Busy is spared regardless (see DevWatcher's sweep);
|
|
159
|
+
# this covers the session that is idle between two bursts of that work.
|
|
160
|
+
# 30 minutes by default: long enough to outlast a close-out, short enough
|
|
161
|
+
# that a corpse costs at most one extra poll window of memory. 0 falls
|
|
162
|
+
# back to stale_minutes, the sentinel every other grace knob here uses.
|
|
163
|
+
reap_grace_minutes: int = 30
|
|
164
|
+
|
|
152
165
|
on_missing_origin_task: str = ON_MISSING_WARN
|
|
153
166
|
on_missing_hub: str = HUB_SKIP
|
|
154
167
|
|
|
@@ -280,6 +293,15 @@ class Config:
|
|
|
280
293
|
the re-request instantly on a default config."""
|
|
281
294
|
return (self.rerequest_grace_minutes or self.stale_minutes) * 60
|
|
282
295
|
|
|
296
|
+
@property
|
|
297
|
+
def reap_grace_seconds(self) -> int:
|
|
298
|
+
"""How long a worker session must have been quiet before the reaper
|
|
299
|
+
may kill it, in seconds. Read this, never the raw field -- the
|
|
300
|
+
`rerequest_grace_seconds` contract verbatim: 0 is the sentinel for
|
|
301
|
+
"the stale window", and a caller reading the field directly would kill
|
|
302
|
+
a session that went quiet seconds ago on a config that set 0."""
|
|
303
|
+
return (self.reap_grace_minutes or self.stale_minutes) * 60
|
|
304
|
+
|
|
283
305
|
@property
|
|
284
306
|
def orphan_grace_seconds(self) -> int:
|
|
285
307
|
"""How long a draft's head must have been public before the orphan
|
|
@@ -391,6 +413,17 @@ class Config:
|
|
|
391
413
|
f"max_sessions must be >= 0 (0 = unlimited), got {max_sessions}"
|
|
392
414
|
)
|
|
393
415
|
|
|
416
|
+
# The reaper's grace, validated like the two below it -- same 0
|
|
417
|
+
# sentinel, and a negative value is the only invalid one (it would
|
|
418
|
+
# make every listed session instantly reapable, which is the one
|
|
419
|
+
# mistake here that kills live work).
|
|
420
|
+
reap_grace = int(raw.get("reap_grace_minutes", cls.reap_grace_minutes))
|
|
421
|
+
if reap_grace < 0:
|
|
422
|
+
raise ValueError(
|
|
423
|
+
f"reap_grace_minutes must be >= 0 (0 = use stale_minutes), "
|
|
424
|
+
f"got {reap_grace}"
|
|
425
|
+
)
|
|
426
|
+
|
|
394
427
|
# 0 is the sentinel for "fall back to stale_minutes" (the documented
|
|
395
428
|
# default), so the floor is 0 like max_sessions' -- a negative grace
|
|
396
429
|
# would make the self-heal fire on a head pushed in the future.
|
|
@@ -433,6 +466,7 @@ class Config:
|
|
|
433
466
|
attempt_cap=cap,
|
|
434
467
|
stale_minutes=stale,
|
|
435
468
|
max_sessions=max_sessions,
|
|
469
|
+
reap_grace_minutes=reap_grace,
|
|
436
470
|
on_missing_origin_task=mode,
|
|
437
471
|
on_missing_hub=hub_mode,
|
|
438
472
|
fix_rounds_enabled=bool(
|
|
@@ -256,25 +256,58 @@ cover the window it is for. The cost is a crash between the line and the
|
|
|
256
256
|
enqueue leaving a claim with no session -- one stale window of deferral, then
|
|
257
257
|
the line ages out and the next poll spawns normally.
|
|
258
258
|
|
|
259
|
-
Finally, every pass runs the SESSION REAPER: finished
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
259
|
+
Finally, every pass runs the SESSION REAPER: finished worker sessions
|
|
260
|
+
otherwise idle in tmux forever (observed live on the reviewer daemon,
|
|
261
|
+
2026-07-22 -- and dev workers are worse, because they block through entire
|
|
262
|
+
review loops; by 2026-07-28 the shared container had climbed past 10 GB with 4
|
|
263
|
+
corpses among 12 live claude sessions). Every directive tells the session to
|
|
264
|
+
kill itself as its last act, but that self-kill is UNRELIABLE by assumption --
|
|
265
|
+
a session that crashes, stalls, or is superseded never reaches its last act --
|
|
266
|
+
so the sweep is the guarantee, not the fast path. The sweep is deliberately
|
|
266
267
|
SEARCH-INDEPENDENT, the reviewer daemon's structural lesson baked in: its
|
|
267
268
|
`_reap_finished` keyed off the poll's search results and starved -- it became
|
|
268
269
|
unreachable the moment the trigger state cleared, which is exactly when
|
|
269
270
|
sessions finish. Here the sweep runs unconditionally at the top of poll_once,
|
|
270
|
-
BEFORE the searches, off the tmux list
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
`
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
271
|
+
BEFORE the searches, off the tmux list, the local ledgers, and GitHub alone.
|
|
272
|
+
|
|
273
|
+
What the sweep will even LOOK at is an exact GRAMMAR, not a prefix (see
|
|
274
|
+
`parse_worker_session`): `develop-<owner>-<repo>-i<n>-a<k>`,
|
|
275
|
+
`fix-<owner>-<repo>-pr<n>-r<r>-a<k>`, `maintain-<owner>-<repo>-pr<n>-a<k>`,
|
|
276
|
+
spelled by the very builders that name the spawns. The container is SHARED
|
|
277
|
+
with other daemons' lanes and with operator shells, and this code kills
|
|
278
|
+
processes: a name that does not parse is not devloop's worker and is never
|
|
279
|
+
considered, let alone killed. Killing is per-session (`alissa tmux kill
|
|
280
|
+
<name>`), never `kill-server` -- the server hosts every lane's workers.
|
|
281
|
+
|
|
282
|
+
"Finished" is an ANCHOR question: the issue closed, or the PR closed/merged.
|
|
283
|
+
The anchor is resolved twice over -- from the spawn ledgers when a row exists
|
|
284
|
+
(which also yields the ledger-only supersession signals: a newer attempt or a
|
|
285
|
+
newer review round ends an older session whatever GitHub says), and otherwise
|
|
286
|
+
from the session NAME, whose slug is matched back to the repos allowlist and
|
|
287
|
+
whose number is asked of GitHub directly. The name path is what makes the
|
|
288
|
+
sweep survive the ledger: a fresh state volume, a migrated deployment, or the
|
|
289
|
+
enqueue-before-ledger crash window all leave real sessions no local row can
|
|
290
|
+
explain, and before it they idled forever.
|
|
291
|
+
|
|
292
|
+
A reap needs THREE things at once: a terminal anchor, an IDLE session, and
|
|
293
|
+
`reap_grace_minutes` (default 30) elapsed since the session's last tmux
|
|
294
|
+
activity. Busy is never killed, terminal anchor or not -- post-merge close-out
|
|
295
|
+
work is real (the PR #275 -> #279 replay-onto-main incident happened in
|
|
296
|
+
exactly that window) -- so a busy session with a terminal anchor is LOGGED as
|
|
297
|
+
a holdout, with its anchor evidence, and left alone. The grace exists so a
|
|
298
|
+
just-merged PR's session can finish its in-session handoff before dying.
|
|
299
|
+
Terminal-anchor-only reaping is also what makes the sweep compose with the
|
|
300
|
+
respawn paths: a closed issue / merged PR has no pending work, and every edge
|
|
301
|
+
re-fetches its anchor and returns before it consults session liveness, so a
|
|
302
|
+
reaped session can never be resurrected as a resume.
|
|
303
|
+
|
|
304
|
+
After the sweep, if the live worker sessions STILL exceed `max_sessions`, the
|
|
305
|
+
pass logs loudly (ERROR, page-worthy): the reaper only kills idle sessions
|
|
306
|
+
with terminal anchors, so an overflow means busy-but-terminal work is piling
|
|
307
|
+
up faster than it finishes and a human should look. Reap failures never block
|
|
308
|
+
the walk: a tmux error or a GitHub 404 on an anchor spares that session for
|
|
309
|
+
the pass and moves on, dry-run only logs, and kills are best-effort (a CLI
|
|
310
|
+
without `tmux kill` degrades to a once-per-process warning).
|
|
278
311
|
"""
|
|
279
312
|
|
|
280
313
|
from __future__ import annotations
|
|
@@ -1451,6 +1484,21 @@ class SessionLiveness:
|
|
|
1451
1484
|
return tuple(sorted(n for n in self._live if n.startswith(prefix)))
|
|
1452
1485
|
|
|
1453
1486
|
|
|
1487
|
+
@dataclass(frozen=True, slots=True)
|
|
1488
|
+
class Sweep:
|
|
1489
|
+
"""What ONE reaper sweep did and saw.
|
|
1490
|
+
|
|
1491
|
+
`reaped` counts the kills (would-kills under dry-run), `names` carries
|
|
1492
|
+
them so the pass can discount them from the live-session count, and
|
|
1493
|
+
`holdouts` are the sessions whose anchor is terminal but which were BUSY
|
|
1494
|
+
-- never killed, by the locked guardrail, and reported because they are
|
|
1495
|
+
the usual answer to "why is the machine still over the cap?"."""
|
|
1496
|
+
|
|
1497
|
+
reaped: int
|
|
1498
|
+
names: "set[str]"
|
|
1499
|
+
holdouts: "tuple[str, ...]" = ()
|
|
1500
|
+
|
|
1501
|
+
|
|
1454
1502
|
class SessionBudget:
|
|
1455
1503
|
"""The per-pass concurrency budget behind `max_sessions`.
|
|
1456
1504
|
|
|
@@ -1640,6 +1688,151 @@ def maintain_session_name(pr: PullRequest, attempt: int) -> str:
|
|
|
1640
1688
|
return f"{pr_session_prefix(pr, 'maintain')}a{attempt}"
|
|
1641
1689
|
|
|
1642
1690
|
|
|
1691
|
+
# The reaper's GRAMMAR: devloop's own worker session names, read back. Each
|
|
1692
|
+
# expression is the exact shape its builder above writes -- `_name_slug`
|
|
1693
|
+
# lowercases and squashes to `[a-z0-9-]`, so the slug alphabet is stated once
|
|
1694
|
+
# here and nowhere else. The slug is greedy on purpose: it must swallow every
|
|
1695
|
+
# dash of `<owner>-<repo>` and stop at the LAST `-i<n>-a<k>` / `-pr<n>-...`
|
|
1696
|
+
# tail, which is the only split the builders can ever have produced.
|
|
1697
|
+
_DEVELOP_NAME_RE = re.compile(r"develop-(?P<slug>[a-z0-9-]+)-i(?P<number>\d+)-a(?P<attempt>\d+)")
|
|
1698
|
+
_FIX_NAME_RE = re.compile(
|
|
1699
|
+
r"fix-(?P<slug>[a-z0-9-]+)-pr(?P<number>\d+)-r(?P<round>\d+)-a(?P<attempt>\d+)"
|
|
1700
|
+
)
|
|
1701
|
+
_MAINTAIN_NAME_RE = re.compile(r"maintain-(?P<slug>[a-z0-9-]+)-pr(?P<number>\d+)-a(?P<attempt>\d+)")
|
|
1702
|
+
|
|
1703
|
+
# The three edges' names for themselves, as they appear in a session name and
|
|
1704
|
+
# in the reaper's log evidence.
|
|
1705
|
+
EDGE_DEVELOP = "develop"
|
|
1706
|
+
EDGE_FIX = "fix"
|
|
1707
|
+
EDGE_MAINTAIN = "maintain"
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
@dataclass(frozen=True, slots=True)
|
|
1711
|
+
class WorkerSession:
|
|
1712
|
+
"""One tmux session name parsed back into the anchor it works on.
|
|
1713
|
+
|
|
1714
|
+
The reaper's admission ticket AND its anchor resolver in one object: a
|
|
1715
|
+
name that parses is provably one of devloop's own workers (see
|
|
1716
|
+
`parse_worker_session`), and what it parses INTO -- the repo slug, the
|
|
1717
|
+
issue or PR number -- is the anchor whose terminal state decides whether
|
|
1718
|
+
the session still has a reason to exist. The `slug` is the NAME slug
|
|
1719
|
+
(`<owner>-<repo>` squashed by `_name_slug`), not an `owner/repo`: the
|
|
1720
|
+
squash is lossy (`a/b-c` and `a-b/c` both slug to `a-b-c`), so turning
|
|
1721
|
+
it back into a repo is the allowlist's job, never a string split's.
|
|
1722
|
+
"""
|
|
1723
|
+
|
|
1724
|
+
name: str
|
|
1725
|
+
edge: str
|
|
1726
|
+
slug: str
|
|
1727
|
+
number: int
|
|
1728
|
+
attempt: int
|
|
1729
|
+
round_: "int | None" = None
|
|
1730
|
+
|
|
1731
|
+
@property
|
|
1732
|
+
def is_issue(self) -> bool:
|
|
1733
|
+
"""Only the develop edge anchors on an issue; both PR edges do not."""
|
|
1734
|
+
return self.edge == EDGE_DEVELOP
|
|
1735
|
+
|
|
1736
|
+
@property
|
|
1737
|
+
def anchor(self) -> str:
|
|
1738
|
+
"""The anchor as log evidence: `acme-widgets issue #7`. Every reap and
|
|
1739
|
+
every busy holdout line carries it -- a kill with no stated reason is
|
|
1740
|
+
not auditable after the fact, which is the whole complaint against the
|
|
1741
|
+
self-kill this sweep backstops."""
|
|
1742
|
+
return f"{self.slug} {'issue' if self.is_issue else 'PR'} #{self.number}"
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def parse_worker_session(name: str) -> "WorkerSession | None":
|
|
1746
|
+
"""A tmux session name as one of devloop's own workers, or None.
|
|
1747
|
+
|
|
1748
|
+
THE guard on everything the reaper touches. The container is shared --
|
|
1749
|
+
reviewloop's sessions, other instances' lanes, operator shells -- so
|
|
1750
|
+
"is this mine?" is answered by a full-match against the grammar the three
|
|
1751
|
+
session-name builders write (`session_name`, `fix_session_name`,
|
|
1752
|
+
`maintain_session_name`), never by a prefix: `develop-` matches anything
|
|
1753
|
+
an operator felt like naming that way, and this code kills processes.
|
|
1754
|
+
Non-matching names are not spared after a check; they are never
|
|
1755
|
+
CONSIDERED, which is the property the tests pin.
|
|
1756
|
+
"""
|
|
1757
|
+
match = _DEVELOP_NAME_RE.fullmatch(name)
|
|
1758
|
+
if match:
|
|
1759
|
+
return WorkerSession(
|
|
1760
|
+
name=name,
|
|
1761
|
+
edge=EDGE_DEVELOP,
|
|
1762
|
+
slug=match["slug"],
|
|
1763
|
+
number=int(match["number"]),
|
|
1764
|
+
attempt=int(match["attempt"]),
|
|
1765
|
+
)
|
|
1766
|
+
match = _FIX_NAME_RE.fullmatch(name)
|
|
1767
|
+
if match:
|
|
1768
|
+
return WorkerSession(
|
|
1769
|
+
name=name,
|
|
1770
|
+
edge=EDGE_FIX,
|
|
1771
|
+
slug=match["slug"],
|
|
1772
|
+
number=int(match["number"]),
|
|
1773
|
+
attempt=int(match["attempt"]),
|
|
1774
|
+
round_=int(match["round"]),
|
|
1775
|
+
)
|
|
1776
|
+
match = _MAINTAIN_NAME_RE.fullmatch(name)
|
|
1777
|
+
if match:
|
|
1778
|
+
return WorkerSession(
|
|
1779
|
+
name=name,
|
|
1780
|
+
edge=EDGE_MAINTAIN,
|
|
1781
|
+
slug=match["slug"],
|
|
1782
|
+
number=int(match["number"]),
|
|
1783
|
+
attempt=int(match["attempt"]),
|
|
1784
|
+
)
|
|
1785
|
+
return None
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
def _casefolded(repo: "tuple[str, str]") -> "tuple[str, str]":
|
|
1789
|
+
"""An (owner, repo) pair as `Config.watches` compares it: casefolded."""
|
|
1790
|
+
return (repo[0].casefold(), repo[1].casefold())
|
|
1791
|
+
|
|
1792
|
+
|
|
1793
|
+
def _slug_index(repos: "tuple[str, ...]") -> "dict[str, tuple[str, str]]":
|
|
1794
|
+
"""`{name slug: (owner, repo)}` over the repos allowlist -- the reverse of
|
|
1795
|
+
`_name_slug`, which is the only way a session NAME can name a repo.
|
|
1796
|
+
|
|
1797
|
+
The squash is lossy -- `a/b-c` and `a-b/c` both slug to `a-b-c`, and a
|
|
1798
|
+
squashed character can manufacture the same clash (`acme/wid.gets` and
|
|
1799
|
+
`acme/wid-gets` both slug to `acme-wid-gets`, and a dot in a repo name is
|
|
1800
|
+
ordinary) -- so an allowlist that collides two repos onto one slug leaves
|
|
1801
|
+
that slug UNRESOLVABLE rather than guessing: the
|
|
1802
|
+
consumer is a reaper, and killing the right session for the wrong repo's
|
|
1803
|
+
reason is exactly the mistake worth failing closed on. Entries that are
|
|
1804
|
+
not `owner/repo` are ignored -- `Config.watches` would never match them
|
|
1805
|
+
either.
|
|
1806
|
+
|
|
1807
|
+
Two spellings of the SAME repo are not a collision. `Config.watches`
|
|
1808
|
+
matches casefolded ("GitHub owner/repo names are case-insensitive"), so
|
|
1809
|
+
`["Acme/Widgets", "acme/widgets"]` is one watched repo everywhere else in
|
|
1810
|
+
the daemon; comparing raw here would fail closed on a duplicate that does
|
|
1811
|
+
not exist, silently turning the name-anchored reap off for that repo and
|
|
1812
|
+
sending an operator looking for a second one.
|
|
1813
|
+
"""
|
|
1814
|
+
index: dict[str, tuple[str, str]] = {}
|
|
1815
|
+
collided: set[str] = set()
|
|
1816
|
+
for entry in repos:
|
|
1817
|
+
owner, sep, repo = entry.partition("/")
|
|
1818
|
+
if not sep or not owner or not repo:
|
|
1819
|
+
continue
|
|
1820
|
+
slug = _name_slug(owner, repo)
|
|
1821
|
+
seen = index.get(slug)
|
|
1822
|
+
if seen is not None and _casefolded(seen) != _casefolded((owner, repo)):
|
|
1823
|
+
collided.add(slug)
|
|
1824
|
+
index[slug] = (owner, repo)
|
|
1825
|
+
for slug in collided:
|
|
1826
|
+
log.warning(
|
|
1827
|
+
"reaper: two allowlisted repos share the session-name slug %r — "
|
|
1828
|
+
"sessions carrying it cannot be resolved to a repo from their "
|
|
1829
|
+
"name and will only be reaped when the ledger knows them",
|
|
1830
|
+
slug,
|
|
1831
|
+
)
|
|
1832
|
+
index.pop(slug, None)
|
|
1833
|
+
return index
|
|
1834
|
+
|
|
1835
|
+
|
|
1643
1836
|
def orphan_activity_lane(pr: PullRequest) -> str:
|
|
1644
1837
|
"""`orphan-<owner>-<repo>-pr<n>` — the name the orphan sweep signs its
|
|
1645
1838
|
activity line with.
|
|
@@ -1787,6 +1980,10 @@ class DevWatcher:
|
|
|
1787
1980
|
# The reaper's warn-once latch for a CLI without `tmux kill` (kills
|
|
1788
1981
|
# are best-effort; the condition is per-process, not per-session).
|
|
1789
1982
|
self._kill_warned = False
|
|
1983
|
+
# The reaper's name-slug -> (owner, repo) index (see `_anchor_repo`),
|
|
1984
|
+
# built once from the frozen allowlist rather than per session per
|
|
1985
|
+
# sweep.
|
|
1986
|
+
self._slug_repos = _slug_index(config.repos)
|
|
1790
1987
|
|
|
1791
1988
|
# -- per-issue decision ------------------------------------------------
|
|
1792
1989
|
|
|
@@ -4888,22 +5085,14 @@ class DevWatcher:
|
|
|
4888
5085
|
|
|
4889
5086
|
# -- the session reaper ------------------------------------------------
|
|
4890
5087
|
|
|
4891
|
-
# Worker-session name prefixes. The reaper SWEEPS the first two
|
|
4892
|
-
# (`develop-*`/`fix-*` -- a maintenance session's terminal act is its own
|
|
4893
|
-
# label removal + self-kill, and `_session_finished` has no maintain
|
|
4894
|
-
# branch), but the max_sessions budget COUNTS all three: a live
|
|
4895
|
-
# maintenance session consumes memory whether or not the sweep reaps it.
|
|
4896
|
-
_REAP_PREFIXES = ("develop-", "fix-")
|
|
4897
|
-
_WORKER_PREFIXES = ("develop-", "fix-", "maintain-")
|
|
4898
|
-
|
|
4899
5088
|
def reap_finished(self) -> int:
|
|
4900
|
-
"""The sweep: kill idle, finished
|
|
4901
|
-
Returns how many were killed (or would be, under dry-run).
|
|
5089
|
+
"""The sweep: kill idle, finished worker sessions whose anchor is
|
|
5090
|
+
terminal. Returns how many were killed (or would be, under dry-run).
|
|
4902
5091
|
|
|
4903
5092
|
Lists the managed sessions itself and delegates the per-session work
|
|
4904
5093
|
to `_reap_sessions`. `poll_once` takes a different path -- it lists
|
|
4905
|
-
ONCE and feeds that same listing to
|
|
4906
|
-
|
|
5094
|
+
ONCE and feeds that same listing to the sweep, the max_sessions count,
|
|
5095
|
+
and the cap check -- so the tmux CLI is hit exactly once per pass;
|
|
4907
5096
|
this standalone entry point stays for callers (and tests) that just
|
|
4908
5097
|
want a sweep. See `_reap_sessions` for the finished-detection
|
|
4909
5098
|
contract."""
|
|
@@ -4913,41 +5102,72 @@ class DevWatcher:
|
|
|
4913
5102
|
log.warning("reaper: could not list sessions (%s) — skipping "
|
|
4914
5103
|
"this sweep", exc)
|
|
4915
5104
|
return 0
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
5105
|
+
return self._reap_sessions(sessions).reaped
|
|
5106
|
+
|
|
5107
|
+
def _reap_sessions(self, sessions: "list[dict]") -> Sweep:
|
|
5108
|
+
"""Kill the idle, finished worker sessions in `sessions`, and report
|
|
5109
|
+
what the sweep saw (see `Sweep`) -- the reaped NAMES let `poll_once`
|
|
5110
|
+
discount them from the live-session count, so a sweep frees
|
|
5111
|
+
max_sessions capacity in the same pass it runs.
|
|
5112
|
+
|
|
5113
|
+
Runs off the tmux list, the local ledgers, and GitHub alone -- never
|
|
5114
|
+
the polls' search results (the reviewer daemon's `_reap_finished`
|
|
5115
|
+
starved exactly there: keyed off the search, it became unreachable
|
|
5116
|
+
once the trigger state cleared, which is when sessions finish).
|
|
5117
|
+
|
|
5118
|
+
Three conditions, ALL required (see the module docstring):
|
|
5119
|
+
|
|
5120
|
+
* the name parses as one of devloop's own workers
|
|
5121
|
+
(`parse_worker_session`); anything else is never considered;
|
|
5122
|
+
* the anchor is TERMINAL -- the issue closed, the PR closed/merged, or
|
|
5123
|
+
the ledger shows a newer attempt/round superseding this session;
|
|
5124
|
+
* the session is IDLE and its last tmux activity is older than
|
|
5125
|
+
`reap_grace_minutes`.
|
|
5126
|
+
|
|
5127
|
+
Everything else is spared: busy sessions (logged as holdouts once the
|
|
5128
|
+
anchor is known terminal -- never killed, whatever the anchor says),
|
|
5129
|
+
sessions still inside the grace, sessions whose slug matches no
|
|
5130
|
+
allowlisted repo AND no ledger row, and sessions whose GitHub check
|
|
5131
|
+
fails this pass. The idle-and-still-in-grace check is deliberately
|
|
5132
|
+
cheap and runs BEFORE the anchor fetch, so a quiet machine's fresh
|
|
5133
|
+
sessions cost no API calls; a BUSY session does pay the fetch, because
|
|
5134
|
+
the holdout log has to state the anchor evidence it is holding out on.
|
|
5135
|
+
Kills go through `alissa tmux kill <name>` one session at a time
|
|
5136
|
+
(never `kill-server` -- the container is shared) and are best-effort;
|
|
5137
|
+
dry-run only logs (and still counts the would-kill, so the freed
|
|
5138
|
+
capacity matches what a live pass would free)."""
|
|
4939
5139
|
reaped = 0
|
|
4940
5140
|
reaped_names: set[str] = set()
|
|
5141
|
+
holdouts: list[str] = []
|
|
4941
5142
|
for entry in sessions:
|
|
4942
5143
|
name = str(entry.get("name") or "")
|
|
4943
|
-
|
|
5144
|
+
worker = parse_worker_session(name)
|
|
5145
|
+
if worker is None:
|
|
5146
|
+
# Not devloop's grammar: another daemon's lane, an operator
|
|
5147
|
+
# shell, a console. Never touched, never even looked up.
|
|
4944
5148
|
continue
|
|
4945
5149
|
if entry.get("live") is False:
|
|
4946
5150
|
# A registered-but-gone session has no tmux session to kill;
|
|
4947
5151
|
# `alissa tmux cleanup` owns purging stale registrations.
|
|
4948
5152
|
continue
|
|
5153
|
+
idle = entry.get("status") == "idle"
|
|
5154
|
+
quiet_for = self._session_quiet_for(entry, worker)
|
|
5155
|
+
graced = (
|
|
5156
|
+
quiet_for is not None
|
|
5157
|
+
and quiet_for >= self.config.reap_grace_seconds
|
|
5158
|
+
)
|
|
5159
|
+
if idle and not graced:
|
|
5160
|
+
log.debug(
|
|
5161
|
+
"reaper: %s is idle but inside the grace (%s of %d min) "
|
|
5162
|
+
"— spared",
|
|
5163
|
+
name,
|
|
5164
|
+
"unknown activity" if quiet_for is None
|
|
5165
|
+
else f"quiet {int(quiet_for / 60)} min",
|
|
5166
|
+
self.config.reap_grace_seconds // 60,
|
|
5167
|
+
)
|
|
5168
|
+
continue
|
|
4949
5169
|
try:
|
|
4950
|
-
verdict = self._session_finished(
|
|
5170
|
+
verdict = self._session_finished(worker)
|
|
4951
5171
|
except CommandError as exc:
|
|
4952
5172
|
log.warning(
|
|
4953
5173
|
"reaper: could not cross-reference %s (%s) — spared "
|
|
@@ -4956,14 +5176,25 @@ class DevWatcher:
|
|
|
4956
5176
|
continue
|
|
4957
5177
|
if verdict is None:
|
|
4958
5178
|
continue
|
|
4959
|
-
if
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
5179
|
+
if not idle:
|
|
5180
|
+
# The locked guardrail: a terminal anchor never licenses
|
|
5181
|
+
# killing a WORKING session. Post-merge close-out is real work
|
|
5182
|
+
# (the PR #275 -> #279 replay-onto-main incident happened in
|
|
5183
|
+
# this window), so this is logged as evidence for a human, at
|
|
5184
|
+
# a level an operator sees without -v, and left alone.
|
|
5185
|
+
log.warning(
|
|
5186
|
+
"reaper: HOLDOUT %s — anchor %s is terminal (%s) but the "
|
|
5187
|
+
"session is %s, not idle; never killing a busy session",
|
|
5188
|
+
name, worker.anchor, verdict, entry.get("status"),
|
|
4963
5189
|
)
|
|
5190
|
+
holdouts.append(name)
|
|
4964
5191
|
continue
|
|
4965
5192
|
if self.config.dry_run:
|
|
4966
|
-
log.info(
|
|
5193
|
+
log.info(
|
|
5194
|
+
"[dry-run] would kill session %s (anchor %s: %s; quiet "
|
|
5195
|
+
"%d min)",
|
|
5196
|
+
name, worker.anchor, verdict, int((quiet_for or 0) / 60),
|
|
5197
|
+
)
|
|
4967
5198
|
reaped += 1
|
|
4968
5199
|
reaped_names.add(name)
|
|
4969
5200
|
continue
|
|
@@ -4981,25 +5212,65 @@ class DevWatcher:
|
|
|
4981
5212
|
else:
|
|
4982
5213
|
log.debug("reaper: could not kill %s (%s)", name, exc)
|
|
4983
5214
|
continue
|
|
4984
|
-
log.info(
|
|
5215
|
+
log.info(
|
|
5216
|
+
"reaper: killed finished session %s (anchor %s: %s; quiet "
|
|
5217
|
+
"%d min)",
|
|
5218
|
+
name, worker.anchor, verdict, int((quiet_for or 0) / 60),
|
|
5219
|
+
)
|
|
4985
5220
|
reaped += 1
|
|
4986
5221
|
reaped_names.add(name)
|
|
4987
|
-
return reaped, reaped_names
|
|
5222
|
+
return Sweep(reaped=reaped, names=reaped_names, holdouts=tuple(holdouts))
|
|
5223
|
+
|
|
5224
|
+
def _session_quiet_for(
|
|
5225
|
+
self, entry: "dict", worker: WorkerSession
|
|
5226
|
+
) -> "float | None":
|
|
5227
|
+
"""Seconds since this session last did anything, or None when nothing
|
|
5228
|
+
can date it.
|
|
5229
|
+
|
|
5230
|
+
`alissa tmux ls --json` carries `lastActivity` (unix seconds) per
|
|
5231
|
+
session -- the signal the grace period is actually about: a session
|
|
5232
|
+
that is still typing is not a corpse however old its spawn is. When
|
|
5233
|
+
the listing does not carry one (an older CLI), the spawn ledger's row
|
|
5234
|
+
is the fallback lower bound, which is the clock every other
|
|
5235
|
+
presumed-dead decision in this loop already runs on. Neither
|
|
5236
|
+
available means the sweep CANNOT date the session, and `None` reads as
|
|
5237
|
+
"no grace has provably elapsed" at the call site -- never kill what
|
|
5238
|
+
cannot be dated. Clamped at 0 like every other age here: `lastActivity`
|
|
5239
|
+
is wall-clock and an NTP step must not read as a long-quiet session."""
|
|
5240
|
+
stamp = entry.get("lastActivity")
|
|
5241
|
+
last: "int | None"
|
|
5242
|
+
try:
|
|
5243
|
+
last = int(stamp) # type: ignore[arg-type] # non-numeric -> except
|
|
5244
|
+
except (TypeError, ValueError):
|
|
5245
|
+
last = None
|
|
5246
|
+
if last is None:
|
|
5247
|
+
row = (
|
|
5248
|
+
self.state.spawn_for_session(worker.name)
|
|
5249
|
+
or self.state.fix_spawn_for_session(worker.name)
|
|
5250
|
+
or self.state.maintain_spawn_for_session(worker.name)
|
|
5251
|
+
)
|
|
5252
|
+
last = int(row["spawned_at"]) if row is not None else None
|
|
5253
|
+
if last is None:
|
|
5254
|
+
return None
|
|
5255
|
+
return max(0.0, time.time() - last)
|
|
4988
5256
|
|
|
4989
5257
|
def _count_active_sessions(
|
|
4990
5258
|
self, sessions: "list[dict]", reaped_names: "set[str]"
|
|
4991
5259
|
) -> int:
|
|
4992
5260
|
"""How many worker sessions are live and NOT reaped this pass -- the
|
|
4993
|
-
`active` half of the max_sessions budget
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
5261
|
+
`active` half of the max_sessions budget, and the number the post-sweep
|
|
5262
|
+
cap check judges. Counts every session whose name parses as one of
|
|
5263
|
+
devloop's own workers (`parse_worker_session` -- the same grammar the
|
|
5264
|
+
sweep admits, so the budget and the reaper can never disagree about
|
|
5265
|
+
what a worker is), live, busy AND fresh in-flight ones alike (both are
|
|
5266
|
+
holding memory), minus the ones the sweep just killed (a
|
|
5267
|
+
registered-but-gone `live: False` row is already dead, so it never
|
|
5268
|
+
counted). Read from the SAME listing the sweep used, so counting adds
|
|
5269
|
+
no CLI call."""
|
|
4999
5270
|
active = 0
|
|
5000
5271
|
for entry in sessions:
|
|
5001
5272
|
name = str(entry.get("name") or "")
|
|
5002
|
-
if
|
|
5273
|
+
if parse_worker_session(name) is None:
|
|
5003
5274
|
continue
|
|
5004
5275
|
if entry.get("live") is False:
|
|
5005
5276
|
continue
|
|
@@ -5034,27 +5305,64 @@ class DevWatcher:
|
|
|
5034
5305
|
"reads INDETERMINATE (no resume can fire)", exc,
|
|
5035
5306
|
)
|
|
5036
5307
|
return 0, SessionBudget(None, 0), SessionLiveness(None)
|
|
5037
|
-
|
|
5038
|
-
active = self._count_active_sessions(sessions,
|
|
5308
|
+
sweep = self._reap_sessions(sessions)
|
|
5309
|
+
active = self._count_active_sessions(sessions, sweep.names)
|
|
5310
|
+
self._check_session_cap(active, sweep)
|
|
5039
5311
|
return (
|
|
5040
|
-
reaped,
|
|
5312
|
+
sweep.reaped,
|
|
5041
5313
|
SessionBudget(limit, active),
|
|
5042
|
-
SessionLiveness(sessions,
|
|
5314
|
+
SessionLiveness(sessions, sweep.names),
|
|
5315
|
+
)
|
|
5316
|
+
|
|
5317
|
+
def _check_session_cap(self, active: int, sweep: Sweep) -> None:
|
|
5318
|
+
"""Page-worthy log when the machine is STILL over `max_sessions` after
|
|
5319
|
+
the sweep.
|
|
5320
|
+
|
|
5321
|
+
The sweep is bounded by design -- it only kills idle sessions with
|
|
5322
|
+
terminal anchors -- so it cannot be the answer to every kind of
|
|
5323
|
+
overload. Sessions that stay over the cap after it ran are, by
|
|
5324
|
+
elimination, busy ones (holdouts and live work) or sessions no anchor
|
|
5325
|
+
marks terminal: the daemon has done everything it may safely do and
|
|
5326
|
+
the excess is a human's call. Logged at ERROR for exactly that reason,
|
|
5327
|
+
with the holdouts named, because that list is usually the answer.
|
|
5328
|
+
`max_sessions=0` (unlimited, the default) configures no cap, so there
|
|
5329
|
+
is nothing to exceed and nothing to say."""
|
|
5330
|
+
limit = self.config.max_sessions
|
|
5331
|
+
if not limit or active <= limit:
|
|
5332
|
+
return
|
|
5333
|
+
log.error(
|
|
5334
|
+
"SESSION CAP EXCEEDED: %d live worker session(s) after the sweep, "
|
|
5335
|
+
"over max_sessions=%d. The reaper only kills IDLE sessions whose "
|
|
5336
|
+
"anchor is terminal, so the excess is busy or unfinished work and "
|
|
5337
|
+
"no sweep will clear it — a human should look%s",
|
|
5338
|
+
active,
|
|
5339
|
+
limit,
|
|
5340
|
+
(
|
|
5341
|
+
"; busy-but-terminal holdout(s) this pass: "
|
|
5342
|
+
+ ", ".join(sweep.holdouts)
|
|
5343
|
+
if sweep.holdouts else " (no busy-but-terminal holdouts this "
|
|
5344
|
+
"pass — the live sessions are on open anchors)"
|
|
5345
|
+
),
|
|
5043
5346
|
)
|
|
5044
5347
|
|
|
5045
|
-
def _session_finished(self,
|
|
5348
|
+
def _session_finished(self, worker: WorkerSession) -> "str | None":
|
|
5046
5349
|
"""Why this worker session is finished, or None to spare it.
|
|
5047
5350
|
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5351
|
+
Two resolutions of one question -- is this session's anchor terminal?
|
|
5352
|
+
The LEDGER answers first when it has a row, because a row carries what
|
|
5353
|
+
a name cannot: supersession (a newer attempt, a newer review round
|
|
5354
|
+
ends this session's episode whatever GitHub says) and the spawn
|
|
5355
|
+
instant that keeps a just-enqueued session off the GitHub path
|
|
5356
|
+
entirely. With no row -- a fresh state volume, a migrated deployment,
|
|
5357
|
+
another workspace's ledger, or the enqueue-before-ledger crash window
|
|
5358
|
+
-- the session NAME answers instead (`_anchor_finished`), which is the
|
|
5359
|
+
path that stops ledger-less corpses idling forever.
|
|
5360
|
+
|
|
5361
|
+
Only a stale, non-superseded session costs a GitHub fetch, whose
|
|
5362
|
+
closed/merged state is the terminal signal. CommandError propagates to
|
|
5363
|
+
the caller (spare this pass); RateLimited propagates further out to
|
|
5057
5364
|
run_forever's backoff, like every other GitHub call in the pass."""
|
|
5365
|
+
name = worker.name
|
|
5058
5366
|
row = self.state.spawn_for_session(name)
|
|
5059
5367
|
if row is not None:
|
|
5060
5368
|
slug, number = row["repo_slug"], row["issue"]
|
|
@@ -5086,11 +5394,56 @@ class DevWatcher:
|
|
|
5086
5394
|
return "PR closed/merged"
|
|
5087
5395
|
return None
|
|
5088
5396
|
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5397
|
+
row = self.state.maintain_spawn_for_session(name)
|
|
5398
|
+
if row is not None:
|
|
5399
|
+
slug, number = row["repo_slug"], row["number"]
|
|
5400
|
+
if time.time() - row["spawned_at"] < self.config.stale_minutes * 60:
|
|
5401
|
+
return None
|
|
5402
|
+
# One supersession axis only: the maintenance edge has no rounds,
|
|
5403
|
+
# and `maintain_max_attempt` is deliberately lifetime-scoped (a
|
|
5404
|
+
# re-label resets the budget, not the numbering), so an older
|
|
5405
|
+
# attempt's session is over whichever request spawned the newer.
|
|
5406
|
+
newest = self.state.maintain_max_attempt(slug, number)
|
|
5407
|
+
if row["attempt"] < newest:
|
|
5408
|
+
return f"superseded by attempt {newest}"
|
|
5409
|
+
owner, _, repo = slug.partition("/")
|
|
5410
|
+
if self.github.pull_request(owner, repo, number).state != "open":
|
|
5411
|
+
return "PR closed/merged"
|
|
5412
|
+
return None
|
|
5413
|
+
|
|
5414
|
+
return self._anchor_finished(worker)
|
|
5415
|
+
|
|
5416
|
+
def _anchor_finished(self, worker: WorkerSession) -> "str | None":
|
|
5417
|
+
"""The ledger-free half of `_session_finished`: is the anchor named by
|
|
5418
|
+
the SESSION NAME terminal?
|
|
5419
|
+
|
|
5420
|
+
The name is not a weaker signal than a ledger row, it is a different
|
|
5421
|
+
one -- it survives the ledger. The 2026-07-28 fleet incident was
|
|
5422
|
+
exactly this shape: idle workers whose PRs had merged hours earlier,
|
|
5423
|
+
with nothing local left to explain them. What the name cannot do is
|
|
5424
|
+
name a REPO (`_name_slug` is lossy), so the slug is resolved through
|
|
5425
|
+
the repos allowlist (`_slug_index`); a slug matching no watched repo
|
|
5426
|
+
is another workspace's or another owner's and is spared, never
|
|
5427
|
+
guessed at. Supersession is unavailable here by construction -- it is
|
|
5428
|
+
a ledger fact -- so this path reaps on the anchor alone, which is the
|
|
5429
|
+
conservative half anyway: a closed issue and a merged PR have no
|
|
5430
|
+
pending work for anyone."""
|
|
5431
|
+
repo = self._slug_repos.get(worker.slug)
|
|
5432
|
+
if repo is None:
|
|
5433
|
+
log.debug(
|
|
5434
|
+
"reaper: %s parses as a worker but its slug %r matches no "
|
|
5435
|
+
"watched repo and no ledger row — spared (another "
|
|
5436
|
+
"workspace's session, or a repo this daemon does not watch)",
|
|
5437
|
+
worker.name, worker.slug,
|
|
5438
|
+
)
|
|
5439
|
+
return None
|
|
5440
|
+
owner, name = repo
|
|
5441
|
+
if worker.is_issue:
|
|
5442
|
+
if self.github.issue(owner, name, worker.number).state != "open":
|
|
5443
|
+
return "issue closed (anchor read from the session name)"
|
|
5444
|
+
return None
|
|
5445
|
+
if self.github.pull_request(owner, name, worker.number).state != "open":
|
|
5446
|
+
return "PR closed/merged (anchor read from the session name)"
|
|
5094
5447
|
return None
|
|
5095
5448
|
|
|
5096
5449
|
# -- polling -----------------------------------------------------------
|
|
@@ -395,6 +395,19 @@ class State:
|
|
|
395
395
|
(session,),
|
|
396
396
|
).fetchone()
|
|
397
397
|
|
|
398
|
+
def maintain_spawn_for_session(self, session: str) -> "sqlite3.Row | None":
|
|
399
|
+
"""maintain_spawns counterpart of spawn_for_session: the row behind a
|
|
400
|
+
maintenance session name (repo_slug, PR number, attempt), or None when
|
|
401
|
+
the name is not on this ledger. The third of the reaper's three ledger
|
|
402
|
+
lookups -- a maintenance session holds as much memory as any other
|
|
403
|
+
worker, so the sweep resolves its anchor the same way."""
|
|
404
|
+
return self._db.execute(
|
|
405
|
+
"SELECT repo_slug, number, attempt, spawned_at "
|
|
406
|
+
"FROM maintain_spawns WHERE session=? "
|
|
407
|
+
"ORDER BY spawned_at DESC LIMIT 1",
|
|
408
|
+
(session,),
|
|
409
|
+
).fetchone()
|
|
410
|
+
|
|
398
411
|
def max_fix_round(self, repo_slug: str, number: int) -> int:
|
|
399
412
|
"""The newest round any fix session was spawned for on this PR (0
|
|
400
413
|
when none) -- the reaper's supersession horizon: a fix session whose
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.8.2
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.8.1
|
|
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
|