alissa-tools-github-revloop 0.16.1__tar.gz → 0.16.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_revloop-0.16.1/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.2}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/__main__.py +16 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/alissa.py +98 -5
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/config.py +69 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/loop.py +376 -74
- alissa_tools_github_revloop-0.16.2/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.16.1/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/README.md +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/setup.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/state.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.2}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -100,6 +100,20 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
100
100
|
choices=[ON_MISSING_SPAWN, ON_MISSING_CREATE, ON_MISSING_SKIP],
|
|
101
101
|
)
|
|
102
102
|
over.add_argument("--on-missing-hub", choices=[HUB_SKIP, HUB_ADD])
|
|
103
|
+
over.add_argument(
|
|
104
|
+
"--reap-grace-seconds",
|
|
105
|
+
type=int,
|
|
106
|
+
metavar="SECONDS",
|
|
107
|
+
help="how long a reviewer session must be idle AND quiet before the "
|
|
108
|
+
"sweep reaps it (and before a stale round reads it as dead)",
|
|
109
|
+
)
|
|
110
|
+
over.add_argument(
|
|
111
|
+
"--reap-session-cap",
|
|
112
|
+
type=int,
|
|
113
|
+
metavar="N",
|
|
114
|
+
help="page-worthy threshold: more live reviewer sessions than this "
|
|
115
|
+
"after a sweep and the daemon logs loudly",
|
|
116
|
+
)
|
|
103
117
|
|
|
104
118
|
dry = over.add_mutually_exclusive_group()
|
|
105
119
|
dry.add_argument(
|
|
@@ -132,6 +146,8 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
132
146
|
"state_path": args.state_path,
|
|
133
147
|
"on_missing_review_task": args.on_missing_review_task,
|
|
134
148
|
"on_missing_hub": args.on_missing_hub,
|
|
149
|
+
"reap_grace_seconds": args.reap_grace_seconds,
|
|
150
|
+
"reap_session_cap": args.reap_session_cap,
|
|
135
151
|
"dry_run": args.dry_run,
|
|
136
152
|
}
|
|
137
153
|
|
|
@@ -44,10 +44,87 @@ class Task:
|
|
|
44
44
|
return self.status in OPEN_STATUSES
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
# The namespace loop.session_name() spawns reviewers into
|
|
48
|
-
#
|
|
47
|
+
# The namespace loop.session_name() spawns reviewers into. Kept as the
|
|
48
|
+
# display-level answer to "is this session in the reviewer namespace?" (the
|
|
49
|
+
# console marks unmanaged sessions with it); the REAP path uses the stricter
|
|
50
|
+
# grammar below, because a prefix is not a strong enough claim of ownership to
|
|
51
|
+
# kill on.
|
|
49
52
|
REVIEW_SESSION_PREFIX = "review-"
|
|
50
53
|
|
|
54
|
+
# The reviewer-session GRAMMAR. The sweep kills sessions and the worker
|
|
55
|
+
# container is shared with other lanes (`develop-*`, `fix-*`, `maintain-*`,
|
|
56
|
+
# ...), so only a name that parses as one of the two shapes the review loop
|
|
57
|
+
# itself produces is ever a reap candidate; anything else is another daemon's
|
|
58
|
+
# (or a human's) and is never touched. Two shapes, both ours:
|
|
59
|
+
#
|
|
60
|
+
# review-<repo>-pr<n>-r<k>-<nonce> this daemon's spawns (loop.session_name)
|
|
61
|
+
# review-pr-<n>[-r<k>] the alissa-code-review skill's own
|
|
62
|
+
# procedures (spawn-a-reviewer-session.md,
|
|
63
|
+
# run-the-review-loop.md) -- hand-driven
|
|
64
|
+
# rounds of the SAME loop. No spawn ledger
|
|
65
|
+
# knows about those and nothing reaped
|
|
66
|
+
# them: every session in the 2026-07-28
|
|
67
|
+
# memory incident was of this shape.
|
|
68
|
+
#
|
|
69
|
+
# The daemon shape is matched with a non-greedy repo so a hyphenated repo name
|
|
70
|
+
# (`alissa-github-review-daemon`) still lands in the repo group, and it is
|
|
71
|
+
# anchored on the `-pr<n>-r<k>-<nonce>` tail that loop.session_name always
|
|
72
|
+
# emits. The skill shape carries no repo and no nonce -- that is the whole
|
|
73
|
+
# reason a bare `review-pr-<n>` needs the repos allowlist to be resolvable at
|
|
74
|
+
# all (see loop.ReviewWatcher._resolve_pr).
|
|
75
|
+
_DAEMON_SESSION_RE = re.compile(
|
|
76
|
+
r"^review-(?P<repo>[a-z0-9-]+?)-pr(?P<number>\d+)-r(?P<round>\d+)-[0-9a-f]{4,16}$"
|
|
77
|
+
)
|
|
78
|
+
_SKILL_SESSION_RE = re.compile(
|
|
79
|
+
r"^review-pr-(?P<number>\d+)(?:-r(?P<round>\d+))?$"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class SessionRef:
|
|
85
|
+
"""What a reviewer session's NAME says about the round it is running.
|
|
86
|
+
|
|
87
|
+
`repo` is the sanitized repo slug (`session_repo_slug`), not an
|
|
88
|
+
`owner/repo`: session names never carry the owner. `repo` and `round` are
|
|
89
|
+
None for the skill shape, which encodes neither.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
number: int
|
|
93
|
+
repo: "str | None" = None
|
|
94
|
+
round: "int | None" = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def session_repo_slug(repo: str) -> str:
|
|
98
|
+
"""The repo component of a session name: tmux-safe, lowercase, no dots.
|
|
99
|
+
|
|
100
|
+
Shared by the producer (loop.session_name) and the parser below so the two
|
|
101
|
+
can never drift -- a rename here that the matcher did not follow would make
|
|
102
|
+
the daemon stop recognizing its own sessions.
|
|
103
|
+
"""
|
|
104
|
+
return re.sub(r"[^A-Za-z0-9-]", "-", repo).strip("-").lower()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_session_name(name: "str | None") -> "SessionRef | None":
|
|
108
|
+
"""Parse a reviewer session name, or None when it is not one of ours.
|
|
109
|
+
|
|
110
|
+
None is the security-relevant answer: it is what keeps the sweep off every
|
|
111
|
+
other lane's sessions in a shared container.
|
|
112
|
+
"""
|
|
113
|
+
if not isinstance(name, str):
|
|
114
|
+
return None
|
|
115
|
+
for pattern in (_SKILL_SESSION_RE, _DAEMON_SESSION_RE):
|
|
116
|
+
match = pattern.match(name)
|
|
117
|
+
if match is None:
|
|
118
|
+
continue
|
|
119
|
+
parts = match.groupdict()
|
|
120
|
+
round_ = parts.get("round")
|
|
121
|
+
return SessionRef(
|
|
122
|
+
number=int(parts["number"]),
|
|
123
|
+
repo=parts.get("repo"),
|
|
124
|
+
round=int(round_) if round_ else None,
|
|
125
|
+
)
|
|
126
|
+
return None
|
|
127
|
+
|
|
51
128
|
|
|
52
129
|
@dataclass(frozen=True)
|
|
53
130
|
class ManagedSession:
|
|
@@ -62,6 +139,11 @@ class ManagedSession:
|
|
|
62
139
|
def is_idle(self) -> bool:
|
|
63
140
|
return self.status == "idle"
|
|
64
141
|
|
|
142
|
+
@property
|
|
143
|
+
def ref(self) -> "SessionRef | None":
|
|
144
|
+
"""What the name says about the round -- see parse_session_name."""
|
|
145
|
+
return parse_session_name(self.name)
|
|
146
|
+
|
|
65
147
|
|
|
66
148
|
def _title_pattern(owner: str, repo: str, number: int) -> re.Pattern[str]:
|
|
67
149
|
"""CR2 title convention: `Review PR <org>/<repo>#<n> (TASK-<origin>)`."""
|
|
@@ -257,7 +339,7 @@ class Alissa:
|
|
|
257
339
|
log.warning("could not set respawn off for %s", session)
|
|
258
340
|
|
|
259
341
|
def list_review_sessions(self) -> list[ManagedSession]:
|
|
260
|
-
"""The live
|
|
342
|
+
"""The live reviewer-grammar managed sessions, from `alissa tmux ls`.
|
|
261
343
|
|
|
262
344
|
The reap sweep's starting point. Unlike the review-requested search,
|
|
263
345
|
the live session list cannot lose a finished session, so every reap
|
|
@@ -265,6 +347,11 @@ class Alissa:
|
|
|
265
347
|
already gone (self-killed, or killed by an operator) holds no worker
|
|
266
348
|
slot and needs no reap. Raises CommandError upward -- the sweep skips
|
|
267
349
|
this pass and tries again next poll.
|
|
350
|
+
|
|
351
|
+
The filter is `parse_session_name`, not the bare `review-` prefix: this
|
|
352
|
+
list is what the sweep kills from, the container is shared with other
|
|
353
|
+
daemons, and a name that does not parse as a reviewer session is never
|
|
354
|
+
enumerated here at all -- so no later bug in the sweep can reach one.
|
|
268
355
|
"""
|
|
269
356
|
data = run_json(["alissa", "tmux", "ls", "--json", "--live"], timeout=60) or []
|
|
270
357
|
sessions = []
|
|
@@ -272,7 +359,7 @@ class Alissa:
|
|
|
272
359
|
if not isinstance(row, dict):
|
|
273
360
|
continue
|
|
274
361
|
name = row.get("name")
|
|
275
|
-
if isinstance(name, str) and name
|
|
362
|
+
if isinstance(name, str) and parse_session_name(name) is not None:
|
|
276
363
|
last = row.get("lastActivity")
|
|
277
364
|
sessions.append(
|
|
278
365
|
ManagedSession(
|
|
@@ -284,7 +371,13 @@ class Alissa:
|
|
|
284
371
|
return sessions
|
|
285
372
|
|
|
286
373
|
def kill_session(self, session: str) -> None:
|
|
287
|
-
"""Kill
|
|
374
|
+
"""Kill ONE finished reviewer's managed session to free its worker slot.
|
|
375
|
+
|
|
376
|
+
Per-session, always: `alissa tmux kill <name>` and never a server-wide
|
|
377
|
+
kill. The worker container is shared with every other lane, so a
|
|
378
|
+
`kill-server` here would take down unrelated daemons' sessions along
|
|
379
|
+
with the one that is actually finished. A test pins the absence of that
|
|
380
|
+
verb across the whole package.
|
|
288
381
|
|
|
289
382
|
Best-effort and idempotent-friendly: the session may already be gone (the
|
|
290
383
|
reviewer self-killed), so a non-zero exit is not an error here. Dry-run
|
|
@@ -47,11 +47,48 @@ CONFIG_KEYS = (
|
|
|
47
47
|
"state_path",
|
|
48
48
|
"on_missing_review_task",
|
|
49
49
|
"on_missing_hub",
|
|
50
|
+
"reap_grace_seconds",
|
|
51
|
+
"reap_session_cap",
|
|
50
52
|
"dry_run",
|
|
51
53
|
)
|
|
52
54
|
|
|
53
55
|
MIN_POLL_INTERVAL = 10 # the search API allows 30 req/min
|
|
54
56
|
|
|
57
|
+
# A reviewer session that has not submitted after this long is presumed dead
|
|
58
|
+
# (skill failure mode: "reviewer session stalls"). The round is re-enqueued --
|
|
59
|
+
# but only with a second signal agreeing: the timer alone cannot tell a dead
|
|
60
|
+
# session from a slow one, and a timer-only re-enqueue double-spends the round
|
|
61
|
+
# (two sessions review it, both submit -- observed live twice: double round-2
|
|
62
|
+
# approves on devloop's PR #11, double approves on this repo's PR #19). See
|
|
63
|
+
# loop._defer_stale_round for the liveness signal.
|
|
64
|
+
#
|
|
65
|
+
# It lives here rather than in `loop` (which re-exports it, so every import
|
|
66
|
+
# site is unchanged) because `reap_grace_seconds` is validated against it, and
|
|
67
|
+
# config cannot import loop without a cycle.
|
|
68
|
+
STALE_ROUND_SECONDS = 90 * 60
|
|
69
|
+
|
|
70
|
+
# How long a reviewer session must have been idle AND quiet (no tmux activity)
|
|
71
|
+
# before the sweep may reap it, and before the stale-round liveness probe
|
|
72
|
+
# reads it as finished rather than working.
|
|
73
|
+
#
|
|
74
|
+
# Both readings need the same number because they ask the same question -- "is
|
|
75
|
+
# this session done?" -- and a claude session parked at its prompt between
|
|
76
|
+
# turns reports "idle", so only the absence of activity can answer it. The
|
|
77
|
+
# default is generous on purpose: a just-merged PR's reviewer still has
|
|
78
|
+
# in-session close-out to do (CR6 envelope, task move, its own self-kill), and
|
|
79
|
+
# reaping under it loses that work for nothing. Configurable because the right
|
|
80
|
+
# value tracks how long a round's close-out takes on a given deployment.
|
|
81
|
+
DEFAULT_REAP_GRACE_SECONDS = 30 * 60
|
|
82
|
+
|
|
83
|
+
# Post-sweep alarm threshold: more live reviewer sessions than this and the
|
|
84
|
+
# sweep logs page-worthy, because the reaper is not keeping up with the spawn
|
|
85
|
+
# rate. Each idle agent session holds hundreds of MB forever, and the worker
|
|
86
|
+
# container is shared -- the 2026-07-28 incident was this exact drift, climbing
|
|
87
|
+
# past 10 GB with every review session idle and its PR long merged. A healthy
|
|
88
|
+
# loop runs a couple of concurrent rounds, so the default is a threshold no
|
|
89
|
+
# healthy deployment reaches, not a capacity limit.
|
|
90
|
+
DEFAULT_REAP_SESSION_CAP = 6
|
|
91
|
+
|
|
55
92
|
|
|
56
93
|
def default_state_path(workspace_root: Path) -> Path:
|
|
57
94
|
return Path(workspace_root) / ".revloop" / "state.db"
|
|
@@ -86,6 +123,12 @@ class Config:
|
|
|
86
123
|
|
|
87
124
|
on_missing_review_task: str = ON_MISSING_SPAWN
|
|
88
125
|
on_missing_hub: str = HUB_SKIP
|
|
126
|
+
|
|
127
|
+
# The reap sweep's two knobs -- see the constants above for what each one
|
|
128
|
+
# buys and why it is tunable rather than pinned.
|
|
129
|
+
reap_grace_seconds: int = DEFAULT_REAP_GRACE_SECONDS
|
|
130
|
+
reap_session_cap: int = DEFAULT_REAP_SESSION_CAP
|
|
131
|
+
|
|
89
132
|
dry_run: bool = False
|
|
90
133
|
|
|
91
134
|
def __post_init__(self) -> None:
|
|
@@ -180,6 +223,30 @@ class Config:
|
|
|
180
223
|
f"poll_interval must be >= {MIN_POLL_INTERVAL} seconds, got {interval}"
|
|
181
224
|
)
|
|
182
225
|
|
|
226
|
+
grace = int(raw.get("reap_grace_seconds", cls.reap_grace_seconds))
|
|
227
|
+
if grace < 0:
|
|
228
|
+
raise ValueError(f"reap_grace_seconds must be >= 0, got {grace}")
|
|
229
|
+
if grace >= STALE_ROUND_SECONDS:
|
|
230
|
+
# The same number answers the stale-round liveness probe, whose
|
|
231
|
+
# own window is STALE_ROUND_SECONDS. At or above it the
|
|
232
|
+
# "idle-finished -> dead -> respawn" branch is unreachable: a
|
|
233
|
+
# session can never have been quiet longer than the grace by the
|
|
234
|
+
# time its round goes stale, so every stale round defers forever
|
|
235
|
+
# and only the operator ping ever fires. Loud here rather than
|
|
236
|
+
# silently wedged there.
|
|
237
|
+
raise ValueError(
|
|
238
|
+
f"reap_grace_seconds must be well under the {STALE_ROUND_SECONDS}s "
|
|
239
|
+
f"stale-round window (it also gates the stale-round liveness "
|
|
240
|
+
f"probe, whose respawn branch it would make unreachable), "
|
|
241
|
+
f"got {grace}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
session_cap = int(raw.get("reap_session_cap", cls.reap_session_cap))
|
|
245
|
+
if session_cap < 1:
|
|
246
|
+
# 0 would page on every single live reviewer, which is the normal
|
|
247
|
+
# state of a working loop -- an alarm that always fires is noise.
|
|
248
|
+
raise ValueError(f"reap_session_cap must be >= 1, got {session_cap}")
|
|
249
|
+
|
|
183
250
|
state_path = raw.get("state_path")
|
|
184
251
|
return cls(
|
|
185
252
|
workspace_root=Path(workspace_root),
|
|
@@ -193,6 +260,8 @@ class Config:
|
|
|
193
260
|
state_path=Path(state_path).expanduser() if state_path else None,
|
|
194
261
|
on_missing_review_task=mode,
|
|
195
262
|
on_missing_hub=hub_mode,
|
|
263
|
+
reap_grace_seconds=grace,
|
|
264
|
+
reap_session_cap=session_cap,
|
|
196
265
|
dry_run=bool(raw.get("dry_run", False)),
|
|
197
266
|
)
|
|
198
267
|
|
|
@@ -20,23 +20,26 @@ from dataclasses import dataclass
|
|
|
20
20
|
from enum import Enum
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
|
-
from .alissa import
|
|
24
|
-
|
|
23
|
+
from .alissa import (
|
|
24
|
+
VERDICT_APPROVE,
|
|
25
|
+
Alissa,
|
|
26
|
+
ManagedSession,
|
|
27
|
+
SessionRef,
|
|
28
|
+
Task,
|
|
29
|
+
session_repo_slug,
|
|
30
|
+
)
|
|
31
|
+
from .config import (
|
|
32
|
+
HUB_ADD,
|
|
33
|
+
ON_MISSING_SKIP,
|
|
34
|
+
STALE_ROUND_SECONDS,
|
|
35
|
+
Config,
|
|
36
|
+
)
|
|
25
37
|
from .ghclient import GitHub, IssueComment, PullRequest, RateLimited, Review
|
|
26
38
|
from .proc import CommandError
|
|
27
39
|
from .state import State
|
|
28
40
|
|
|
29
41
|
log = logging.getLogger(__name__)
|
|
30
42
|
|
|
31
|
-
# A reviewer session that has not submitted after this long is presumed dead
|
|
32
|
-
# (skill failure mode: "reviewer session stalls"). The round is re-enqueued --
|
|
33
|
-
# but only with a second signal agreeing: the timer alone cannot tell a dead
|
|
34
|
-
# session from a slow one, and a timer-only re-enqueue double-spends the round
|
|
35
|
-
# (two sessions review it, both submit -- observed live twice: double round-2
|
|
36
|
-
# approves on devloop's PR #11, double approves on this repo's PR #19). See
|
|
37
|
-
# _defer_stale_round for the liveness signal.
|
|
38
|
-
STALE_ROUND_SECONDS = 90 * 60
|
|
39
|
-
|
|
40
43
|
# The floor under the liveness deferral: a live session defers the stale
|
|
41
44
|
# respawn indefinitely -- correct for a genuinely slow round, silent forever
|
|
42
45
|
# for a session that is wedged but still registers tmux activity. Once the
|
|
@@ -48,13 +51,14 @@ STALE_ROUND_SECONDS = 90 * 60
|
|
|
48
51
|
# wedged one surfaces the same day.
|
|
49
52
|
STALLED_DEFER_MULTIPLE = 2
|
|
50
53
|
|
|
51
|
-
# The sweep only reaps a session that has been idle AND quiet
|
|
52
|
-
# GitHub review count increments the moment a
|
|
53
|
-
# reviewer still has close-out work after that
|
|
54
|
-
# and a claude session parked at its prompt
|
|
55
|
-
# idleness alone cannot distinguish "between
|
|
56
|
-
# activity can.
|
|
57
|
-
|
|
54
|
+
# The sweep only reaps a session that has been idle AND quiet for
|
|
55
|
+
# `config.reap_grace_seconds`. The GitHub review count increments the moment a
|
|
56
|
+
# review is submitted, but the reviewer still has close-out work after that
|
|
57
|
+
# (CR6 envelope, task status) -- and a claude session parked at its prompt
|
|
58
|
+
# between turns reports "idle", so idleness alone cannot distinguish "between
|
|
59
|
+
# turns" from "done". Recent tmux activity can. The same number answers the
|
|
60
|
+
# stale-round liveness probe, which asks the same question; see
|
|
61
|
+
# config.DEFAULT_REAP_GRACE_SECONDS.
|
|
58
62
|
|
|
59
63
|
# The closing contract is spelled out in both directives (not just the skill)
|
|
60
64
|
# because it is the reviewer's most-skipped step: on re-review, sessions produce
|
|
@@ -395,9 +399,15 @@ def session_name(pr: PullRequest, round_: int) -> str:
|
|
|
395
399
|
original 'stuck' failure). Safe to be non-deterministic: the generated name
|
|
396
400
|
is recorded in the spawn ledger and is what gets reaped / self-killed, so the
|
|
397
401
|
daemon never re-derives it.
|
|
402
|
+
|
|
403
|
+
The shape is a contract, not just a convention: alissa.parse_session_name
|
|
404
|
+
matches it, and only names it matches are ever reap candidates. Both sides
|
|
405
|
+
build the repo component through `session_repo_slug` so they cannot drift.
|
|
398
406
|
"""
|
|
399
|
-
|
|
400
|
-
|
|
407
|
+
return (
|
|
408
|
+
f"review-{session_repo_slug(pr.repo)}-pr{pr.number}"
|
|
409
|
+
f"-r{round_}-{secrets.token_hex(3)}"
|
|
410
|
+
)
|
|
401
411
|
|
|
402
412
|
|
|
403
413
|
class ReviewWatcher:
|
|
@@ -415,6 +425,14 @@ class ReviewWatcher:
|
|
|
415
425
|
# (repo, number, comment id) of every re-entry directive already
|
|
416
426
|
# refused in this process -- see _log_ignored_ack.
|
|
417
427
|
self._ignored_acks: set[tuple[str, int, int]] = set()
|
|
428
|
+
# Reviewer session name -> the (repo slug, number) its NAME resolved
|
|
429
|
+
# to, or None for "could not be resolved". Process-lifetime, pruned
|
|
430
|
+
# against the live list each sweep; see _resolve_pr for why the probe
|
|
431
|
+
# behind it must not be paid per poll.
|
|
432
|
+
self._probe_cache: dict[str, tuple[str, int] | None] = {}
|
|
433
|
+
# The surviving-session set the cap alarm last paged on, so a standing
|
|
434
|
+
# over-cap condition pages once per episode -- see _check_session_cap.
|
|
435
|
+
self._paged_cap: frozenset[str] | None = None
|
|
418
436
|
|
|
419
437
|
# -- per-PR decision ---------------------------------------------------
|
|
420
438
|
|
|
@@ -688,7 +706,8 @@ class ReviewWatcher:
|
|
|
688
706
|
round and both submit. So before respawning, consult external
|
|
689
707
|
evidence of life: the round's recorded session in the live list
|
|
690
708
|
(the reap sweep's own probe). Busy, or idle without a real quiet
|
|
691
|
-
period (mid-close-out between turns;
|
|
709
|
+
period (mid-close-out between turns; the same `reap_grace_seconds`
|
|
710
|
+
the sweep waits out, because it is the same question) -> the
|
|
692
711
|
round is alive, defer with a reason. Gone, or idle-finished -> dead,
|
|
693
712
|
respawn (the sweep separately handles any corpse). An unprobeable
|
|
694
713
|
live list defers too: respawning on missing evidence is exactly the
|
|
@@ -726,7 +745,8 @@ class ReviewWatcher:
|
|
|
726
745
|
ses = live.get(session)
|
|
727
746
|
if ses is None:
|
|
728
747
|
return None # session gone -> presumed dead -> respawn
|
|
729
|
-
|
|
748
|
+
quiet_for = time.time() - ses.last_activity
|
|
749
|
+
if ses.is_idle and quiet_for >= self.config.reap_grace_seconds:
|
|
730
750
|
return None # idle-finished: it died without submitting -> respawn
|
|
731
751
|
|
|
732
752
|
if (
|
|
@@ -823,18 +843,51 @@ class ReviewWatcher:
|
|
|
823
843
|
spawn ledger. It must stay search-independent: never move it (back)
|
|
824
844
|
into the evaluate() path.
|
|
825
845
|
|
|
826
|
-
|
|
846
|
+
Two reap edges, and the difference is what the sweep KNOWS about a
|
|
847
|
+
session:
|
|
848
|
+
|
|
849
|
+
* With a spawn-ledger row the session's PR and round are exact, so a
|
|
850
|
+
round whose verdict already landed is reapable on its own -- an
|
|
851
|
+
approved-but-unmerged PR is the common case, and waiting for a human
|
|
852
|
+
to merge before freeing the slot is the leak this sweep predates.
|
|
853
|
+
* Without one -- the hand-driven `review-pr-<n>` rounds the
|
|
854
|
+
alissa-code-review skill's procedures spawn, and any spawn whose
|
|
855
|
+
ledger was lost -- the name is the only evidence, and it cannot tell
|
|
856
|
+
a superseded round from an in-flight one. Those are reaped on a
|
|
857
|
+
TERMINAL PR only (issue #46): merged or closed ends every round on
|
|
858
|
+
the PR, so no round accounting is needed to be sure. Superseded
|
|
859
|
+
rounds of an OPEN PR are deliberately out of scope -- an operator
|
|
860
|
+
re-entry (`alissa-review: re-enter +N`) may still want that context.
|
|
861
|
+
|
|
862
|
+
Both edges additionally require the session to be idle and to have been
|
|
863
|
+
quiet for `reap_grace_seconds`: a busy session is NEVER killed, even on
|
|
864
|
+
a merged PR (scoped post-merge re-reviews of fold commits are an
|
|
865
|
+
established pattern), and the grace period leaves a just-merged PR's
|
|
866
|
+
reviewer time to finish its in-session close-out.
|
|
867
|
+
|
|
868
|
+
Every-poll cost, honestly: one `alissa tmux ls` when no reviewer
|
|
827
869
|
session is live; otherwise one PR fetch per distinct PR with a live
|
|
828
870
|
idle quiet session, plus -- per distinct (PR, task ref) among its
|
|
829
|
-
rows -- exactly one of `alissa task get <ref>` (the row carries
|
|
830
|
-
task ref) or the reviews fetch (it does not). The ledger ref is used
|
|
831
|
-
deliberately instead of
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
871
|
+
ledger rows -- exactly one of `alissa task get <ref>` (the row carries
|
|
872
|
+
a task ref) or the reviews fetch (it does not). The ledger ref is used
|
|
873
|
+
deliberately instead of find_review_task: that would fetch the actor's
|
|
874
|
+
ENTIRE task list per PR, and its open-status filter would drop a
|
|
875
|
+
human-validated review task back onto the racier GitHub-count
|
|
876
|
+
fallback. A ledger-less session costs its allowlist probe ONCE per
|
|
877
|
+
session lifetime, not once per poll -- `_probe_cache` remembers the
|
|
878
|
+
answer, including the negative one -- after which it is the same one
|
|
879
|
+
fetch per distinct PR as everything else. Busy and in-grace sessions
|
|
880
|
+
are filtered out BEFORE anything is fetched at all. Only individual
|
|
835
881
|
sessions are ever killed (`alissa tmux kill <name>`) -- never the
|
|
836
|
-
server
|
|
837
|
-
|
|
882
|
+
server, which in this shared container would take every other lane's
|
|
883
|
+
workers with it. Best-effort throughout: an undecidable session is
|
|
884
|
+
spared, logged at debug, and looked at again next poll; only genuine
|
|
885
|
+
failures (a fetch that blew up, a kill that raised) are logged louder.
|
|
886
|
+
|
|
887
|
+
Every spare records WHY against the session name. Those reasons are
|
|
888
|
+
debug-level (they repeat every poll), but the cap alarm interpolates
|
|
889
|
+
them, so the one line an operator does see in a container running at
|
|
890
|
+
INFO carries the explanation with it.
|
|
838
891
|
"""
|
|
839
892
|
try:
|
|
840
893
|
sessions = self.alissa.list_review_sessions()
|
|
@@ -842,93 +895,342 @@ class ReviewWatcher:
|
|
|
842
895
|
log.warning("reap sweep skipped: could not list sessions: %s", exc)
|
|
843
896
|
return 0
|
|
844
897
|
|
|
845
|
-
#
|
|
846
|
-
#
|
|
847
|
-
#
|
|
898
|
+
# Drop cached resolutions for sessions that are gone. Names are unique
|
|
899
|
+
# per spawn (that is what the nonce is for) and a session's PR never
|
|
900
|
+
# changes, so this is the only invalidation the cache needs.
|
|
901
|
+
live_names = {ses.name for ses in sessions}
|
|
902
|
+
self._probe_cache = {
|
|
903
|
+
name: target
|
|
904
|
+
for name, target in self._probe_cache.items()
|
|
905
|
+
if name in live_names
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
# Per-sweep memos. The PR fetch is keyed per distinct (repo, number);
|
|
909
|
+
# the round count additionally keys on the task ref, because two spawns
|
|
910
|
+
# of one PR can disagree on it (a round-1 row recorded before the review
|
|
848
911
|
# task existed carries None). None = undecidable this pass.
|
|
849
912
|
prs: dict[tuple[str, int], PullRequest | None] = {}
|
|
850
913
|
completed_cache: dict[tuple[str, int, str | None], float | None] = {}
|
|
851
|
-
|
|
914
|
+
holdouts: dict[str, str] = {}
|
|
915
|
+
reaped: list[str] = []
|
|
852
916
|
|
|
853
917
|
for ses in sessions:
|
|
918
|
+
idle_for = time.time() - ses.last_activity
|
|
854
919
|
if not ses.is_idle:
|
|
855
920
|
# A busy session is still doing something (reviewing, or
|
|
856
|
-
# closing out its round) -- never yank the slot from under it
|
|
921
|
+
# closing out its round) -- never yank the slot from under it,
|
|
922
|
+
# whatever its PR's state. Logged, not killed. Deliberately
|
|
923
|
+
# without resolving the PR: the state cannot change the
|
|
924
|
+
# outcome, and paying a fetch per poll for every working
|
|
925
|
+
# reviewer just to log it is not worth the API budget.
|
|
926
|
+
self._hold(holdouts, ses, "busy — never reaped")
|
|
857
927
|
continue
|
|
858
|
-
if
|
|
928
|
+
if idle_for < self.config.reap_grace_seconds:
|
|
859
929
|
# Idle but recently active: likely mid-close-out (the review
|
|
860
930
|
# is submitted before the envelope and task move land). Wait
|
|
861
|
-
#
|
|
931
|
+
# out the grace period.
|
|
932
|
+
self._hold(
|
|
933
|
+
holdouts, ses,
|
|
934
|
+
f"idle {idle_for // 60:.0f} min, inside the "
|
|
935
|
+
f"{self.config.reap_grace_seconds // 60} min grace",
|
|
936
|
+
)
|
|
862
937
|
continue
|
|
938
|
+
|
|
863
939
|
row = self.state.find_spawn_by_session(ses.name)
|
|
864
|
-
|
|
865
|
-
# Not in our ledger: another workspace's daemon (or a human)
|
|
866
|
-
# owns it. Not ours to judge.
|
|
867
|
-
continue
|
|
868
|
-
pr_key = (row["repo"], row["number"])
|
|
869
|
-
if pr_key not in prs:
|
|
870
|
-
prs[pr_key] = self._sweep_pr(row["repo"], row["number"])
|
|
871
|
-
pr = prs[pr_key]
|
|
940
|
+
pr = self._resolve_pr(ses, row, prs, holdouts)
|
|
872
941
|
if pr is None:
|
|
873
|
-
continue #
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
942
|
+
continue # undecidable -- already recorded, retried next poll
|
|
943
|
+
|
|
944
|
+
reason = self._reap_reason(pr, ses, row, completed_cache, holdouts)
|
|
945
|
+
if reason is None:
|
|
946
|
+
continue
|
|
947
|
+
|
|
948
|
+
# `last_activity` is 0 when the CLI reported none: that reads as
|
|
949
|
+
# "quiet for ages" to the guard above, but printing the epoch as a
|
|
950
|
+
# duration would be a lie, so the evidence says so instead.
|
|
951
|
+
idle_note = (
|
|
952
|
+
f"idle {idle_for // 60:.0f} min"
|
|
953
|
+
if ses.last_activity
|
|
954
|
+
else "idle, no activity timestamp"
|
|
955
|
+
)
|
|
956
|
+
evidence = (
|
|
957
|
+
f"{pr.slug} is {'merged' if pr.merged else pr.state}, "
|
|
958
|
+
f"{idle_note} — {reason}"
|
|
959
|
+
)
|
|
880
960
|
if self.config.dry_run:
|
|
881
|
-
log.info(
|
|
882
|
-
|
|
883
|
-
ses.name, row["round"],
|
|
884
|
-
)
|
|
961
|
+
log.info("[dry-run] would reap reviewer session %s (%s)", ses.name, evidence)
|
|
962
|
+
holdouts[ses.name] = f"dry-run: would have reaped ({evidence})"
|
|
885
963
|
continue
|
|
886
964
|
try:
|
|
887
965
|
self.alissa.kill_session(ses.name)
|
|
888
966
|
except Exception: # pragma: no cover - defence in depth
|
|
889
967
|
log.exception("failed to reap session %s", ses.name)
|
|
968
|
+
holdouts[ses.name] = "the kill raised — see the traceback above"
|
|
890
969
|
continue
|
|
891
970
|
# Bookkeeping only -- deliberately never consulted before a kill.
|
|
892
971
|
# The live list is the authority; gating on the reaps table would
|
|
893
972
|
# spare any session killed behind the ledger's back.
|
|
894
973
|
self.state.record_reap(ses.name)
|
|
895
|
-
reaped
|
|
896
|
-
log.info(
|
|
897
|
-
|
|
898
|
-
|
|
974
|
+
reaped.append(ses.name)
|
|
975
|
+
log.info("reaped reviewer session %s (%s)", ses.name, evidence)
|
|
976
|
+
|
|
977
|
+
self._check_session_cap(sessions, reaped, holdouts)
|
|
978
|
+
return len(reaped)
|
|
979
|
+
|
|
980
|
+
def _hold(self, holdouts: dict[str, str], ses: ManagedSession, why: str) -> None:
|
|
981
|
+
"""Record (and log at debug) why a live session was not reaped.
|
|
982
|
+
|
|
983
|
+
Debug because it repeats every poll for the whole life of a spared
|
|
984
|
+
session -- and the deployed container runs at INFO, so these lines are
|
|
985
|
+
invisible there by design. They are not the operator's channel: the
|
|
986
|
+
cap alarm is, and it interpolates what is recorded here.
|
|
987
|
+
"""
|
|
988
|
+
holdouts[ses.name] = why
|
|
989
|
+
log.debug("reap sweep: %s spared — %s", ses.name, why)
|
|
990
|
+
|
|
991
|
+
def _resolve_pr(
|
|
992
|
+
self,
|
|
993
|
+
ses: ManagedSession,
|
|
994
|
+
row: sqlite3.Row | None,
|
|
995
|
+
prs: dict[tuple[str, int], PullRequest | None],
|
|
996
|
+
holdouts: dict[str, str],
|
|
997
|
+
) -> PullRequest | None:
|
|
998
|
+
"""The PR a reapable session is about, or None when undecidable.
|
|
999
|
+
|
|
1000
|
+
A ledger row names the PR outright. Without one the session name is
|
|
1001
|
+
the only evidence, so the number it carries is resolved against the
|
|
1002
|
+
`repos` allowlist. A name carrying a repo component picks its
|
|
1003
|
+
allowlist entry directly; a bare `review-pr-<n>` (the skill's shape)
|
|
1004
|
+
names no repo, so every watched repo is probed and EXACTLY one must
|
|
1005
|
+
have that PR. An empty allowlist -- "watch every repo that asks" --
|
|
1006
|
+
can never resolve a bare name at all; nothing bounds the search.
|
|
1007
|
+
|
|
1008
|
+
What that does and does NOT establish, precisely, because the
|
|
1009
|
+
difference decides whose session can be killed: the allowlist BOUNDS
|
|
1010
|
+
THE SEARCH, it does not prove ownership. A bare number carries no
|
|
1011
|
+
repo, so a session reviewing a PR in an UNWATCHED repo is reaped if
|
|
1012
|
+
exactly one watched repo happens to have a terminal PR of the same
|
|
1013
|
+
number. Requiring a unique hit bounds the blast radius rather than
|
|
1014
|
+
removing it. The converse costs reaps instead: once two watched repos
|
|
1015
|
+
both have a PR #n -- inevitable as newer repos' numbering catches up
|
|
1016
|
+
with older ones -- every bare `review-pr-<n>` in the overlap resolves
|
|
1017
|
+
to two hits and is spared forever. Both are known v1 limits, recorded
|
|
1018
|
+
in the README; the durable fix is a repo in the name (skill-side) or a
|
|
1019
|
+
ledger row for hand-spawned sessions.
|
|
1020
|
+
|
|
1021
|
+
The probe is paid ONCE per session name. Its answer -- the resolved
|
|
1022
|
+
(repo, number), or a sticky None for "could not be resolved" -- is
|
|
1023
|
+
cached on the watcher, because a name is unique per spawn and its PR
|
|
1024
|
+
never changes. Without that, a session spared on an open PR (the
|
|
1025
|
+
common case: a finished round waits idle for the implementer) re-ran
|
|
1026
|
+
the whole allowlist sweep on every poll for the life of the PR, which
|
|
1027
|
+
at 7 repos and a 30s interval is ~840 REST calls an hour for one
|
|
1028
|
+
session. The sticky None never re-probes within a process, so an
|
|
1029
|
+
ambiguity that later clears is not noticed until a restart -- the safe
|
|
1030
|
+
direction (spare, never guess), and the alternative is exactly the
|
|
1031
|
+
cost above.
|
|
1032
|
+
"""
|
|
1033
|
+
if row is not None:
|
|
1034
|
+
return self._fetch_pr(prs, row["repo"], row["number"])
|
|
1035
|
+
|
|
1036
|
+
ref = ses.ref
|
|
1037
|
+
if ref is None: # pragma: no cover - list_review_sessions filters these
|
|
1038
|
+
return None
|
|
1039
|
+
|
|
1040
|
+
if ses.name not in self._probe_cache:
|
|
1041
|
+
self._probe_cache[ses.name] = self._probe_allowlist(ses, ref, prs)
|
|
1042
|
+
target = self._probe_cache[ses.name]
|
|
1043
|
+
if target is None:
|
|
1044
|
+
self._hold(
|
|
1045
|
+
holdouts, ses,
|
|
1046
|
+
f"no ledger row and PR #{ref.number} does not resolve to exactly "
|
|
1047
|
+
f"one watched repo — sparing it rather than guessing",
|
|
899
1048
|
)
|
|
900
|
-
|
|
1049
|
+
return None
|
|
1050
|
+
# A memo hit in the common case: the probe that resolved this session
|
|
1051
|
+
# already fetched it. On a later poll this is the one fetch that can
|
|
1052
|
+
# turn the session reapable, so it is not cacheable.
|
|
1053
|
+
return self._fetch_pr(prs, target[0], target[1])
|
|
901
1054
|
|
|
902
|
-
def
|
|
903
|
-
|
|
1055
|
+
def _probe_allowlist(
|
|
1056
|
+
self,
|
|
1057
|
+
ses: ManagedSession,
|
|
1058
|
+
ref: SessionRef,
|
|
1059
|
+
prs: dict[tuple[str, int], PullRequest | None],
|
|
1060
|
+
) -> tuple[str, int] | None:
|
|
1061
|
+
"""Which watched repo owns this session's PR number, or None.
|
|
904
1062
|
|
|
905
|
-
|
|
906
|
-
|
|
1063
|
+
The expensive half of _resolve_pr, called once per session name.
|
|
1064
|
+
"""
|
|
1065
|
+
candidates = self._name_candidates(ref)
|
|
1066
|
+
if not candidates:
|
|
1067
|
+
return None
|
|
1068
|
+
hits = []
|
|
1069
|
+
for repo_slug in candidates:
|
|
1070
|
+
# Quiet: probing a repo that simply has no PR #n is the expected
|
|
1071
|
+
# outcome for all but one candidate, not a failure worth a warning.
|
|
1072
|
+
if self._fetch_pr(prs, repo_slug, ref.number, quiet=True) is not None:
|
|
1073
|
+
hits.append(repo_slug)
|
|
1074
|
+
if len(hits) != 1:
|
|
1075
|
+
log.debug(
|
|
1076
|
+
"reap sweep: PR #%d of %s resolves to %d watched repo(s)",
|
|
1077
|
+
ref.number, ses.name, len(hits),
|
|
1078
|
+
)
|
|
1079
|
+
return None
|
|
1080
|
+
return (hits[0], ref.number)
|
|
1081
|
+
|
|
1082
|
+
def _name_candidates(self, ref: SessionRef) -> list[str]:
|
|
1083
|
+
"""The watched repos a session name could be about.
|
|
1084
|
+
|
|
1085
|
+
A name-borne repo component narrows the allowlist to (at most) its own
|
|
1086
|
+
entry; a bare name leaves the whole allowlist as candidates. Matching
|
|
1087
|
+
goes through `session_repo_slug` on both sides so `studio.alissa.app`
|
|
1088
|
+
and the `studio-alissa-app` a session name can carry compare equal.
|
|
1089
|
+
"""
|
|
1090
|
+
if ref.repo is None:
|
|
1091
|
+
return list(self.config.repos)
|
|
1092
|
+
return [
|
|
1093
|
+
full_name
|
|
1094
|
+
for full_name in self.config.repos
|
|
1095
|
+
if session_repo_slug(full_name.partition("/")[2]) == ref.repo
|
|
1096
|
+
]
|
|
1097
|
+
|
|
1098
|
+
def _reap_reason(
|
|
1099
|
+
self,
|
|
1100
|
+
pr: PullRequest,
|
|
1101
|
+
ses: ManagedSession,
|
|
1102
|
+
row: sqlite3.Row | None,
|
|
1103
|
+
completed_cache: dict[tuple[str, int, str | None], float | None],
|
|
1104
|
+
holdouts: dict[str, str],
|
|
1105
|
+
) -> str | None:
|
|
1106
|
+
"""Why this session may be reaped, or None to spare it (recorded)."""
|
|
1107
|
+
if pr.is_terminal:
|
|
1108
|
+
return "the PR is terminal, so every round on it is over"
|
|
1109
|
+
|
|
1110
|
+
if row is None:
|
|
1111
|
+
# v1 reaps a ledger-less session on a terminal PR only. The name's
|
|
1112
|
+
# `-r<k>` cannot tell a superseded round from an in-flight one, and
|
|
1113
|
+
# an operator re-entry may still want the earlier round's context;
|
|
1114
|
+
# superseded-round reaping is a v2 with its own analysis (#46).
|
|
1115
|
+
self._hold(
|
|
1116
|
+
holdouts, ses,
|
|
1117
|
+
f"{pr.slug} is open and there is no ledger row — v1 reaps "
|
|
1118
|
+
f"ledger-less sessions on terminal PRs only",
|
|
1119
|
+
)
|
|
1120
|
+
return None
|
|
1121
|
+
|
|
1122
|
+
key = (row["repo"], row["number"], row["task_ref"])
|
|
1123
|
+
if key not in completed_cache:
|
|
1124
|
+
completed_cache[key] = self._completed_rounds(pr, row["task_ref"])
|
|
1125
|
+
completed = completed_cache[key]
|
|
1126
|
+
if completed is None or row["round"] > completed:
|
|
1127
|
+
self._hold(
|
|
1128
|
+
holdouts, ses,
|
|
1129
|
+
f"round {row['round']} of {pr.slug} is not finished "
|
|
1130
|
+
f"({completed} completed)",
|
|
1131
|
+
)
|
|
1132
|
+
return None
|
|
1133
|
+
return f"round {row['round']} of an open PR is done"
|
|
1134
|
+
|
|
1135
|
+
def _check_session_cap(
|
|
1136
|
+
self,
|
|
1137
|
+
sessions: list[ManagedSession],
|
|
1138
|
+
reaped: list[str],
|
|
1139
|
+
holdouts: dict[str, str],
|
|
1140
|
+
) -> None:
|
|
1141
|
+
"""Page-worthy log when the sweep is not keeping up.
|
|
1142
|
+
|
|
1143
|
+
Counted AFTER the sweep, from the same live list the sweep walked
|
|
1144
|
+
minus what it killed, so the number is "sessions this pass could not
|
|
1145
|
+
free". Every idle agent session holds hundreds of MB forever and the
|
|
1146
|
+
worker container is shared, so a count that stays above the cap is the
|
|
1147
|
+
2026-07-28 incident happening again.
|
|
1148
|
+
|
|
1149
|
+
The page carries each survivor's spare reason inline, because the
|
|
1150
|
+
per-session holdout lines are debug and the deployed container runs at
|
|
1151
|
+
INFO: an alarm whose explanation is invisible is an alarm an operator
|
|
1152
|
+
cannot act on.
|
|
1153
|
+
|
|
1154
|
+
Deduped in-process on the set of surviving names: the sweep runs every
|
|
1155
|
+
poll (every 30s in the deployed config), and 120 identical pages an
|
|
1156
|
+
hour is not a page. It re-fires when the set changes, and clears once
|
|
1157
|
+
the count falls back inside the cap so the next episode pages again.
|
|
1158
|
+
Deliberately NOT the `pings` ledger the stalled-round escalation uses:
|
|
1159
|
+
that table is keyed (repo, number, kind) and this alarm belongs to no
|
|
1160
|
+
PR, so persisting it would mean a sentinel row every console reader
|
|
1161
|
+
then has to filter around -- and a standing over-cap condition SHOULD
|
|
1162
|
+
announce itself once to a freshly restarted daemon, which a persisted
|
|
1163
|
+
ping would suppress forever.
|
|
1164
|
+
"""
|
|
1165
|
+
killed = set(reaped)
|
|
1166
|
+
remaining = sorted(s.name for s in sessions if s.name not in killed)
|
|
1167
|
+
if len(remaining) <= self.config.reap_session_cap:
|
|
1168
|
+
self._paged_cap = None
|
|
1169
|
+
return
|
|
1170
|
+
episode = frozenset(remaining)
|
|
1171
|
+
if episode == self._paged_cap:
|
|
1172
|
+
return
|
|
1173
|
+
self._paged_cap = episode
|
|
1174
|
+
log.error(
|
|
1175
|
+
"REVIEWER SESSION CAP EXCEEDED: %d live reviewer sessions after the "
|
|
1176
|
+
"sweep (cap %d) — each holds hundreds of MB in a shared container. "
|
|
1177
|
+
"Survivors and why the sweep spared each: %s",
|
|
1178
|
+
len(remaining),
|
|
1179
|
+
self.config.reap_session_cap,
|
|
1180
|
+
"; ".join(
|
|
1181
|
+
f"{name} ({holdouts.get(name, 'no reason recorded')})"
|
|
1182
|
+
for name in remaining
|
|
1183
|
+
),
|
|
1184
|
+
)
|
|
1185
|
+
|
|
1186
|
+
def _fetch_pr(
|
|
1187
|
+
self,
|
|
1188
|
+
prs: dict[tuple[str, int], PullRequest | None],
|
|
1189
|
+
repo_slug: str,
|
|
1190
|
+
number: int,
|
|
1191
|
+
*,
|
|
1192
|
+
quiet: bool = False,
|
|
1193
|
+
) -> PullRequest | None:
|
|
1194
|
+
"""One memoized PR fetch for the sweep, per distinct (repo, number).
|
|
1195
|
+
|
|
1196
|
+
None = the PR could not be fetched -- it does not exist in that repo,
|
|
1197
|
+
or the call failed. Either way every session resolving through it is
|
|
1198
|
+
spared this pass and looked at again next poll. `quiet` demotes the
|
|
1199
|
+
log to debug for the allowlist probe, where a miss is the expected
|
|
1200
|
+
answer for all but one candidate. RateLimited propagates so
|
|
907
1201
|
run_forever backs off instead of hammering the API once per session.
|
|
908
1202
|
"""
|
|
1203
|
+
key = (repo_slug, number)
|
|
1204
|
+
if key in prs:
|
|
1205
|
+
return prs[key]
|
|
909
1206
|
owner, _, repo = repo_slug.partition("/")
|
|
910
1207
|
try:
|
|
911
|
-
|
|
1208
|
+
prs[key] = self.github.pull_request(owner, repo, number)
|
|
912
1209
|
except RateLimited:
|
|
913
1210
|
raise
|
|
914
1211
|
except CommandError as exc:
|
|
915
|
-
log.
|
|
916
|
-
|
|
1212
|
+
log.log(
|
|
1213
|
+
logging.DEBUG if quiet else logging.WARNING,
|
|
1214
|
+
"reap sweep: could not fetch %s#%d: %s", repo_slug, number, exc,
|
|
1215
|
+
)
|
|
1216
|
+
prs[key] = None
|
|
1217
|
+
return prs[key]
|
|
917
1218
|
|
|
918
1219
|
def _completed_rounds(self, pr: PullRequest, task_ref: str | None) -> float | None:
|
|
919
|
-
"""How many rounds of this PR are over, judged from GitHub
|
|
1220
|
+
"""How many rounds of this OPEN PR are over, judged from task/GitHub.
|
|
920
1221
|
|
|
921
|
-
|
|
922
|
-
Otherwise rounds completed = verdict envelopes on the review task (the
|
|
1222
|
+
Rounds completed = verdict envelopes on the review task (the
|
|
923
1223
|
authoritative round record), addressed by the task ref the ledger
|
|
924
1224
|
captured at spawn time -- NOT find_review_task, which would fetch the
|
|
925
1225
|
whole task list and whose open-status filter loses validated tasks.
|
|
926
1226
|
The substantive-review count is the fallback only for spawns recorded
|
|
927
1227
|
before any review task existed. None means "could not tell" -- the
|
|
928
1228
|
sweep spares the session and retries next poll.
|
|
1229
|
+
|
|
1230
|
+
Terminal PRs never reach here: the caller answers those before asking
|
|
1231
|
+
about rounds, because a merged or closed PR ends every round on it
|
|
1232
|
+
whatever the envelopes say.
|
|
929
1233
|
"""
|
|
930
|
-
if pr.is_terminal:
|
|
931
|
-
return float("inf")
|
|
932
1234
|
if task_ref:
|
|
933
1235
|
# count_verdicts never raises; unreadable evidence degrades to 0,
|
|
934
1236
|
# which spares the session (round >= 1 > 0).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.16.2
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.16.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
|
|
File without changes
|