alissa-tools-github-revloop 0.16.1__tar.gz → 0.16.3__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.3}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/__main__.py +25 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/alissa.py +98 -5
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/config.py +157 -0
- alissa_tools_github_revloop-0.16.3/src/main/alissa/tools/github/revloop/ghclient.py +596 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/loop.py +884 -82
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/proc.py +26 -5
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/state.py +171 -2
- alissa_tools_github_revloop-0.16.3/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/webui/page.py +3 -1
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3/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/ghclient.py +0 -292
- 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.3}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/README.md +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/setup.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.16.1 → alissa_tools_github_revloop-0.16.3}/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.3}/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.3}/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.3}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -94,12 +94,34 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
94
94
|
over.add_argument("--hub-template", metavar="TEMPLATE")
|
|
95
95
|
over.add_argument("--agent-profile", metavar="NAME")
|
|
96
96
|
over.add_argument("--reviewer-login", metavar="LOGIN")
|
|
97
|
+
over.add_argument(
|
|
98
|
+
"--reviewer-token-env",
|
|
99
|
+
metavar="VAR",
|
|
100
|
+
help="NAME of the environment variable holding the reviewer "
|
|
101
|
+
"identity's GitHub token (never the token itself). Set it and every "
|
|
102
|
+
"`gh` call runs under that credential explicitly instead of whatever "
|
|
103
|
+
"the container happened to export",
|
|
104
|
+
)
|
|
97
105
|
over.add_argument("--state-path", type=Path, metavar="PATH")
|
|
98
106
|
over.add_argument(
|
|
99
107
|
"--on-missing-review-task",
|
|
100
108
|
choices=[ON_MISSING_SPAWN, ON_MISSING_CREATE, ON_MISSING_SKIP],
|
|
101
109
|
)
|
|
102
110
|
over.add_argument("--on-missing-hub", choices=[HUB_SKIP, HUB_ADD])
|
|
111
|
+
over.add_argument(
|
|
112
|
+
"--reap-grace-seconds",
|
|
113
|
+
type=int,
|
|
114
|
+
metavar="SECONDS",
|
|
115
|
+
help="how long a reviewer session must be idle AND quiet before the "
|
|
116
|
+
"sweep reaps it (and before a stale round reads it as dead)",
|
|
117
|
+
)
|
|
118
|
+
over.add_argument(
|
|
119
|
+
"--reap-session-cap",
|
|
120
|
+
type=int,
|
|
121
|
+
metavar="N",
|
|
122
|
+
help="page-worthy threshold: more live reviewer sessions than this "
|
|
123
|
+
"after a sweep and the daemon logs loudly",
|
|
124
|
+
)
|
|
103
125
|
|
|
104
126
|
dry = over.add_mutually_exclusive_group()
|
|
105
127
|
dry.add_argument(
|
|
@@ -129,9 +151,12 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
129
151
|
"hub_template": args.hub_template,
|
|
130
152
|
"agent_profile": args.agent_profile,
|
|
131
153
|
"reviewer_login": args.reviewer_login,
|
|
154
|
+
"reviewer_token_env": args.reviewer_token_env,
|
|
132
155
|
"state_path": args.state_path,
|
|
133
156
|
"on_missing_review_task": args.on_missing_review_task,
|
|
134
157
|
"on_missing_hub": args.on_missing_hub,
|
|
158
|
+
"reap_grace_seconds": args.reap_grace_seconds,
|
|
159
|
+
"reap_session_cap": args.reap_session_cap,
|
|
135
160
|
"dry_run": args.dry_run,
|
|
136
161
|
}
|
|
137
162
|
|
|
@@ -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
|
|
@@ -15,10 +15,65 @@ daemons over different workspaces on the same machine, each pointed with
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import json
|
|
18
|
+
import re
|
|
18
19
|
from dataclasses import dataclass
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
from typing import Any, Mapping
|
|
21
22
|
|
|
23
|
+
# A POSIX-ish environment variable name -- what `reviewer_token_env` must be.
|
|
24
|
+
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
25
|
+
|
|
26
|
+
# ...which, on its own, accepts every GitHub credential format: `ghp_`, `gho_`,
|
|
27
|
+
# `ghu_`, `ghs_`, `ghr_` and `github_pat_` tokens are `[A-Za-z0-9_]` throughout
|
|
28
|
+
# and therefore valid identifiers. So the shape has to be rejected explicitly,
|
|
29
|
+
# or the one mistake this validation exists to catch -- pasting the token where
|
|
30
|
+
# its variable's NAME belongs -- sails through.
|
|
31
|
+
_TOKEN_SHAPE_RE = re.compile(r"^(gh[pousr]_|github_pat_)", re.IGNORECASE)
|
|
32
|
+
|
|
33
|
+
# No environment variable name comes close (`REVLOOP_REVIEWER_GH_TOKEN` is 25);
|
|
34
|
+
# every GitHub token, including the 40-character legacy hex ones, exceeds it.
|
|
35
|
+
MAX_ENV_NAME_LENGTH = 32
|
|
36
|
+
|
|
37
|
+
# Length is not the whole discriminator, because a secret can be short enough
|
|
38
|
+
# to fit under the ceiling. The shape is: a long, undifferentiated run of
|
|
39
|
+
# alphanumerics. Names are conventionally SCREAMING_SNAKE and separated -- the
|
|
40
|
+
# underscore is what a legacy 40-hex token and a generic alnum API key lack.
|
|
41
|
+
MIN_SECRET_RUN_LENGTH = 20
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_credential_shaped(value: str) -> bool:
|
|
45
|
+
"""Whether a `reviewer_token_env` value looks like a pasted secret.
|
|
46
|
+
|
|
47
|
+
A heuristic, and deliberately one that errs toward accusing: the cost of a
|
|
48
|
+
false positive is renaming a variable, and the cost of a false negative is
|
|
49
|
+
a live credential sitting in a config file on a shared volume.
|
|
50
|
+
"""
|
|
51
|
+
return bool(
|
|
52
|
+
_TOKEN_SHAPE_RE.match(value)
|
|
53
|
+
or len(value) > MAX_ENV_NAME_LENGTH
|
|
54
|
+
or (
|
|
55
|
+
len(value) >= MIN_SECRET_RUN_LENGTH
|
|
56
|
+
and "_" not in value
|
|
57
|
+
and value.isalnum()
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _describe(value: str) -> str:
|
|
63
|
+
"""Name a rejected value in the error -- redacted only when it has to be.
|
|
64
|
+
|
|
65
|
+
`__main__` prints this to stderr as `config error: …`, straight into the
|
|
66
|
+
container log, so a pasted secret must not be duplicated there: those get
|
|
67
|
+
length plus a short prefix. But the other way to fail this check is an
|
|
68
|
+
ordinary typo (`REVLOOP-REVIEWER-GH-TOKEN`), and there the value IS the
|
|
69
|
+
diagnostic -- redacting it turns a one-glance fix into a puzzle. The two
|
|
70
|
+
are separable now that credential shapes are identified explicitly.
|
|
71
|
+
"""
|
|
72
|
+
if _is_credential_shaped(value):
|
|
73
|
+
return f'a {len(value)}-character value starting "{value[:4]}…"'
|
|
74
|
+
return repr(value)
|
|
75
|
+
|
|
76
|
+
|
|
22
77
|
# What to do when a PR has a pending review request but no matching Alissa
|
|
23
78
|
# review task (CR2 is implementer-side, so a third-party PR may not have one).
|
|
24
79
|
ON_MISSING_SPAWN = "spawn_anyway" # review anyway, PR URL carries the context
|
|
@@ -44,14 +99,52 @@ CONFIG_KEYS = (
|
|
|
44
99
|
"operators",
|
|
45
100
|
"agent_profile",
|
|
46
101
|
"reviewer_login",
|
|
102
|
+
"reviewer_token_env",
|
|
47
103
|
"state_path",
|
|
48
104
|
"on_missing_review_task",
|
|
49
105
|
"on_missing_hub",
|
|
106
|
+
"reap_grace_seconds",
|
|
107
|
+
"reap_session_cap",
|
|
50
108
|
"dry_run",
|
|
51
109
|
)
|
|
52
110
|
|
|
53
111
|
MIN_POLL_INTERVAL = 10 # the search API allows 30 req/min
|
|
54
112
|
|
|
113
|
+
# A reviewer session that has not submitted after this long is presumed dead
|
|
114
|
+
# (skill failure mode: "reviewer session stalls"). The round is re-enqueued --
|
|
115
|
+
# but only with a second signal agreeing: the timer alone cannot tell a dead
|
|
116
|
+
# session from a slow one, and a timer-only re-enqueue double-spends the round
|
|
117
|
+
# (two sessions review it, both submit -- observed live twice: double round-2
|
|
118
|
+
# approves on devloop's PR #11, double approves on this repo's PR #19). See
|
|
119
|
+
# loop._defer_stale_round for the liveness signal.
|
|
120
|
+
#
|
|
121
|
+
# It lives here rather than in `loop` (which re-exports it, so every import
|
|
122
|
+
# site is unchanged) because `reap_grace_seconds` is validated against it, and
|
|
123
|
+
# config cannot import loop without a cycle.
|
|
124
|
+
STALE_ROUND_SECONDS = 90 * 60
|
|
125
|
+
|
|
126
|
+
# How long a reviewer session must have been idle AND quiet (no tmux activity)
|
|
127
|
+
# before the sweep may reap it, and before the stale-round liveness probe
|
|
128
|
+
# reads it as finished rather than working.
|
|
129
|
+
#
|
|
130
|
+
# Both readings need the same number because they ask the same question -- "is
|
|
131
|
+
# this session done?" -- and a claude session parked at its prompt between
|
|
132
|
+
# turns reports "idle", so only the absence of activity can answer it. The
|
|
133
|
+
# default is generous on purpose: a just-merged PR's reviewer still has
|
|
134
|
+
# in-session close-out to do (CR6 envelope, task move, its own self-kill), and
|
|
135
|
+
# reaping under it loses that work for nothing. Configurable because the right
|
|
136
|
+
# value tracks how long a round's close-out takes on a given deployment.
|
|
137
|
+
DEFAULT_REAP_GRACE_SECONDS = 30 * 60
|
|
138
|
+
|
|
139
|
+
# Post-sweep alarm threshold: more live reviewer sessions than this and the
|
|
140
|
+
# sweep logs page-worthy, because the reaper is not keeping up with the spawn
|
|
141
|
+
# rate. Each idle agent session holds hundreds of MB forever, and the worker
|
|
142
|
+
# container is shared -- the 2026-07-28 incident was this exact drift, climbing
|
|
143
|
+
# past 10 GB with every review session idle and its PR long merged. A healthy
|
|
144
|
+
# loop runs a couple of concurrent rounds, so the default is a threshold no
|
|
145
|
+
# healthy deployment reaches, not a capacity limit.
|
|
146
|
+
DEFAULT_REAP_SESSION_CAP = 6
|
|
147
|
+
|
|
55
148
|
|
|
56
149
|
def default_state_path(workspace_root: Path) -> Path:
|
|
57
150
|
return Path(workspace_root) / ".revloop" / "state.db"
|
|
@@ -80,12 +173,27 @@ class Config:
|
|
|
80
173
|
agent_profile: str = "claude"
|
|
81
174
|
reviewer_login: str | None = None # None -> resolve once via `gh api user`
|
|
82
175
|
|
|
176
|
+
# NAME of the environment variable holding the reviewer identity's GitHub
|
|
177
|
+
# token -- never the token itself, which has no business in a config file
|
|
178
|
+
# on a mounted volume. Set it and every `gh` call the daemon makes runs
|
|
179
|
+
# under that credential explicitly, with the container's inherited
|
|
180
|
+
# GH_TOKEN/GITHUB_TOKEN stripped; leave it None and the daemon inherits,
|
|
181
|
+
# which is the pre-#51 behaviour and warns loudly at preflight because a
|
|
182
|
+
# shared container is exactly where inheritance picks the wrong identity.
|
|
183
|
+
reviewer_token_env: str | None = None
|
|
184
|
+
|
|
83
185
|
# None means "derive from the workspace" -- read `state_db` for the
|
|
84
186
|
# resolved location, never this field.
|
|
85
187
|
state_path: Path | None = None
|
|
86
188
|
|
|
87
189
|
on_missing_review_task: str = ON_MISSING_SPAWN
|
|
88
190
|
on_missing_hub: str = HUB_SKIP
|
|
191
|
+
|
|
192
|
+
# The reap sweep's two knobs -- see the constants above for what each one
|
|
193
|
+
# buys and why it is tunable rather than pinned.
|
|
194
|
+
reap_grace_seconds: int = DEFAULT_REAP_GRACE_SECONDS
|
|
195
|
+
reap_session_cap: int = DEFAULT_REAP_SESSION_CAP
|
|
196
|
+
|
|
89
197
|
dry_run: bool = False
|
|
90
198
|
|
|
91
199
|
def __post_init__(self) -> None:
|
|
@@ -180,6 +288,52 @@ class Config:
|
|
|
180
288
|
f"poll_interval must be >= {MIN_POLL_INTERVAL} seconds, got {interval}"
|
|
181
289
|
)
|
|
182
290
|
|
|
291
|
+
grace = int(raw.get("reap_grace_seconds", cls.reap_grace_seconds))
|
|
292
|
+
if grace < 0:
|
|
293
|
+
raise ValueError(f"reap_grace_seconds must be >= 0, got {grace}")
|
|
294
|
+
if grace >= STALE_ROUND_SECONDS:
|
|
295
|
+
# The same number answers the stale-round liveness probe, whose
|
|
296
|
+
# own window is STALE_ROUND_SECONDS. At or above it the
|
|
297
|
+
# "idle-finished -> dead -> respawn" branch is unreachable: a
|
|
298
|
+
# session can never have been quiet longer than the grace by the
|
|
299
|
+
# time its round goes stale, so every stale round defers forever
|
|
300
|
+
# and only the operator ping ever fires. Loud here rather than
|
|
301
|
+
# silently wedged there.
|
|
302
|
+
raise ValueError(
|
|
303
|
+
f"reap_grace_seconds must be well under the {STALE_ROUND_SECONDS}s "
|
|
304
|
+
f"stale-round window (it also gates the stale-round liveness "
|
|
305
|
+
f"probe, whose respawn branch it would make unreachable), "
|
|
306
|
+
f"got {grace}"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
session_cap = int(raw.get("reap_session_cap", cls.reap_session_cap))
|
|
310
|
+
if session_cap < 1:
|
|
311
|
+
# 0 would page on every single live reviewer, which is the normal
|
|
312
|
+
# state of a working loop -- an alarm that always fires is noise.
|
|
313
|
+
raise ValueError(f"reap_session_cap must be >= 1, got {session_cap}")
|
|
314
|
+
|
|
315
|
+
token_env = raw.get("reviewer_token_env")
|
|
316
|
+
if token_env is not None:
|
|
317
|
+
token_env = str(token_env).strip()
|
|
318
|
+
# The overwhelmingly likely way to get here is pasting the TOKEN in
|
|
319
|
+
# place of the variable's name, which would leave a live credential
|
|
320
|
+
# in a config file on a shared volume and then fail as "unset
|
|
321
|
+
# variable" — an error that reads as though the secret were missing
|
|
322
|
+
# rather than exposed. So the check is two-sided: it must LOOK like
|
|
323
|
+
# a name, and it must not look like a credential. The value itself
|
|
324
|
+
# is never echoed back (see _redact).
|
|
325
|
+
if not _ENV_NAME_RE.match(token_env) or _is_credential_shaped(token_env):
|
|
326
|
+
rotate = (
|
|
327
|
+
" If that is a credential, rotate it: it is now in a config file."
|
|
328
|
+
if _is_credential_shaped(token_env)
|
|
329
|
+
else ""
|
|
330
|
+
)
|
|
331
|
+
raise ValueError(
|
|
332
|
+
f"reviewer_token_env must be an environment variable NAME "
|
|
333
|
+
f"(e.g. 'REVLOOP_REVIEWER_GH_TOKEN'), not a value or a "
|
|
334
|
+
f"token — got {_describe(token_env)}.{rotate}"
|
|
335
|
+
)
|
|
336
|
+
|
|
183
337
|
state_path = raw.get("state_path")
|
|
184
338
|
return cls(
|
|
185
339
|
workspace_root=Path(workspace_root),
|
|
@@ -190,9 +344,12 @@ class Config:
|
|
|
190
344
|
operators=operators,
|
|
191
345
|
agent_profile=raw.get("agent_profile", "claude"),
|
|
192
346
|
reviewer_login=raw.get("reviewer_login"),
|
|
347
|
+
reviewer_token_env=token_env or None,
|
|
193
348
|
state_path=Path(state_path).expanduser() if state_path else None,
|
|
194
349
|
on_missing_review_task=mode,
|
|
195
350
|
on_missing_hub=hub_mode,
|
|
351
|
+
reap_grace_seconds=grace,
|
|
352
|
+
reap_session_cap=session_cap,
|
|
196
353
|
dry_run=bool(raw.get("dry_run", False)),
|
|
197
354
|
)
|
|
198
355
|
|