alissa-tools-github-reviewloop 0.7.0__tar.gz → 0.8.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.8.0}/PKG-INFO +1 -1
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/alissa.py +49 -5
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/ghclient.py +10 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/loop.py +151 -32
- alissa_tools_github_reviewloop-0.8.0/src/main/alissa/tools/github/reviewloop/state.py +184 -0
- alissa_tools_github_reviewloop-0.8.0/src/main/alissa/tools/github/reviewloop/version +1 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.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.8.0}/MANIFEST.in +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/README.md +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/requirements.txt +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/setup.cfg +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/setup.py +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/__init__.py +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/__main__.py +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/config.py +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/proc.py +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/prreview.py +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/version.py +0 -0
- {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.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.8.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.8.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.8.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]:
|
|
@@ -30,6 +30,14 @@ log = logging.getLogger(__name__)
|
|
|
30
30
|
# (skill failure mode: "reviewer session stalls"). The round is re-enqueued.
|
|
31
31
|
STALE_ROUND_SECONDS = 90 * 60
|
|
32
32
|
|
|
33
|
+
# The sweep only reaps a session that has been idle AND quiet this long. The
|
|
34
|
+
# GitHub review count increments the moment a review is submitted, but the
|
|
35
|
+
# reviewer still has close-out work after that (CR6 envelope, task status) --
|
|
36
|
+
# and a claude session parked at its prompt between turns reports "idle", so
|
|
37
|
+
# idleness alone cannot distinguish "between turns" from "done". Recent tmux
|
|
38
|
+
# activity can.
|
|
39
|
+
REAP_QUIET_SECONDS = 5 * 60
|
|
40
|
+
|
|
33
41
|
# The closing contract is spelled out in both directives (not just the skill)
|
|
34
42
|
# because it is the reviewer's most-skipped step: on re-review, sessions produce
|
|
35
43
|
# findings but never register the review on the PR, or stop without a verdict.
|
|
@@ -167,11 +175,6 @@ class ReviewWatcher:
|
|
|
167
175
|
else len(my_reviews)
|
|
168
176
|
)
|
|
169
177
|
|
|
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
178
|
converged = self._convergence_reason(my_reviews, task, pr.head_sha)
|
|
176
179
|
if converged is not None:
|
|
177
180
|
return Decision(Action.CONVERGED, converged, completed)
|
|
@@ -244,30 +247,135 @@ class ReviewWatcher:
|
|
|
244
247
|
|
|
245
248
|
return None
|
|
246
249
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
250
|
+
# -- reap sweep --------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def sweep_sessions(self) -> None:
|
|
253
|
+
"""Kill the managed session of every finished round. Runs every poll.
|
|
254
|
+
|
|
255
|
+
The predecessor of this sweep ran inside evaluate(), which is fed by
|
|
256
|
+
the review-requested:@me search -- and submitting a review CLEARS the
|
|
257
|
+
request, so a finished round's PR vanished from the search at exactly
|
|
258
|
+
the moment its session became reapable; terminal (approved) rounds
|
|
259
|
+
were never reaped and idle reviewer sessions accumulated in the
|
|
260
|
+
worker. The sweep instead starts from the live session list, which
|
|
261
|
+
cannot lose a finished session, and works back to the round via the
|
|
262
|
+
spawn ledger. It must stay search-independent: never move it (back)
|
|
263
|
+
into the evaluate() path.
|
|
264
|
+
|
|
265
|
+
Every-poll cost, honestly: one `alissa tmux ls` when no review-*
|
|
266
|
+
session is live; otherwise one PR fetch per distinct PR with a live
|
|
267
|
+
idle quiet session, plus -- per distinct (PR, task ref) among its
|
|
268
|
+
rows -- exactly one of `alissa task get <ref>` (the row carries a
|
|
269
|
+
task ref) or the reviews fetch (it does not). The ledger ref is used
|
|
270
|
+
deliberately instead of
|
|
271
|
+
find_review_task: that would fetch the actor's ENTIRE task list per
|
|
272
|
+
PR, and its open-status filter would drop a human-validated review
|
|
273
|
+
task back onto the racier GitHub-count fallback. Only individual
|
|
274
|
+
sessions are ever killed (`alissa tmux kill <name>`) -- never the
|
|
275
|
+
server. Best-effort throughout: an undecidable session is spared and
|
|
276
|
+
looked at again next poll.
|
|
256
277
|
"""
|
|
257
|
-
|
|
278
|
+
try:
|
|
279
|
+
sessions = self.alissa.list_review_sessions()
|
|
280
|
+
except CommandError as exc:
|
|
281
|
+
log.warning("reap sweep skipped: could not list sessions: %s", exc)
|
|
258
282
|
return
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
283
|
+
|
|
284
|
+
# Per-sweep memos. The PR fetch is keyed per distinct PR; the round
|
|
285
|
+
# count additionally keys on the task ref, because two spawns of one
|
|
286
|
+
# PR can disagree on it (a round-1 row recorded before the review
|
|
287
|
+
# task existed carries None). None = undecidable this pass.
|
|
288
|
+
prs: dict[tuple[str, int], PullRequest | None] = {}
|
|
289
|
+
completed_cache: dict[tuple[str, int, str | None], float | None] = {}
|
|
290
|
+
|
|
291
|
+
for ses in sessions:
|
|
292
|
+
if not ses.is_idle:
|
|
293
|
+
# A busy session is still doing something (reviewing, or
|
|
294
|
+
# closing out its round) -- never yank the slot from under it.
|
|
295
|
+
continue
|
|
296
|
+
if time.time() - ses.last_activity < REAP_QUIET_SECONDS:
|
|
297
|
+
# Idle but recently active: likely mid-close-out (the review
|
|
298
|
+
# is submitted before the envelope and task move land). Wait
|
|
299
|
+
# for a real quiet period; see REAP_QUIET_SECONDS.
|
|
300
|
+
continue
|
|
301
|
+
row = self.state.find_spawn_by_session(ses.name)
|
|
302
|
+
if row is None:
|
|
303
|
+
# Not in our ledger: another workspace's daemon (or a human)
|
|
304
|
+
# owns it. Not ours to judge.
|
|
305
|
+
continue
|
|
306
|
+
pr_key = (row["repo"], row["number"])
|
|
307
|
+
if pr_key not in prs:
|
|
308
|
+
prs[pr_key] = self._sweep_pr(row["repo"], row["number"])
|
|
309
|
+
pr = prs[pr_key]
|
|
310
|
+
if pr is None:
|
|
311
|
+
continue # fetch failed -- spare everything on this PR
|
|
312
|
+
key = (row["repo"], row["number"], row["task_ref"])
|
|
313
|
+
if key not in completed_cache:
|
|
314
|
+
completed_cache[key] = self._completed_rounds(pr, row["task_ref"])
|
|
315
|
+
completed = completed_cache[key]
|
|
316
|
+
if completed is None or row["round"] > completed:
|
|
317
|
+
continue # undecidable, or the round is still in flight
|
|
318
|
+
if self.config.dry_run:
|
|
319
|
+
log.info(
|
|
320
|
+
"[dry-run] would reap finished reviewer session %s (round %d done)",
|
|
321
|
+
ses.name, row["round"],
|
|
322
|
+
)
|
|
262
323
|
continue
|
|
263
|
-
session = row["session"]
|
|
264
324
|
try:
|
|
265
|
-
self.alissa.kill_session(
|
|
325
|
+
self.alissa.kill_session(ses.name)
|
|
266
326
|
except Exception: # pragma: no cover - defence in depth
|
|
267
|
-
log.exception("failed to reap session %s",
|
|
327
|
+
log.exception("failed to reap session %s", ses.name)
|
|
268
328
|
continue
|
|
269
|
-
|
|
270
|
-
|
|
329
|
+
# Bookkeeping only -- deliberately never consulted before a kill.
|
|
330
|
+
# The live list is the authority; gating on the reaps table would
|
|
331
|
+
# spare any session killed behind the ledger's back.
|
|
332
|
+
self.state.record_reap(ses.name)
|
|
333
|
+
log.info(
|
|
334
|
+
"reaped finished reviewer session %s (round %d done)",
|
|
335
|
+
ses.name, row["round"],
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def _sweep_pr(self, repo_slug: str, number: int) -> PullRequest | None:
|
|
339
|
+
"""One PR fetch for the sweep; the caller memoizes per distinct PR.
|
|
340
|
+
|
|
341
|
+
None = the fetch failed -- every session on that PR is spared this
|
|
342
|
+
pass and looked at again next poll. RateLimited propagates so
|
|
343
|
+
run_forever backs off instead of hammering the API once per session.
|
|
344
|
+
"""
|
|
345
|
+
owner, _, repo = repo_slug.partition("/")
|
|
346
|
+
try:
|
|
347
|
+
return self.github.pull_request(owner, repo, number)
|
|
348
|
+
except RateLimited:
|
|
349
|
+
raise
|
|
350
|
+
except CommandError as exc:
|
|
351
|
+
log.warning("reap sweep: could not fetch %s#%d: %s", repo_slug, number, exc)
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
def _completed_rounds(self, pr: PullRequest, task_ref: str | None) -> float | None:
|
|
355
|
+
"""How many rounds of this PR are over, judged from GitHub/task state.
|
|
356
|
+
|
|
357
|
+
A closed or merged PR terminates every round, so it reports infinity.
|
|
358
|
+
Otherwise rounds completed = verdict envelopes on the review task (the
|
|
359
|
+
authoritative round record), addressed by the task ref the ledger
|
|
360
|
+
captured at spawn time -- NOT find_review_task, which would fetch the
|
|
361
|
+
whole task list and whose open-status filter loses validated tasks.
|
|
362
|
+
The substantive-review count is the fallback only for spawns recorded
|
|
363
|
+
before any review task existed. None means "could not tell" -- the
|
|
364
|
+
sweep spares the session and retries next poll.
|
|
365
|
+
"""
|
|
366
|
+
if pr.is_terminal:
|
|
367
|
+
return float("inf")
|
|
368
|
+
if task_ref:
|
|
369
|
+
# count_verdicts never raises; unreadable evidence degrades to 0,
|
|
370
|
+
# which spares the session (round >= 1 > 0).
|
|
371
|
+
return self.alissa.count_verdicts(task_ref)
|
|
372
|
+
try:
|
|
373
|
+
return len(self.github.my_reviews(pr.owner, pr.repo, pr.number))
|
|
374
|
+
except RateLimited:
|
|
375
|
+
raise
|
|
376
|
+
except CommandError as exc:
|
|
377
|
+
log.warning("reap sweep: could not count reviews on %s: %s", pr.slug, exc)
|
|
378
|
+
return None
|
|
271
379
|
|
|
272
380
|
# -- actions -----------------------------------------------------------
|
|
273
381
|
|
|
@@ -416,6 +524,12 @@ class ReviewWatcher:
|
|
|
416
524
|
# -- polling -----------------------------------------------------------
|
|
417
525
|
|
|
418
526
|
def poll_once(self) -> list[tuple[str, Decision]]:
|
|
527
|
+
# Sweep BEFORE evaluating: a full worker is exactly when a fresh spawn
|
|
528
|
+
# needs the slot a finished session is squatting on. Deliberately not
|
|
529
|
+
# inside the per-request loop below — the sweep must reach sessions
|
|
530
|
+
# whose PR no longer appears in the search at all.
|
|
531
|
+
self.sweep_sessions()
|
|
532
|
+
|
|
419
533
|
requests = self.github.review_requests(self.config.repos)
|
|
420
534
|
log.info("%d PR(s) with a review pending from %s", len(requests), self.github.login)
|
|
421
535
|
|
|
@@ -442,16 +556,21 @@ class ReviewWatcher:
|
|
|
442
556
|
# every mode, so calling it here too would double every check.
|
|
443
557
|
backoff = self.config.poll_interval
|
|
444
558
|
while True:
|
|
559
|
+
# The sleep lives INSIDE the KeyboardInterrupt guard: with a 60s
|
|
560
|
+
# poll interval (up to 900s backing off) the loop spends nearly
|
|
561
|
+
# all its wall-clock sleeping, so a real Ctrl-C almost always
|
|
562
|
+
# lands there and must hit the same clean-exit path.
|
|
445
563
|
try:
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
564
|
+
try:
|
|
565
|
+
self.poll_once()
|
|
566
|
+
backoff = self.config.poll_interval
|
|
567
|
+
except RateLimited as exc:
|
|
568
|
+
backoff = min(backoff * 2, 900)
|
|
569
|
+
log.warning("rate limited (%s) — backing off %ds", exc, backoff)
|
|
570
|
+
except CommandError as exc:
|
|
571
|
+
backoff = min(backoff * 2, 900)
|
|
572
|
+
log.error("poll failed: %s — retrying in %ds", exc, backoff)
|
|
573
|
+
time.sleep(backoff)
|
|
454
574
|
except KeyboardInterrupt:
|
|
455
575
|
log.info("stopping")
|
|
456
576
|
return
|
|
457
|
-
time.sleep(backoff)
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
|
|
49
|
+
|
|
50
|
+
class State:
|
|
51
|
+
def __init__(self, path: Path):
|
|
52
|
+
path = Path(path).expanduser()
|
|
53
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
self._db = sqlite3.connect(str(path))
|
|
55
|
+
self._db.row_factory = sqlite3.Row
|
|
56
|
+
if self._spawns_keyed_by_round():
|
|
57
|
+
self._migrate_spawns()
|
|
58
|
+
self._db.executescript(SCHEMA)
|
|
59
|
+
self._db.commit()
|
|
60
|
+
|
|
61
|
+
def _migrate_spawns(self) -> None:
|
|
62
|
+
"""Re-key an old round-keyed `spawns` by session, in ONE transaction.
|
|
63
|
+
|
|
64
|
+
Deliberately not executescript (it COMMITs the open transaction before
|
|
65
|
+
running): a crash between the rename and the copy would otherwise
|
|
66
|
+
leave an empty new `spawns` that no longer looks stale, stranding
|
|
67
|
+
every row in spawns_v0 — an empty ledger makes the sweep spare every
|
|
68
|
+
live session as "not ours". All-or-nothing instead: any failure rolls
|
|
69
|
+
back to the untouched old table and the next open retries.
|
|
70
|
+
"""
|
|
71
|
+
self._db.execute("BEGIN IMMEDIATE")
|
|
72
|
+
try:
|
|
73
|
+
self._db.execute("ALTER TABLE spawns RENAME TO spawns_v0")
|
|
74
|
+
self._db.execute(_SPAWNS_TABLE)
|
|
75
|
+
self._db.execute(
|
|
76
|
+
"INSERT OR REPLACE INTO spawns "
|
|
77
|
+
"(repo, number, round, head_sha, session, task_ref, spawned_at) "
|
|
78
|
+
"SELECT repo, number, round, head_sha, session, task_ref, spawned_at "
|
|
79
|
+
"FROM spawns_v0"
|
|
80
|
+
)
|
|
81
|
+
self._db.execute("DROP TABLE spawns_v0")
|
|
82
|
+
except BaseException:
|
|
83
|
+
self._db.execute("ROLLBACK")
|
|
84
|
+
raise
|
|
85
|
+
self._db.execute("COMMIT")
|
|
86
|
+
|
|
87
|
+
def _spawns_keyed_by_round(self) -> bool:
|
|
88
|
+
"""True when `spawns` still has the pre-0.8 (repo, number, round) key.
|
|
89
|
+
|
|
90
|
+
That key made `record_spawn` overwrite the row when a stalled round
|
|
91
|
+
was re-enqueued, orphaning the original -- possibly still-live --
|
|
92
|
+
session so the reap sweep spared it forever as "not ours". The key is
|
|
93
|
+
now the session name (unique per spawn, thanks to the nonce). SQLite
|
|
94
|
+
cannot alter a primary key in place, so an old table is renamed and
|
|
95
|
+
copied over exactly once on open.
|
|
96
|
+
"""
|
|
97
|
+
info = self._db.execute("PRAGMA table_info(spawns)").fetchall()
|
|
98
|
+
if not info:
|
|
99
|
+
return False # fresh database, nothing to migrate
|
|
100
|
+
return [r["name"] for r in info if r["pk"]] != ["session"]
|
|
101
|
+
|
|
102
|
+
def close(self) -> None:
|
|
103
|
+
self._db.close()
|
|
104
|
+
|
|
105
|
+
def __enter__(self) -> "State":
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def __exit__(self, *exc) -> None:
|
|
109
|
+
self.close()
|
|
110
|
+
|
|
111
|
+
def get_spawn(self, repo: str, number: int, round_: int) -> sqlite3.Row | None:
|
|
112
|
+
"""The NEWEST spawn recorded for this round, or None.
|
|
113
|
+
|
|
114
|
+
A stalled round can be re-enqueued, so one round may have several
|
|
115
|
+
spawns; aging and the in-flight check are about the latest attempt.
|
|
116
|
+
"""
|
|
117
|
+
return self._db.execute(
|
|
118
|
+
"SELECT * FROM spawns WHERE repo=? AND number=? AND round=? "
|
|
119
|
+
"ORDER BY spawned_at DESC, rowid DESC LIMIT 1",
|
|
120
|
+
(repo, number, round_),
|
|
121
|
+
).fetchone()
|
|
122
|
+
|
|
123
|
+
def find_spawn_by_session(self, session: str) -> sqlite3.Row | None:
|
|
124
|
+
"""The spawn a live session name belongs to, or None if it is not ours.
|
|
125
|
+
|
|
126
|
+
Session names carry a random nonce, so a name maps to at most one
|
|
127
|
+
spawn. The reap sweep starts from live tmux state and uses this to
|
|
128
|
+
recover (repo, number, round); a session with no row (another
|
|
129
|
+
workspace's daemon, or a hand-started one) is not ours to judge.
|
|
130
|
+
"""
|
|
131
|
+
return self._db.execute(
|
|
132
|
+
"SELECT * FROM spawns WHERE session=?", (session,)
|
|
133
|
+
).fetchone()
|
|
134
|
+
|
|
135
|
+
def spawn_age(self, repo: str, number: int, round_: int) -> float | None:
|
|
136
|
+
"""Seconds since round `round_` was enqueued, or None if never spawned."""
|
|
137
|
+
row = self.get_spawn(repo, number, round_)
|
|
138
|
+
return None if row is None else time.time() - row["spawned_at"]
|
|
139
|
+
|
|
140
|
+
def record_spawn(
|
|
141
|
+
self,
|
|
142
|
+
*,
|
|
143
|
+
repo: str,
|
|
144
|
+
number: int,
|
|
145
|
+
round_: int,
|
|
146
|
+
head_sha: str,
|
|
147
|
+
session: str,
|
|
148
|
+
task_ref: str | None,
|
|
149
|
+
) -> None:
|
|
150
|
+
self._db.execute(
|
|
151
|
+
"INSERT OR REPLACE INTO spawns "
|
|
152
|
+
"(repo, number, round, head_sha, session, task_ref, spawned_at) "
|
|
153
|
+
"VALUES (?,?,?,?,?,?,?)",
|
|
154
|
+
(repo, number, round_, head_sha, session, task_ref, int(time.time())),
|
|
155
|
+
)
|
|
156
|
+
self._db.commit()
|
|
157
|
+
|
|
158
|
+
def is_reaped(self, session: str) -> bool:
|
|
159
|
+
row = self._db.execute(
|
|
160
|
+
"SELECT 1 FROM reaps WHERE session=?", (session,)
|
|
161
|
+
).fetchone()
|
|
162
|
+
return row is not None
|
|
163
|
+
|
|
164
|
+
def record_reap(self, session: str) -> None:
|
|
165
|
+
self._db.execute(
|
|
166
|
+
"INSERT OR REPLACE INTO reaps (session, reaped_at) VALUES (?,?)",
|
|
167
|
+
(session, int(time.time())),
|
|
168
|
+
)
|
|
169
|
+
self._db.commit()
|
|
170
|
+
|
|
171
|
+
def escalated(self, repo: str, number: int, head_sha: str) -> bool:
|
|
172
|
+
row = self._db.execute(
|
|
173
|
+
"SELECT 1 FROM escalations WHERE repo=? AND number=? AND head_sha=?",
|
|
174
|
+
(repo, number, head_sha),
|
|
175
|
+
).fetchone()
|
|
176
|
+
return row is not None
|
|
177
|
+
|
|
178
|
+
def record_escalation(self, repo: str, number: int, head_sha: str) -> None:
|
|
179
|
+
self._db.execute(
|
|
180
|
+
"INSERT OR REPLACE INTO escalations "
|
|
181
|
+
"(repo, number, head_sha, escalated_at) VALUES (?,?,?,?)",
|
|
182
|
+
(repo, number, head_sha, int(time.time())),
|
|
183
|
+
)
|
|
184
|
+
self._db.commit()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.8.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.8.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
|