alissa-tools-github-reviewloop 0.7.0__tar.gz → 0.9.0__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_reviewloop-0.7.0/src/main/alissa_tools_github_reviewloop.egg-info → alissa_tools_github_reviewloop-0.9.0}/PKG-INFO +1 -1
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/alissa.py +49 -5
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/ghclient.py +10 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/loop.py +314 -34
- alissa_tools_github_reviewloop-0.9.0/src/main/alissa/tools/github/reviewloop/state.py +218 -0
- alissa_tools_github_reviewloop-0.9.0/src/main/alissa/tools/github/reviewloop/version +1 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0/src/main/alissa_tools_github_reviewloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_reviewloop-0.7.0/src/main/alissa/tools/github/reviewloop/state.py +0 -115
- alissa_tools_github_reviewloop-0.7.0/src/main/alissa/tools/github/reviewloop/version +0 -1
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/MANIFEST.in +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/README.md +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/requirements.txt +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/setup.cfg +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/setup.py +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/__init__.py +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/__main__.py +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/config.py +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/proc.py +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/prreview.py +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/version.py +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa_tools_github_reviewloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa_tools_github_reviewloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa_tools_github_reviewloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa_tools_github_reviewloop.egg-info/top_level.txt +0 -0
|
@@ -44,6 +44,25 @@ class Task:
|
|
|
44
44
|
return self.status in OPEN_STATUSES
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
# The namespace loop.session_name() spawns reviewers into; the reap sweep only
|
|
48
|
+
# ever considers sessions under it.
|
|
49
|
+
REVIEW_SESSION_PREFIX = "review-"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ManagedSession:
|
|
54
|
+
name: str
|
|
55
|
+
status: str # the worker's view: "idle", "busy", ...
|
|
56
|
+
# Epoch seconds of the session's last tmux activity. 0 when the CLI did
|
|
57
|
+
# not report one -- treated as "long quiet", so a missing field can never
|
|
58
|
+
# indefinitely immunize a session against the sweep.
|
|
59
|
+
last_activity: float = 0.0
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def is_idle(self) -> bool:
|
|
63
|
+
return self.status == "idle"
|
|
64
|
+
|
|
65
|
+
|
|
47
66
|
def _title_pattern(owner: str, repo: str, number: int) -> re.Pattern[str]:
|
|
48
67
|
"""CR2 title convention: `Review PR <org>/<repo>#<n> (TASK-<origin>)`."""
|
|
49
68
|
return re.compile(
|
|
@@ -237,15 +256,40 @@ class Alissa:
|
|
|
237
256
|
except CommandError: # pragma: no cover - defence in depth
|
|
238
257
|
log.warning("could not set respawn off for %s", session)
|
|
239
258
|
|
|
240
|
-
def
|
|
259
|
+
def list_review_sessions(self) -> list[ManagedSession]:
|
|
260
|
+
"""The live `review-*` managed sessions, from `alissa tmux ls`.
|
|
261
|
+
|
|
262
|
+
The reap sweep's starting point. Unlike the review-requested search,
|
|
263
|
+
the live session list cannot lose a finished session, so every reap
|
|
264
|
+
candidate is reachable from here. `--live` because a session that is
|
|
265
|
+
already gone (self-killed, or killed by an operator) holds no worker
|
|
266
|
+
slot and needs no reap. Raises CommandError upward -- the sweep skips
|
|
267
|
+
this pass and tries again next poll.
|
|
268
|
+
"""
|
|
269
|
+
data = run_json(["alissa", "tmux", "ls", "--json", "--live"], timeout=60) or []
|
|
270
|
+
sessions = []
|
|
271
|
+
for row in data if isinstance(data, list) else []:
|
|
272
|
+
if not isinstance(row, dict):
|
|
273
|
+
continue
|
|
274
|
+
name = row.get("name")
|
|
275
|
+
if isinstance(name, str) and name.startswith(REVIEW_SESSION_PREFIX):
|
|
276
|
+
last = row.get("lastActivity")
|
|
277
|
+
sessions.append(
|
|
278
|
+
ManagedSession(
|
|
279
|
+
name=name,
|
|
280
|
+
status=str(row.get("status") or ""),
|
|
281
|
+
last_activity=float(last) if isinstance(last, (int, float)) else 0.0,
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
return sessions
|
|
285
|
+
|
|
286
|
+
def kill_session(self, session: str) -> None:
|
|
241
287
|
"""Kill a finished reviewer's managed session to free its worker slot.
|
|
242
288
|
|
|
243
289
|
Best-effort and idempotent-friendly: the session may already be gone (the
|
|
244
|
-
reviewer self-killed), so a non-zero exit is not an error here.
|
|
290
|
+
reviewer self-killed), so a non-zero exit is not an error here. Dry-run
|
|
291
|
+
is the caller's job (the sweep decides and logs before calling).
|
|
245
292
|
"""
|
|
246
|
-
if dry_run:
|
|
247
|
-
log.info("[dry-run] would kill session %s", session)
|
|
248
|
-
return
|
|
249
293
|
run(["alissa", "tmux", "kill", session], timeout=30, check=False)
|
|
250
294
|
|
|
251
295
|
def add_repo_to_workspace(
|
|
@@ -28,6 +28,9 @@ class PullRequest:
|
|
|
28
28
|
head_sha: str
|
|
29
29
|
draft: bool
|
|
30
30
|
url: str
|
|
31
|
+
# "open" or "closed"; merged PRs report state "closed" AND merged True.
|
|
32
|
+
state: str = "open"
|
|
33
|
+
merged: bool = False
|
|
31
34
|
|
|
32
35
|
@property
|
|
33
36
|
def full_name(self) -> str:
|
|
@@ -37,6 +40,11 @@ class PullRequest:
|
|
|
37
40
|
def slug(self) -> str:
|
|
38
41
|
return f"{self.owner}/{self.repo}#{self.number}"
|
|
39
42
|
|
|
43
|
+
@property
|
|
44
|
+
def is_terminal(self) -> bool:
|
|
45
|
+
"""Closed or merged: no round can ever be owed on this PR again."""
|
|
46
|
+
return self.merged or self.state != "open"
|
|
47
|
+
|
|
40
48
|
|
|
41
49
|
@dataclass(frozen=True)
|
|
42
50
|
class Review:
|
|
@@ -150,6 +158,8 @@ class GitHub:
|
|
|
150
158
|
head_sha=(data.get("head") or {}).get("sha", ""),
|
|
151
159
|
draft=bool(data.get("draft")),
|
|
152
160
|
url=data.get("html_url", ""),
|
|
161
|
+
state=data.get("state") or "open",
|
|
162
|
+
merged=bool(data.get("merged")),
|
|
153
163
|
)
|
|
154
164
|
|
|
155
165
|
def reviews(self, owner: str, repo: str, number: int) -> list[Review]:
|
|
@@ -27,9 +27,33 @@ from .state import State
|
|
|
27
27
|
log = logging.getLogger(__name__)
|
|
28
28
|
|
|
29
29
|
# A reviewer session that has not submitted after this long is presumed dead
|
|
30
|
-
# (skill failure mode: "reviewer session stalls"). The round is re-enqueued
|
|
30
|
+
# (skill failure mode: "reviewer session stalls"). The round is re-enqueued --
|
|
31
|
+
# but only with a second signal agreeing: the timer alone cannot tell a dead
|
|
32
|
+
# session from a slow one, and a timer-only re-enqueue double-spends the round
|
|
33
|
+
# (two sessions review it, both submit -- observed live twice: double round-2
|
|
34
|
+
# approves on devloop's PR #11, double approves on this repo's PR #19). See
|
|
35
|
+
# _defer_stale_round for the liveness signal.
|
|
31
36
|
STALE_ROUND_SECONDS = 90 * 60
|
|
32
37
|
|
|
38
|
+
# The floor under the liveness deferral: a live session defers the stale
|
|
39
|
+
# respawn indefinitely -- correct for a genuinely slow round, silent forever
|
|
40
|
+
# for a session that is wedged but still registers tmux activity. Once the
|
|
41
|
+
# newest spawn's age reaches this multiple of STALE_ROUND_SECONDS, the loop
|
|
42
|
+
# posts one "stalled" operator comment per deferral episode (stalled_kind)
|
|
43
|
+
# and keeps deferring. 2 means the deferral itself has lasted a full extra
|
|
44
|
+
# stale window beyond the point the timer first fired -- long enough that a
|
|
45
|
+
# healthy round has almost always submitted by then, early enough that a
|
|
46
|
+
# wedged one surfaces the same day.
|
|
47
|
+
STALLED_DEFER_MULTIPLE = 2
|
|
48
|
+
|
|
49
|
+
# The sweep only reaps a session that has been idle AND quiet this long. The
|
|
50
|
+
# GitHub review count increments the moment a review is submitted, but the
|
|
51
|
+
# reviewer still has close-out work after that (CR6 envelope, task status) --
|
|
52
|
+
# and a claude session parked at its prompt between turns reports "idle", so
|
|
53
|
+
# idleness alone cannot distinguish "between turns" from "done". Recent tmux
|
|
54
|
+
# activity can.
|
|
55
|
+
REAP_QUIET_SECONDS = 5 * 60
|
|
56
|
+
|
|
33
57
|
# The closing contract is spelled out in both directives (not just the skill)
|
|
34
58
|
# because it is the reviewer's most-skipped step: on re-review, sessions produce
|
|
35
59
|
# findings but never register the review on the PR, or stop without a verdict.
|
|
@@ -88,6 +112,38 @@ ESCALATION_COMMENT = (
|
|
|
88
112
|
"Last verdict: `{last_state}` at `{sha}`."
|
|
89
113
|
)
|
|
90
114
|
|
|
115
|
+
STALLED_COMMENT = (
|
|
116
|
+
"**Review round stalled?** — round {round} has been in flight {minutes} min "
|
|
117
|
+
"(stale window: {stale} min), but its reviewer session `{session}` still "
|
|
118
|
+
"shows signs of life, so the daemon keeps deferring the respawn — "
|
|
119
|
+
"respawning over a live session double-spends the round: two reviewers "
|
|
120
|
+
"work it, both submit. Is that session actually making progress? Operator "
|
|
121
|
+
"options: inspect it (`alissa tmux ls`) and, if it is wedged, kill it "
|
|
122
|
+
"(`alissa tmux kill {session}`) so the respawn proceeds next poll, or "
|
|
123
|
+
"finish the round by hand."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# The ping-ledger kind prefix for the stalled-deferral operator ping. Unlike
|
|
127
|
+
# the cap-out escalation (terminal per head), a stall can recur, so the kind
|
|
128
|
+
# is narrowed per episode -- see stalled_kind.
|
|
129
|
+
ESCALATION_STALLED = "stalled"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def stalled_kind(session: str) -> str:
|
|
133
|
+
"""The ping-ledger kind that dedupes ONE deferral episode's operator ping.
|
|
134
|
+
|
|
135
|
+
Devloop's stalled_kind reasoning, transposed: a stall can recur -- every
|
|
136
|
+
spawn of every round can wedge mid-flight, and episode k's ping must not
|
|
137
|
+
silence episode k+1's. Keyed on the bare kind (or even on the round), the
|
|
138
|
+
re-enqueue of a round that wedges AGAIN would defer silently forever. The
|
|
139
|
+
session name already IS the episode identity -- nonce-unique per spawn
|
|
140
|
+
(see session_name) -- so it folds into the key. Delivery contract: the
|
|
141
|
+
ledger row lands only AFTER the comment posts (see _escalate_stalled), so
|
|
142
|
+
a transient comment failure retries next poll and the ping lands exactly
|
|
143
|
+
once per episode.
|
|
144
|
+
"""
|
|
145
|
+
return f"{ESCALATION_STALLED}:{session}"
|
|
146
|
+
|
|
91
147
|
|
|
92
148
|
class Action(str, Enum):
|
|
93
149
|
SPAWNED = "spawned"
|
|
@@ -167,11 +223,6 @@ class ReviewWatcher:
|
|
|
167
223
|
else len(my_reviews)
|
|
168
224
|
)
|
|
169
225
|
|
|
170
|
-
# Backstop: reap the sessions of rounds that have already landed a review
|
|
171
|
-
# (the fast path is the reviewer self-killing, but it may forget). A round
|
|
172
|
-
# <= completed is done; its session, if we still have it, can be killed.
|
|
173
|
-
self._reap_finished(pr.full_name, number, completed)
|
|
174
|
-
|
|
175
226
|
converged = self._convergence_reason(my_reviews, task, pr.head_sha)
|
|
176
227
|
if converged is not None:
|
|
177
228
|
return Decision(Action.CONVERGED, converged, completed)
|
|
@@ -189,9 +240,13 @@ class ReviewWatcher:
|
|
|
189
240
|
if age is not None and age < STALE_ROUND_SECONDS:
|
|
190
241
|
return Decision(Action.IN_FLIGHT, f"round {round_} enqueued {int(age)}s ago", round_)
|
|
191
242
|
if age is not None:
|
|
243
|
+
deferred = self._defer_stale_round(pr, round_, age)
|
|
244
|
+
if deferred is not None:
|
|
245
|
+
return deferred
|
|
192
246
|
log.warning(
|
|
193
247
|
"%s round %d has been in flight %.0f min with no submitted review "
|
|
194
|
-
"— re-enqueuing (reviewer
|
|
248
|
+
"and its session is gone or finished — re-enqueuing (reviewer "
|
|
249
|
+
"session presumed dead)",
|
|
195
250
|
pr.slug,
|
|
196
251
|
round_,
|
|
197
252
|
age / 60,
|
|
@@ -199,6 +254,70 @@ class ReviewWatcher:
|
|
|
199
254
|
|
|
200
255
|
return self._spawn(pr, round_, task)
|
|
201
256
|
|
|
257
|
+
def _defer_stale_round(self, pr: PullRequest, round_: int, age: float) -> Decision | None:
|
|
258
|
+
"""The liveness signal under the stale timer: a deferral, or None to
|
|
259
|
+
respawn.
|
|
260
|
+
|
|
261
|
+
Staleness needs TWO signals, not one: the ledger timer says the
|
|
262
|
+
newest spawn is old, but elapsed time alone cannot tell a dead
|
|
263
|
+
session from a slow one -- a thorough round can outlast
|
|
264
|
+
STALE_ROUND_SECONDS, and a timer-only re-enqueue respawns a reviewer
|
|
265
|
+
over the still-working first one; two sessions review the same
|
|
266
|
+
round and both submit. So before respawning, consult external
|
|
267
|
+
evidence of life: the round's recorded session in the live list
|
|
268
|
+
(the reap sweep's own probe). Busy, or idle without a real quiet
|
|
269
|
+
period (mid-close-out between turns; see REAP_QUIET_SECONDS) -> the
|
|
270
|
+
round is alive, defer with a reason. Gone, or idle-finished -> dead,
|
|
271
|
+
respawn (the sweep separately handles any corpse). An unprobeable
|
|
272
|
+
live list defers too: respawning on missing evidence is exactly the
|
|
273
|
+
double-spend, and the probe retries next poll.
|
|
274
|
+
|
|
275
|
+
The deferral is floored, not unbounded: past STALLED_DEFER_MULTIPLE
|
|
276
|
+
stale windows with the session still alive, one operator ping per
|
|
277
|
+
deferral episode (stalled_kind) -- then keep deferring. This method
|
|
278
|
+
never respawns over a live session; only the operator killing the
|
|
279
|
+
session (or it finishing/dying) unblocks the respawn.
|
|
280
|
+
"""
|
|
281
|
+
row = self.state.get_spawn(pr.full_name, pr.number, round_)
|
|
282
|
+
if row is None: # age came from this row; belt and braces
|
|
283
|
+
return None
|
|
284
|
+
session = row["session"]
|
|
285
|
+
try:
|
|
286
|
+
live = {s.name: s for s in self.alissa.list_review_sessions()}
|
|
287
|
+
except CommandError as exc:
|
|
288
|
+
log.warning(
|
|
289
|
+
"%s round %d is stale but the session list is unavailable (%s) "
|
|
290
|
+
"— deferring the respawn rather than risking a double-spawned "
|
|
291
|
+
"round; the probe retries next poll",
|
|
292
|
+
pr.slug,
|
|
293
|
+
round_,
|
|
294
|
+
exc,
|
|
295
|
+
)
|
|
296
|
+
return Decision(
|
|
297
|
+
Action.IN_FLIGHT,
|
|
298
|
+
f"round {round_} is stale but liveness is unprobeable — deferring",
|
|
299
|
+
round_,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
ses = live.get(session)
|
|
303
|
+
if ses is None:
|
|
304
|
+
return None # session gone -> presumed dead -> respawn
|
|
305
|
+
if ses.is_idle and time.time() - ses.last_activity >= REAP_QUIET_SECONDS:
|
|
306
|
+
return None # idle-finished: it died without submitting -> respawn
|
|
307
|
+
|
|
308
|
+
if (
|
|
309
|
+
age >= STALLED_DEFER_MULTIPLE * STALE_ROUND_SECONDS
|
|
310
|
+
and not self.state.pinged(pr.full_name, pr.number, stalled_kind(session))
|
|
311
|
+
):
|
|
312
|
+
self._escalate_stalled(pr, round_, session, age)
|
|
313
|
+
return Decision(
|
|
314
|
+
Action.IN_FLIGHT,
|
|
315
|
+
f"round {round_} is stale ({int(age / 60)} min) but session "
|
|
316
|
+
f"{session} is still {'active' if ses.is_idle else 'busy'} — not "
|
|
317
|
+
f"respawning over a live reviewer",
|
|
318
|
+
round_,
|
|
319
|
+
)
|
|
320
|
+
|
|
202
321
|
def _convergence_reason(
|
|
203
322
|
self, my_reviews: list[Review], task: Task | None, head_sha: str
|
|
204
323
|
) -> str | None:
|
|
@@ -244,30 +363,135 @@ class ReviewWatcher:
|
|
|
244
363
|
|
|
245
364
|
return None
|
|
246
365
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
366
|
+
# -- reap sweep --------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
def sweep_sessions(self) -> None:
|
|
369
|
+
"""Kill the managed session of every finished round. Runs every poll.
|
|
370
|
+
|
|
371
|
+
The predecessor of this sweep ran inside evaluate(), which is fed by
|
|
372
|
+
the review-requested:@me search -- and submitting a review CLEARS the
|
|
373
|
+
request, so a finished round's PR vanished from the search at exactly
|
|
374
|
+
the moment its session became reapable; terminal (approved) rounds
|
|
375
|
+
were never reaped and idle reviewer sessions accumulated in the
|
|
376
|
+
worker. The sweep instead starts from the live session list, which
|
|
377
|
+
cannot lose a finished session, and works back to the round via the
|
|
378
|
+
spawn ledger. It must stay search-independent: never move it (back)
|
|
379
|
+
into the evaluate() path.
|
|
380
|
+
|
|
381
|
+
Every-poll cost, honestly: one `alissa tmux ls` when no review-*
|
|
382
|
+
session is live; otherwise one PR fetch per distinct PR with a live
|
|
383
|
+
idle quiet session, plus -- per distinct (PR, task ref) among its
|
|
384
|
+
rows -- exactly one of `alissa task get <ref>` (the row carries a
|
|
385
|
+
task ref) or the reviews fetch (it does not). The ledger ref is used
|
|
386
|
+
deliberately instead of
|
|
387
|
+
find_review_task: that would fetch the actor's ENTIRE task list per
|
|
388
|
+
PR, and its open-status filter would drop a human-validated review
|
|
389
|
+
task back onto the racier GitHub-count fallback. Only individual
|
|
390
|
+
sessions are ever killed (`alissa tmux kill <name>`) -- never the
|
|
391
|
+
server. Best-effort throughout: an undecidable session is spared and
|
|
392
|
+
looked at again next poll.
|
|
256
393
|
"""
|
|
257
|
-
|
|
394
|
+
try:
|
|
395
|
+
sessions = self.alissa.list_review_sessions()
|
|
396
|
+
except CommandError as exc:
|
|
397
|
+
log.warning("reap sweep skipped: could not list sessions: %s", exc)
|
|
258
398
|
return
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
399
|
+
|
|
400
|
+
# Per-sweep memos. The PR fetch is keyed per distinct PR; the round
|
|
401
|
+
# count additionally keys on the task ref, because two spawns of one
|
|
402
|
+
# PR can disagree on it (a round-1 row recorded before the review
|
|
403
|
+
# task existed carries None). None = undecidable this pass.
|
|
404
|
+
prs: dict[tuple[str, int], PullRequest | None] = {}
|
|
405
|
+
completed_cache: dict[tuple[str, int, str | None], float | None] = {}
|
|
406
|
+
|
|
407
|
+
for ses in sessions:
|
|
408
|
+
if not ses.is_idle:
|
|
409
|
+
# A busy session is still doing something (reviewing, or
|
|
410
|
+
# closing out its round) -- never yank the slot from under it.
|
|
411
|
+
continue
|
|
412
|
+
if time.time() - ses.last_activity < REAP_QUIET_SECONDS:
|
|
413
|
+
# Idle but recently active: likely mid-close-out (the review
|
|
414
|
+
# is submitted before the envelope and task move land). Wait
|
|
415
|
+
# for a real quiet period; see REAP_QUIET_SECONDS.
|
|
416
|
+
continue
|
|
417
|
+
row = self.state.find_spawn_by_session(ses.name)
|
|
418
|
+
if row is None:
|
|
419
|
+
# Not in our ledger: another workspace's daemon (or a human)
|
|
420
|
+
# owns it. Not ours to judge.
|
|
421
|
+
continue
|
|
422
|
+
pr_key = (row["repo"], row["number"])
|
|
423
|
+
if pr_key not in prs:
|
|
424
|
+
prs[pr_key] = self._sweep_pr(row["repo"], row["number"])
|
|
425
|
+
pr = prs[pr_key]
|
|
426
|
+
if pr is None:
|
|
427
|
+
continue # fetch failed -- spare everything on this PR
|
|
428
|
+
key = (row["repo"], row["number"], row["task_ref"])
|
|
429
|
+
if key not in completed_cache:
|
|
430
|
+
completed_cache[key] = self._completed_rounds(pr, row["task_ref"])
|
|
431
|
+
completed = completed_cache[key]
|
|
432
|
+
if completed is None or row["round"] > completed:
|
|
433
|
+
continue # undecidable, or the round is still in flight
|
|
434
|
+
if self.config.dry_run:
|
|
435
|
+
log.info(
|
|
436
|
+
"[dry-run] would reap finished reviewer session %s (round %d done)",
|
|
437
|
+
ses.name, row["round"],
|
|
438
|
+
)
|
|
262
439
|
continue
|
|
263
|
-
session = row["session"]
|
|
264
440
|
try:
|
|
265
|
-
self.alissa.kill_session(
|
|
441
|
+
self.alissa.kill_session(ses.name)
|
|
266
442
|
except Exception: # pragma: no cover - defence in depth
|
|
267
|
-
log.exception("failed to reap session %s",
|
|
443
|
+
log.exception("failed to reap session %s", ses.name)
|
|
268
444
|
continue
|
|
269
|
-
|
|
270
|
-
|
|
445
|
+
# Bookkeeping only -- deliberately never consulted before a kill.
|
|
446
|
+
# The live list is the authority; gating on the reaps table would
|
|
447
|
+
# spare any session killed behind the ledger's back.
|
|
448
|
+
self.state.record_reap(ses.name)
|
|
449
|
+
log.info(
|
|
450
|
+
"reaped finished reviewer session %s (round %d done)",
|
|
451
|
+
ses.name, row["round"],
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
def _sweep_pr(self, repo_slug: str, number: int) -> PullRequest | None:
|
|
455
|
+
"""One PR fetch for the sweep; the caller memoizes per distinct PR.
|
|
456
|
+
|
|
457
|
+
None = the fetch failed -- every session on that PR is spared this
|
|
458
|
+
pass and looked at again next poll. RateLimited propagates so
|
|
459
|
+
run_forever backs off instead of hammering the API once per session.
|
|
460
|
+
"""
|
|
461
|
+
owner, _, repo = repo_slug.partition("/")
|
|
462
|
+
try:
|
|
463
|
+
return self.github.pull_request(owner, repo, number)
|
|
464
|
+
except RateLimited:
|
|
465
|
+
raise
|
|
466
|
+
except CommandError as exc:
|
|
467
|
+
log.warning("reap sweep: could not fetch %s#%d: %s", repo_slug, number, exc)
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
def _completed_rounds(self, pr: PullRequest, task_ref: str | None) -> float | None:
|
|
471
|
+
"""How many rounds of this PR are over, judged from GitHub/task state.
|
|
472
|
+
|
|
473
|
+
A closed or merged PR terminates every round, so it reports infinity.
|
|
474
|
+
Otherwise rounds completed = verdict envelopes on the review task (the
|
|
475
|
+
authoritative round record), addressed by the task ref the ledger
|
|
476
|
+
captured at spawn time -- NOT find_review_task, which would fetch the
|
|
477
|
+
whole task list and whose open-status filter loses validated tasks.
|
|
478
|
+
The substantive-review count is the fallback only for spawns recorded
|
|
479
|
+
before any review task existed. None means "could not tell" -- the
|
|
480
|
+
sweep spares the session and retries next poll.
|
|
481
|
+
"""
|
|
482
|
+
if pr.is_terminal:
|
|
483
|
+
return float("inf")
|
|
484
|
+
if task_ref:
|
|
485
|
+
# count_verdicts never raises; unreadable evidence degrades to 0,
|
|
486
|
+
# which spares the session (round >= 1 > 0).
|
|
487
|
+
return self.alissa.count_verdicts(task_ref)
|
|
488
|
+
try:
|
|
489
|
+
return len(self.github.my_reviews(pr.owner, pr.repo, pr.number))
|
|
490
|
+
except RateLimited:
|
|
491
|
+
raise
|
|
492
|
+
except CommandError as exc:
|
|
493
|
+
log.warning("reap sweep: could not count reviews on %s: %s", pr.slug, exc)
|
|
494
|
+
return None
|
|
271
495
|
|
|
272
496
|
# -- actions -----------------------------------------------------------
|
|
273
497
|
|
|
@@ -397,6 +621,51 @@ class ReviewWatcher:
|
|
|
397
621
|
|
|
398
622
|
return warnings
|
|
399
623
|
|
|
624
|
+
def _escalate_stalled(
|
|
625
|
+
self, pr: PullRequest, round_: int, session: str, age: float
|
|
626
|
+
) -> None:
|
|
627
|
+
"""Operator ping when the liveness deferral itself runs long: the
|
|
628
|
+
session showing life is the only thing holding the respawn back, so
|
|
629
|
+
a human must check whether it is progressing or wedged. Posted once
|
|
630
|
+
per deferral EPISODE (the ping ledger row is keyed
|
|
631
|
+
stalled_kind(session); a re-enqueued round that stalls again is a
|
|
632
|
+
new session, so it pings again), and the row is recorded only AFTER
|
|
633
|
+
the comment posts: this ping is the operator's only signal for the
|
|
634
|
+
episode, so a transient comment failure must retry next poll --
|
|
635
|
+
exactly-once delivered, unlike the cap-out page (a terminal state,
|
|
636
|
+
recorded despite failure). The decision stays a deferral either way
|
|
637
|
+
-- this comments, it never respawns."""
|
|
638
|
+
body = STALLED_COMMENT.format(
|
|
639
|
+
round=round_,
|
|
640
|
+
minutes=int(age / 60),
|
|
641
|
+
stale=STALE_ROUND_SECONDS // 60,
|
|
642
|
+
session=session,
|
|
643
|
+
)
|
|
644
|
+
log.warning(
|
|
645
|
+
"STALLED %s round %d has been deferred %.0f min behind live session "
|
|
646
|
+
"%s — escalating to operator (once per episode)",
|
|
647
|
+
pr.slug,
|
|
648
|
+
round_,
|
|
649
|
+
age / 60,
|
|
650
|
+
session,
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
if self.config.dry_run:
|
|
654
|
+
log.info("[dry-run] would comment on %s:\n%s", pr.slug, body)
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
try:
|
|
658
|
+
self.github.comment(pr.owner, pr.repo, pr.number, body)
|
|
659
|
+
except CommandError as exc:
|
|
660
|
+
log.error(
|
|
661
|
+
"could not post the stalled-round comment on %s: %s — not "
|
|
662
|
+
"recording the episode; the ping retries next poll",
|
|
663
|
+
pr.slug,
|
|
664
|
+
exc,
|
|
665
|
+
)
|
|
666
|
+
return
|
|
667
|
+
self.state.record_ping(pr.full_name, pr.number, stalled_kind(session))
|
|
668
|
+
|
|
400
669
|
def _escalate(self, pr: PullRequest, last_state: str, rounds: int) -> None:
|
|
401
670
|
body = ESCALATION_COMMENT.format(
|
|
402
671
|
rounds=rounds, last_state=last_state.lower(), sha=pr.head_sha[:8]
|
|
@@ -416,6 +685,12 @@ class ReviewWatcher:
|
|
|
416
685
|
# -- polling -----------------------------------------------------------
|
|
417
686
|
|
|
418
687
|
def poll_once(self) -> list[tuple[str, Decision]]:
|
|
688
|
+
# Sweep BEFORE evaluating: a full worker is exactly when a fresh spawn
|
|
689
|
+
# needs the slot a finished session is squatting on. Deliberately not
|
|
690
|
+
# inside the per-request loop below — the sweep must reach sessions
|
|
691
|
+
# whose PR no longer appears in the search at all.
|
|
692
|
+
self.sweep_sessions()
|
|
693
|
+
|
|
419
694
|
requests = self.github.review_requests(self.config.repos)
|
|
420
695
|
log.info("%d PR(s) with a review pending from %s", len(requests), self.github.login)
|
|
421
696
|
|
|
@@ -442,16 +717,21 @@ class ReviewWatcher:
|
|
|
442
717
|
# every mode, so calling it here too would double every check.
|
|
443
718
|
backoff = self.config.poll_interval
|
|
444
719
|
while True:
|
|
720
|
+
# The sleep lives INSIDE the KeyboardInterrupt guard: with a 60s
|
|
721
|
+
# poll interval (up to 900s backing off) the loop spends nearly
|
|
722
|
+
# all its wall-clock sleeping, so a real Ctrl-C almost always
|
|
723
|
+
# lands there and must hit the same clean-exit path.
|
|
445
724
|
try:
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
725
|
+
try:
|
|
726
|
+
self.poll_once()
|
|
727
|
+
backoff = self.config.poll_interval
|
|
728
|
+
except RateLimited as exc:
|
|
729
|
+
backoff = min(backoff * 2, 900)
|
|
730
|
+
log.warning("rate limited (%s) — backing off %ds", exc, backoff)
|
|
731
|
+
except CommandError as exc:
|
|
732
|
+
backoff = min(backoff * 2, 900)
|
|
733
|
+
log.error("poll failed: %s — retrying in %ds", exc, backoff)
|
|
734
|
+
time.sleep(backoff)
|
|
454
735
|
except KeyboardInterrupt:
|
|
455
736
|
log.info("stopping")
|
|
456
737
|
return
|
|
457
|
-
time.sleep(backoff)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Local spawn ledger.
|
|
2
|
+
|
|
3
|
+
Deliberately thin: GitHub is the source of truth for how many rounds have run
|
|
4
|
+
(one submitted review per round). This table exists only to stop the daemon
|
|
5
|
+
double-spawning a reviewer while a round is still in flight, to map a live
|
|
6
|
+
session name back to the round it was spawned for (so the reap sweep can tell
|
|
7
|
+
a finished round's session from an in-flight one), and to remember that a
|
|
8
|
+
cap-out was already escalated. The ledger tolerates sessions dying or being
|
|
9
|
+
killed behind its back: a reap record is bookkeeping, never a precondition.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sqlite3
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# Shared between SCHEMA and the migration so the two can never drift.
|
|
19
|
+
_SPAWNS_TABLE = """
|
|
20
|
+
CREATE TABLE IF NOT EXISTS spawns (
|
|
21
|
+
repo TEXT NOT NULL,
|
|
22
|
+
number INTEGER NOT NULL,
|
|
23
|
+
round INTEGER NOT NULL,
|
|
24
|
+
head_sha TEXT NOT NULL,
|
|
25
|
+
session TEXT NOT NULL PRIMARY KEY,
|
|
26
|
+
task_ref TEXT,
|
|
27
|
+
spawned_at INTEGER NOT NULL
|
|
28
|
+
)"""
|
|
29
|
+
|
|
30
|
+
SCHEMA = f"""
|
|
31
|
+
{_SPAWNS_TABLE};
|
|
32
|
+
|
|
33
|
+
CREATE INDEX IF NOT EXISTS spawns_by_round ON spawns (repo, number, round);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS escalations (
|
|
36
|
+
repo TEXT NOT NULL,
|
|
37
|
+
number INTEGER NOT NULL,
|
|
38
|
+
head_sha TEXT NOT NULL,
|
|
39
|
+
escalated_at INTEGER NOT NULL,
|
|
40
|
+
PRIMARY KEY (repo, number, head_sha)
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS reaps (
|
|
44
|
+
session TEXT NOT NULL PRIMARY KEY,
|
|
45
|
+
reaped_at INTEGER NOT NULL
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS pings (
|
|
49
|
+
repo TEXT NOT NULL,
|
|
50
|
+
number INTEGER NOT NULL,
|
|
51
|
+
kind TEXT NOT NULL,
|
|
52
|
+
pinged_at INTEGER NOT NULL,
|
|
53
|
+
PRIMARY KEY (repo, number, kind)
|
|
54
|
+
);
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class State:
|
|
59
|
+
def __init__(self, path: Path):
|
|
60
|
+
path = Path(path).expanduser()
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
self._db = sqlite3.connect(str(path))
|
|
63
|
+
self._db.row_factory = sqlite3.Row
|
|
64
|
+
if self._spawns_keyed_by_round():
|
|
65
|
+
self._migrate_spawns()
|
|
66
|
+
self._db.executescript(SCHEMA)
|
|
67
|
+
self._db.commit()
|
|
68
|
+
|
|
69
|
+
def _migrate_spawns(self) -> None:
|
|
70
|
+
"""Re-key an old round-keyed `spawns` by session, in ONE transaction.
|
|
71
|
+
|
|
72
|
+
Deliberately not executescript (it COMMITs the open transaction before
|
|
73
|
+
running): a crash between the rename and the copy would otherwise
|
|
74
|
+
leave an empty new `spawns` that no longer looks stale, stranding
|
|
75
|
+
every row in spawns_v0 — an empty ledger makes the sweep spare every
|
|
76
|
+
live session as "not ours". All-or-nothing instead: any failure rolls
|
|
77
|
+
back to the untouched old table and the next open retries.
|
|
78
|
+
"""
|
|
79
|
+
self._db.execute("BEGIN IMMEDIATE")
|
|
80
|
+
try:
|
|
81
|
+
self._db.execute("ALTER TABLE spawns RENAME TO spawns_v0")
|
|
82
|
+
self._db.execute(_SPAWNS_TABLE)
|
|
83
|
+
self._db.execute(
|
|
84
|
+
"INSERT OR REPLACE INTO spawns "
|
|
85
|
+
"(repo, number, round, head_sha, session, task_ref, spawned_at) "
|
|
86
|
+
"SELECT repo, number, round, head_sha, session, task_ref, spawned_at "
|
|
87
|
+
"FROM spawns_v0"
|
|
88
|
+
)
|
|
89
|
+
self._db.execute("DROP TABLE spawns_v0")
|
|
90
|
+
except BaseException:
|
|
91
|
+
self._db.execute("ROLLBACK")
|
|
92
|
+
raise
|
|
93
|
+
self._db.execute("COMMIT")
|
|
94
|
+
|
|
95
|
+
def _spawns_keyed_by_round(self) -> bool:
|
|
96
|
+
"""True when `spawns` still has the pre-0.8 (repo, number, round) key.
|
|
97
|
+
|
|
98
|
+
That key made `record_spawn` overwrite the row when a stalled round
|
|
99
|
+
was re-enqueued, orphaning the original -- possibly still-live --
|
|
100
|
+
session so the reap sweep spared it forever as "not ours". The key is
|
|
101
|
+
now the session name (unique per spawn, thanks to the nonce). SQLite
|
|
102
|
+
cannot alter a primary key in place, so an old table is renamed and
|
|
103
|
+
copied over exactly once on open.
|
|
104
|
+
"""
|
|
105
|
+
info = self._db.execute("PRAGMA table_info(spawns)").fetchall()
|
|
106
|
+
if not info:
|
|
107
|
+
return False # fresh database, nothing to migrate
|
|
108
|
+
return [r["name"] for r in info if r["pk"]] != ["session"]
|
|
109
|
+
|
|
110
|
+
def close(self) -> None:
|
|
111
|
+
self._db.close()
|
|
112
|
+
|
|
113
|
+
def __enter__(self) -> "State":
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def __exit__(self, *exc) -> None:
|
|
117
|
+
self.close()
|
|
118
|
+
|
|
119
|
+
def get_spawn(self, repo: str, number: int, round_: int) -> sqlite3.Row | None:
|
|
120
|
+
"""The NEWEST spawn recorded for this round, or None.
|
|
121
|
+
|
|
122
|
+
A stalled round can be re-enqueued, so one round may have several
|
|
123
|
+
spawns; aging and the in-flight check are about the latest attempt.
|
|
124
|
+
"""
|
|
125
|
+
return self._db.execute(
|
|
126
|
+
"SELECT * FROM spawns WHERE repo=? AND number=? AND round=? "
|
|
127
|
+
"ORDER BY spawned_at DESC, rowid DESC LIMIT 1",
|
|
128
|
+
(repo, number, round_),
|
|
129
|
+
).fetchone()
|
|
130
|
+
|
|
131
|
+
def find_spawn_by_session(self, session: str) -> sqlite3.Row | None:
|
|
132
|
+
"""The spawn a live session name belongs to, or None if it is not ours.
|
|
133
|
+
|
|
134
|
+
Session names carry a random nonce, so a name maps to at most one
|
|
135
|
+
spawn. The reap sweep starts from live tmux state and uses this to
|
|
136
|
+
recover (repo, number, round); a session with no row (another
|
|
137
|
+
workspace's daemon, or a hand-started one) is not ours to judge.
|
|
138
|
+
"""
|
|
139
|
+
return self._db.execute(
|
|
140
|
+
"SELECT * FROM spawns WHERE session=?", (session,)
|
|
141
|
+
).fetchone()
|
|
142
|
+
|
|
143
|
+
def spawn_age(self, repo: str, number: int, round_: int) -> float | None:
|
|
144
|
+
"""Seconds since round `round_` was enqueued, or None if never spawned."""
|
|
145
|
+
row = self.get_spawn(repo, number, round_)
|
|
146
|
+
return None if row is None else time.time() - row["spawned_at"]
|
|
147
|
+
|
|
148
|
+
def record_spawn(
|
|
149
|
+
self,
|
|
150
|
+
*,
|
|
151
|
+
repo: str,
|
|
152
|
+
number: int,
|
|
153
|
+
round_: int,
|
|
154
|
+
head_sha: str,
|
|
155
|
+
session: str,
|
|
156
|
+
task_ref: str | None,
|
|
157
|
+
) -> None:
|
|
158
|
+
self._db.execute(
|
|
159
|
+
"INSERT OR REPLACE INTO spawns "
|
|
160
|
+
"(repo, number, round, head_sha, session, task_ref, spawned_at) "
|
|
161
|
+
"VALUES (?,?,?,?,?,?,?)",
|
|
162
|
+
(repo, number, round_, head_sha, session, task_ref, int(time.time())),
|
|
163
|
+
)
|
|
164
|
+
self._db.commit()
|
|
165
|
+
|
|
166
|
+
def is_reaped(self, session: str) -> bool:
|
|
167
|
+
row = self._db.execute(
|
|
168
|
+
"SELECT 1 FROM reaps WHERE session=?", (session,)
|
|
169
|
+
).fetchone()
|
|
170
|
+
return row is not None
|
|
171
|
+
|
|
172
|
+
def record_reap(self, session: str) -> None:
|
|
173
|
+
self._db.execute(
|
|
174
|
+
"INSERT OR REPLACE INTO reaps (session, reaped_at) VALUES (?,?)",
|
|
175
|
+
(session, int(time.time())),
|
|
176
|
+
)
|
|
177
|
+
self._db.commit()
|
|
178
|
+
|
|
179
|
+
def pinged(self, repo: str, number: int, kind: str) -> bool:
|
|
180
|
+
"""Whether this KIND of operator ping already went out for the PR.
|
|
181
|
+
|
|
182
|
+
Kind is free-form TEXT (devloop's escalation-kind pattern): a caller
|
|
183
|
+
narrows a kind's dedupe scope by folding identity into the string --
|
|
184
|
+
e.g. one stalled ping per deferral episode, "stalled:<session>" (see
|
|
185
|
+
loop.stalled_kind). Kept apart from `escalations`, whose key is
|
|
186
|
+
(repo, number, head_sha) and whose rows page terminal states.
|
|
187
|
+
"""
|
|
188
|
+
row = self._db.execute(
|
|
189
|
+
"SELECT 1 FROM pings WHERE repo=? AND number=? AND kind=?",
|
|
190
|
+
(repo, number, kind),
|
|
191
|
+
).fetchone()
|
|
192
|
+
return row is not None
|
|
193
|
+
|
|
194
|
+
def record_ping(self, repo: str, number: int, kind: str) -> None:
|
|
195
|
+
"""Idempotent per kind: OR IGNORE keeps the FIRST ping's timestamp,
|
|
196
|
+
so `pinged_at` is an audit field for when the episode was first
|
|
197
|
+
raised, not the most recent re-raise."""
|
|
198
|
+
self._db.execute(
|
|
199
|
+
"INSERT OR IGNORE INTO pings (repo, number, kind, pinged_at) "
|
|
200
|
+
"VALUES (?,?,?,?)",
|
|
201
|
+
(repo, number, kind, int(time.time())),
|
|
202
|
+
)
|
|
203
|
+
self._db.commit()
|
|
204
|
+
|
|
205
|
+
def escalated(self, repo: str, number: int, head_sha: str) -> bool:
|
|
206
|
+
row = self._db.execute(
|
|
207
|
+
"SELECT 1 FROM escalations WHERE repo=? AND number=? AND head_sha=?",
|
|
208
|
+
(repo, number, head_sha),
|
|
209
|
+
).fetchone()
|
|
210
|
+
return row is not None
|
|
211
|
+
|
|
212
|
+
def record_escalation(self, repo: str, number: int, head_sha: str) -> None:
|
|
213
|
+
self._db.execute(
|
|
214
|
+
"INSERT OR REPLACE INTO escalations "
|
|
215
|
+
"(repo, number, head_sha, escalated_at) VALUES (?,?,?,?)",
|
|
216
|
+
(repo, number, head_sha, int(time.time())),
|
|
217
|
+
)
|
|
218
|
+
self._db.commit()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.9.0
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
"""Local spawn ledger.
|
|
2
|
-
|
|
3
|
-
Deliberately thin: GitHub is the source of truth for how many rounds have run
|
|
4
|
-
(one submitted review per round). This table exists only to stop the daemon
|
|
5
|
-
double-spawning a reviewer while a round is still in flight, and to remember
|
|
6
|
-
that a cap-out was already escalated.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import sqlite3
|
|
12
|
-
import time
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
|
|
15
|
-
SCHEMA = """
|
|
16
|
-
CREATE TABLE IF NOT EXISTS spawns (
|
|
17
|
-
repo TEXT NOT NULL,
|
|
18
|
-
number INTEGER NOT NULL,
|
|
19
|
-
round INTEGER NOT NULL,
|
|
20
|
-
head_sha TEXT NOT NULL,
|
|
21
|
-
session TEXT NOT NULL,
|
|
22
|
-
task_ref TEXT,
|
|
23
|
-
spawned_at INTEGER NOT NULL,
|
|
24
|
-
PRIMARY KEY (repo, number, round)
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
CREATE TABLE IF NOT EXISTS escalations (
|
|
28
|
-
repo TEXT NOT NULL,
|
|
29
|
-
number INTEGER NOT NULL,
|
|
30
|
-
head_sha TEXT NOT NULL,
|
|
31
|
-
escalated_at INTEGER NOT NULL,
|
|
32
|
-
PRIMARY KEY (repo, number, head_sha)
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
CREATE TABLE IF NOT EXISTS reaps (
|
|
36
|
-
session TEXT NOT NULL PRIMARY KEY,
|
|
37
|
-
reaped_at INTEGER NOT NULL
|
|
38
|
-
);
|
|
39
|
-
"""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class State:
|
|
43
|
-
def __init__(self, path: Path):
|
|
44
|
-
path = Path(path).expanduser()
|
|
45
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
-
self._db = sqlite3.connect(str(path))
|
|
47
|
-
self._db.row_factory = sqlite3.Row
|
|
48
|
-
self._db.executescript(SCHEMA)
|
|
49
|
-
self._db.commit()
|
|
50
|
-
|
|
51
|
-
def close(self) -> None:
|
|
52
|
-
self._db.close()
|
|
53
|
-
|
|
54
|
-
def __enter__(self) -> "State":
|
|
55
|
-
return self
|
|
56
|
-
|
|
57
|
-
def __exit__(self, *exc) -> None:
|
|
58
|
-
self.close()
|
|
59
|
-
|
|
60
|
-
def get_spawn(self, repo: str, number: int, round_: int) -> sqlite3.Row | None:
|
|
61
|
-
return self._db.execute(
|
|
62
|
-
"SELECT * FROM spawns WHERE repo=? AND number=? AND round=?",
|
|
63
|
-
(repo, number, round_),
|
|
64
|
-
).fetchone()
|
|
65
|
-
|
|
66
|
-
def spawn_age(self, repo: str, number: int, round_: int) -> float | None:
|
|
67
|
-
"""Seconds since round `round_` was enqueued, or None if never spawned."""
|
|
68
|
-
row = self.get_spawn(repo, number, round_)
|
|
69
|
-
return None if row is None else time.time() - row["spawned_at"]
|
|
70
|
-
|
|
71
|
-
def record_spawn(
|
|
72
|
-
self,
|
|
73
|
-
*,
|
|
74
|
-
repo: str,
|
|
75
|
-
number: int,
|
|
76
|
-
round_: int,
|
|
77
|
-
head_sha: str,
|
|
78
|
-
session: str,
|
|
79
|
-
task_ref: str | None,
|
|
80
|
-
) -> None:
|
|
81
|
-
self._db.execute(
|
|
82
|
-
"INSERT OR REPLACE INTO spawns "
|
|
83
|
-
"(repo, number, round, head_sha, session, task_ref, spawned_at) "
|
|
84
|
-
"VALUES (?,?,?,?,?,?,?)",
|
|
85
|
-
(repo, number, round_, head_sha, session, task_ref, int(time.time())),
|
|
86
|
-
)
|
|
87
|
-
self._db.commit()
|
|
88
|
-
|
|
89
|
-
def is_reaped(self, session: str) -> bool:
|
|
90
|
-
row = self._db.execute(
|
|
91
|
-
"SELECT 1 FROM reaps WHERE session=?", (session,)
|
|
92
|
-
).fetchone()
|
|
93
|
-
return row is not None
|
|
94
|
-
|
|
95
|
-
def record_reap(self, session: str) -> None:
|
|
96
|
-
self._db.execute(
|
|
97
|
-
"INSERT OR REPLACE INTO reaps (session, reaped_at) VALUES (?,?)",
|
|
98
|
-
(session, int(time.time())),
|
|
99
|
-
)
|
|
100
|
-
self._db.commit()
|
|
101
|
-
|
|
102
|
-
def escalated(self, repo: str, number: int, head_sha: str) -> bool:
|
|
103
|
-
row = self._db.execute(
|
|
104
|
-
"SELECT 1 FROM escalations WHERE repo=? AND number=? AND head_sha=?",
|
|
105
|
-
(repo, number, head_sha),
|
|
106
|
-
).fetchone()
|
|
107
|
-
return row is not None
|
|
108
|
-
|
|
109
|
-
def record_escalation(self, repo: str, number: int, head_sha: str) -> None:
|
|
110
|
-
self._db.execute(
|
|
111
|
-
"INSERT OR REPLACE INTO escalations "
|
|
112
|
-
"(repo, number, head_sha, escalated_at) VALUES (?,?,?,?)",
|
|
113
|
-
(repo, number, head_sha, int(time.time())),
|
|
114
|
-
)
|
|
115
|
-
self._db.commit()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.7.0
|
|
File without changes
|
|
File without changes
|
{alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.9.0}/requirements.txt
RENAMED
|
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
|