alissa-tools-github-revloop 0.12.0__tar.gz → 0.13.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_revloop-0.12.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.13.0}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/loop.py +114 -3
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/state.py +116 -0
- alissa_tools_github_revloop-0.13.0/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.12.0/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/README.md +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/setup.py +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/config.py +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -14,6 +14,7 @@ import logging
|
|
|
14
14
|
import re
|
|
15
15
|
import secrets
|
|
16
16
|
import time
|
|
17
|
+
from collections import Counter
|
|
17
18
|
from dataclasses import dataclass
|
|
18
19
|
from enum import Enum
|
|
19
20
|
from pathlib import Path
|
|
@@ -185,6 +186,19 @@ class Decision:
|
|
|
185
186
|
action: Action
|
|
186
187
|
reason: str = ""
|
|
187
188
|
round: int | None = None
|
|
189
|
+
# Descriptive metadata for the poll-snapshot exhaust (see
|
|
190
|
+
# ReviewWatcher._stage_record). Populated where a decision names a concrete
|
|
191
|
+
# reviewer -- the SPAWNED path and the liveness-deferral IN_FLIGHT paths --
|
|
192
|
+
# and left at its default elsewhere. Purely observational: nothing in the
|
|
193
|
+
# decision logic reads these, so they never change which branch is taken.
|
|
194
|
+
# `deferred` marks an IN_FLIGHT that is a liveness deferral (a live session
|
|
195
|
+
# holding the respawn back) rather than a freshly-enqueued round;
|
|
196
|
+
# `reenqueued` marks a SPAWNED that respawned a round whose prior session
|
|
197
|
+
# was presumed dead (the "stale-re-enqueued" summary bucket).
|
|
198
|
+
session: str | None = None
|
|
199
|
+
task_ref: str | None = None
|
|
200
|
+
deferred: bool = False
|
|
201
|
+
reenqueued: bool = False
|
|
188
202
|
|
|
189
203
|
|
|
190
204
|
def session_name(pr: PullRequest, round_: int) -> str:
|
|
@@ -323,6 +337,8 @@ class ReviewWatcher:
|
|
|
323
337
|
Action.IN_FLIGHT,
|
|
324
338
|
f"round {round_} is stale but liveness is unprobeable — deferring",
|
|
325
339
|
round_,
|
|
340
|
+
session=session,
|
|
341
|
+
deferred=True,
|
|
326
342
|
)
|
|
327
343
|
|
|
328
344
|
ses = live.get(session)
|
|
@@ -356,6 +372,8 @@ class ReviewWatcher:
|
|
|
356
372
|
f"{session} is still {life} — not "
|
|
357
373
|
f"respawning over a live reviewer",
|
|
358
374
|
round_,
|
|
375
|
+
session=session,
|
|
376
|
+
deferred=True,
|
|
359
377
|
)
|
|
360
378
|
|
|
361
379
|
def _convergence_reason(
|
|
@@ -405,9 +423,14 @@ class ReviewWatcher:
|
|
|
405
423
|
|
|
406
424
|
# -- reap sweep --------------------------------------------------------
|
|
407
425
|
|
|
408
|
-
def sweep_sessions(self) ->
|
|
426
|
+
def sweep_sessions(self) -> int:
|
|
409
427
|
"""Kill the managed session of every finished round. Runs every poll.
|
|
410
428
|
|
|
429
|
+
Returns the number of sessions actually reaped this pass (0 in
|
|
430
|
+
`--dry-run`, where the sweep only logs) -- the poll-snapshot exhaust
|
|
431
|
+
records it, and it is the one count the snapshot cannot derive from
|
|
432
|
+
the per-PR Decision list.
|
|
433
|
+
|
|
411
434
|
The predecessor of this sweep ran inside evaluate(), which is fed by
|
|
412
435
|
the review-requested:@me search -- and submitting a review CLEARS the
|
|
413
436
|
request, so a finished round's PR vanished from the search at exactly
|
|
@@ -435,7 +458,7 @@ class ReviewWatcher:
|
|
|
435
458
|
sessions = self.alissa.list_review_sessions()
|
|
436
459
|
except CommandError as exc:
|
|
437
460
|
log.warning("reap sweep skipped: could not list sessions: %s", exc)
|
|
438
|
-
return
|
|
461
|
+
return 0
|
|
439
462
|
|
|
440
463
|
# Per-sweep memos. The PR fetch is keyed per distinct PR; the round
|
|
441
464
|
# count additionally keys on the task ref, because two spawns of one
|
|
@@ -443,6 +466,7 @@ class ReviewWatcher:
|
|
|
443
466
|
# task existed carries None). None = undecidable this pass.
|
|
444
467
|
prs: dict[tuple[str, int], PullRequest | None] = {}
|
|
445
468
|
completed_cache: dict[tuple[str, int, str | None], float | None] = {}
|
|
469
|
+
reaped = 0
|
|
446
470
|
|
|
447
471
|
for ses in sessions:
|
|
448
472
|
if not ses.is_idle:
|
|
@@ -486,10 +510,12 @@ class ReviewWatcher:
|
|
|
486
510
|
# The live list is the authority; gating on the reaps table would
|
|
487
511
|
# spare any session killed behind the ledger's back.
|
|
488
512
|
self.state.record_reap(ses.name)
|
|
513
|
+
reaped += 1
|
|
489
514
|
log.info(
|
|
490
515
|
"reaped finished reviewer session %s (round %d done)",
|
|
491
516
|
ses.name, row["round"],
|
|
492
517
|
)
|
|
518
|
+
return reaped
|
|
493
519
|
|
|
494
520
|
def _sweep_pr(self, repo_slug: str, number: int) -> PullRequest | None:
|
|
495
521
|
"""One PR fetch for the sweep; the caller memoizes per distinct PR.
|
|
@@ -596,6 +622,9 @@ class ReviewWatcher:
|
|
|
596
622
|
Action.SPAWNED,
|
|
597
623
|
f"session {name} → {task.ref if task else 'no task'}",
|
|
598
624
|
round_,
|
|
625
|
+
session=name,
|
|
626
|
+
task_ref=task.ref if task else None,
|
|
627
|
+
reenqueued=reenqueued,
|
|
599
628
|
)
|
|
600
629
|
|
|
601
630
|
def _ensure_hub(self, pr: PullRequest) -> tuple[Path, str | None]:
|
|
@@ -778,7 +807,8 @@ class ReviewWatcher:
|
|
|
778
807
|
# needs the slot a finished session is squatting on. Deliberately not
|
|
779
808
|
# inside the per-request loop below — the sweep must reach sessions
|
|
780
809
|
# whose PR no longer appears in the search at all.
|
|
781
|
-
|
|
810
|
+
started = time.monotonic()
|
|
811
|
+
reaped = self.sweep_sessions()
|
|
782
812
|
|
|
783
813
|
requests = self.github.review_requests(self.config.repos)
|
|
784
814
|
log.info("%d PR(s) with a review pending from %s", len(requests), self.github.login)
|
|
@@ -799,8 +829,89 @@ class ReviewWatcher:
|
|
|
799
829
|
level = logging.INFO if decision.action != Action.SKIPPED else logging.DEBUG
|
|
800
830
|
log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
|
|
801
831
|
results.append((slug, decision))
|
|
832
|
+
|
|
833
|
+
# Persist one poll_snapshots row per pass, built entirely from the
|
|
834
|
+
# Decision list already in hand plus the reap count -- no new GitHub
|
|
835
|
+
# calls. Written in dry-run too: a snapshot OBSERVES the pass, it is
|
|
836
|
+
# not a side effect the daemon takes, so a future console sees dry-run
|
|
837
|
+
# passes as well as live ones.
|
|
838
|
+
self._write_snapshot(
|
|
839
|
+
results, reaped, duration_ms=int((time.monotonic() - started) * 1000)
|
|
840
|
+
)
|
|
802
841
|
return results
|
|
803
842
|
|
|
843
|
+
def _stage_record(self, slug: str, decision: Decision) -> dict:
|
|
844
|
+
"""One per-item entry of a poll snapshot's compact JSON: the PR
|
|
845
|
+
reference (the slug and the number parsed from it), the current stage
|
|
846
|
+
(the decision's action, refined to name the stale-re-enqueue and
|
|
847
|
+
liveness-deferral buckets the bare action folds together), and the
|
|
848
|
+
round, session name, and origin task ref carried on the Decision.
|
|
849
|
+
`attempt` is carried as a fixed None for schema parity with the
|
|
850
|
+
devloop's per-item record -- the reviewloop is round-based and has no
|
|
851
|
+
attempt dimension -- so one console can read both loops' snapshots."""
|
|
852
|
+
_, _, tail = slug.partition("#")
|
|
853
|
+
stage = decision.action.value
|
|
854
|
+
if decision.reenqueued:
|
|
855
|
+
stage = "stale-re-enqueued"
|
|
856
|
+
elif decision.deferred:
|
|
857
|
+
stage = "deferred"
|
|
858
|
+
return {
|
|
859
|
+
"slug": slug,
|
|
860
|
+
"number": int(tail),
|
|
861
|
+
"round": decision.round,
|
|
862
|
+
"attempt": None,
|
|
863
|
+
"session": decision.session,
|
|
864
|
+
"stage": stage,
|
|
865
|
+
"reason": decision.reason,
|
|
866
|
+
"task_ref": decision.task_ref,
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
def _write_snapshot(
|
|
870
|
+
self,
|
|
871
|
+
results: list[tuple[str, Decision]],
|
|
872
|
+
reaped: int,
|
|
873
|
+
*,
|
|
874
|
+
duration_ms: int,
|
|
875
|
+
) -> None:
|
|
876
|
+
"""Persist one poll_snapshots row from the pass's Decision list and the
|
|
877
|
+
reaper count -- no new GitHub calls. The SPAWNED and IN_FLIGHT buckets
|
|
878
|
+
each split in two off observational Decision flags: a SPAWNED that
|
|
879
|
+
respawned a presumed-dead round is `stale_reenqueued`, and an
|
|
880
|
+
IN_FLIGHT that is a liveness deferral (not a freshly-enqueued round) is
|
|
881
|
+
`deferred`."""
|
|
882
|
+
stages = [self._stage_record(slug, d) for slug, d in results]
|
|
883
|
+
counts = Counter(d.action for _, d in results)
|
|
884
|
+
spawned = sum(
|
|
885
|
+
1 for _, d in results
|
|
886
|
+
if d.action is Action.SPAWNED and not d.reenqueued
|
|
887
|
+
)
|
|
888
|
+
stale_reenqueued = sum(
|
|
889
|
+
1 for _, d in results
|
|
890
|
+
if d.action is Action.SPAWNED and d.reenqueued
|
|
891
|
+
)
|
|
892
|
+
in_flight = sum(
|
|
893
|
+
1 for _, d in results
|
|
894
|
+
if d.action is Action.IN_FLIGHT and not d.deferred
|
|
895
|
+
)
|
|
896
|
+
deferred = sum(
|
|
897
|
+
1 for _, d in results
|
|
898
|
+
if d.action is Action.IN_FLIGHT and d.deferred
|
|
899
|
+
)
|
|
900
|
+
self.state.record_snapshot(
|
|
901
|
+
duration_ms=duration_ms,
|
|
902
|
+
candidates=len(results),
|
|
903
|
+
spawned=spawned,
|
|
904
|
+
stale_reenqueued=stale_reenqueued,
|
|
905
|
+
in_flight=in_flight,
|
|
906
|
+
deferred=deferred,
|
|
907
|
+
converged=counts[Action.CONVERGED],
|
|
908
|
+
capped=counts[Action.CAPPED],
|
|
909
|
+
escalated=counts[Action.ESCALATED],
|
|
910
|
+
skipped=counts[Action.SKIPPED],
|
|
911
|
+
reaped=reaped,
|
|
912
|
+
stages=stages,
|
|
913
|
+
)
|
|
914
|
+
|
|
804
915
|
def run_forever(self) -> None:
|
|
805
916
|
# preflight() is the caller's responsibility -- the CLI runs it once for
|
|
806
917
|
# every mode, so calling it here too would double every check.
|
|
@@ -7,14 +7,35 @@ session name back to the round it was spawned for (so the reap sweep can tell
|
|
|
7
7
|
a finished round's session from an in-flight one), and to remember that a
|
|
8
8
|
cap-out was already escalated. The ledger tolerates sessions dying or being
|
|
9
9
|
killed behind its back: a reap record is bookkeeping, never a precondition.
|
|
10
|
+
|
|
11
|
+
The `poll_snapshots` table is a different animal from the ledger above: it
|
|
12
|
+
records what each poll pass OBSERVED, not what the daemon must remember to
|
|
13
|
+
avoid double-work. One row per pass carries the timing, the candidate count,
|
|
14
|
+
the decision-summary counts, and a compact JSON column of the pass's per-item
|
|
15
|
+
stages -- everything a future console sidecar (the UI-1 pattern ported from
|
|
16
|
+
the devloop) needs to render live daemon state without spending a single
|
|
17
|
+
GitHub API call of its own. It is self-bounding: the newest SNAPSHOT_RETENTION
|
|
18
|
+
rows are kept and older ones pruned on every write. `read_snapshots` is the
|
|
19
|
+
reader that sidecar will consume (newest first, `stages` decoded back from
|
|
20
|
+
JSON). Adding the table is itself the migration for an existing database --
|
|
21
|
+
`CREATE TABLE IF NOT EXISTS` creates it on the next open of a DB that predates
|
|
22
|
+
it, alongside the untouched legacy ledgers.
|
|
10
23
|
"""
|
|
11
24
|
|
|
12
25
|
from __future__ import annotations
|
|
13
26
|
|
|
27
|
+
import json
|
|
14
28
|
import sqlite3
|
|
15
29
|
import time
|
|
16
30
|
from pathlib import Path
|
|
17
31
|
|
|
32
|
+
# Poll-snapshot retention: the newest N rows are kept, older ones pruned on
|
|
33
|
+
# every write. Fixed, not a config key -- `poll_snapshots` is an observation
|
|
34
|
+
# buffer for a future console sidecar, and a bounded ring is all it needs (it
|
|
35
|
+
# reads the recent tail). A change to this constant is a change to the
|
|
36
|
+
# observable buffer size, so it is pinned by a test.
|
|
37
|
+
SNAPSHOT_RETENTION = 1000
|
|
38
|
+
|
|
18
39
|
# Shared between SCHEMA and the migration so the two can never drift.
|
|
19
40
|
_SPAWNS_TABLE = """
|
|
20
41
|
CREATE TABLE IF NOT EXISTS spawns (
|
|
@@ -52,6 +73,23 @@ CREATE TABLE IF NOT EXISTS pings (
|
|
|
52
73
|
pinged_at INTEGER NOT NULL,
|
|
53
74
|
PRIMARY KEY (repo, number, kind)
|
|
54
75
|
);
|
|
76
|
+
|
|
77
|
+
CREATE TABLE IF NOT EXISTS poll_snapshots (
|
|
78
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
79
|
+
ts INTEGER NOT NULL,
|
|
80
|
+
duration_ms INTEGER NOT NULL,
|
|
81
|
+
candidates INTEGER NOT NULL,
|
|
82
|
+
spawned INTEGER NOT NULL,
|
|
83
|
+
stale_reenqueued INTEGER NOT NULL,
|
|
84
|
+
in_flight INTEGER NOT NULL,
|
|
85
|
+
deferred INTEGER NOT NULL,
|
|
86
|
+
converged INTEGER NOT NULL,
|
|
87
|
+
capped INTEGER NOT NULL,
|
|
88
|
+
escalated INTEGER NOT NULL,
|
|
89
|
+
skipped INTEGER NOT NULL,
|
|
90
|
+
reaped INTEGER NOT NULL,
|
|
91
|
+
stages_json TEXT NOT NULL
|
|
92
|
+
);
|
|
55
93
|
"""
|
|
56
94
|
|
|
57
95
|
|
|
@@ -216,3 +254,81 @@ class State:
|
|
|
216
254
|
(repo, number, head_sha, int(time.time())),
|
|
217
255
|
)
|
|
218
256
|
self._db.commit()
|
|
257
|
+
|
|
258
|
+
# -- poll snapshots (the console sidecar's exhaust buffer) -------------
|
|
259
|
+
|
|
260
|
+
def record_snapshot(
|
|
261
|
+
self,
|
|
262
|
+
*,
|
|
263
|
+
duration_ms: int,
|
|
264
|
+
candidates: int,
|
|
265
|
+
spawned: int = 0,
|
|
266
|
+
stale_reenqueued: int = 0,
|
|
267
|
+
in_flight: int = 0,
|
|
268
|
+
deferred: int = 0,
|
|
269
|
+
converged: int = 0,
|
|
270
|
+
capped: int = 0,
|
|
271
|
+
escalated: int = 0,
|
|
272
|
+
skipped: int = 0,
|
|
273
|
+
reaped: int = 0,
|
|
274
|
+
stages: list[dict],
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Append one poll-pass observation, then prune to the newest
|
|
277
|
+
SNAPSHOT_RETENTION rows. `ts` is stamped here (wall-clock seconds,
|
|
278
|
+
like every other row in this ledger); `stages` is the compact
|
|
279
|
+
per-item list a future console reads back through read_snapshots,
|
|
280
|
+
serialized to JSON. Purely observational -- written on every pass,
|
|
281
|
+
dry-run included -- and pruned on write, so the table is
|
|
282
|
+
self-bounding. The count kwargs default to 0 so a caller need only
|
|
283
|
+
pass the ones a given pass produced.
|
|
284
|
+
"""
|
|
285
|
+
self._db.execute(
|
|
286
|
+
"INSERT INTO poll_snapshots "
|
|
287
|
+
"(ts, duration_ms, candidates, spawned, stale_reenqueued, "
|
|
288
|
+
"in_flight, deferred, converged, capped, escalated, skipped, "
|
|
289
|
+
"reaped, stages_json) "
|
|
290
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
291
|
+
(
|
|
292
|
+
int(time.time()),
|
|
293
|
+
duration_ms,
|
|
294
|
+
candidates,
|
|
295
|
+
spawned,
|
|
296
|
+
stale_reenqueued,
|
|
297
|
+
in_flight,
|
|
298
|
+
deferred,
|
|
299
|
+
converged,
|
|
300
|
+
capped,
|
|
301
|
+
escalated,
|
|
302
|
+
skipped,
|
|
303
|
+
reaped,
|
|
304
|
+
json.dumps(stages, separators=(",", ":")),
|
|
305
|
+
),
|
|
306
|
+
)
|
|
307
|
+
# Prune on write: keep the newest SNAPSHOT_RETENTION rows by id. The
|
|
308
|
+
# autoincrement id is monotonic across prunes, so "newest" is well
|
|
309
|
+
# defined even when a wall-clock step would leave `ts` unordered.
|
|
310
|
+
self._db.execute(
|
|
311
|
+
"DELETE FROM poll_snapshots WHERE id NOT IN "
|
|
312
|
+
"(SELECT id FROM poll_snapshots ORDER BY id DESC LIMIT ?)",
|
|
313
|
+
(SNAPSHOT_RETENTION,),
|
|
314
|
+
)
|
|
315
|
+
self._db.commit()
|
|
316
|
+
|
|
317
|
+
def read_snapshots(self, limit: int | None = None) -> list[dict]:
|
|
318
|
+
"""Poll snapshots newest-first, each with its per-item `stages` list
|
|
319
|
+
decoded back from JSON (the round-trip counterpart of
|
|
320
|
+
record_snapshot). `limit` caps the rows returned; None returns every
|
|
321
|
+
retained row. This is the whole contract the future console depends
|
|
322
|
+
on -- everything it needs is already here, so it makes no GitHub
|
|
323
|
+
calls."""
|
|
324
|
+
sql = "SELECT * FROM poll_snapshots ORDER BY id DESC"
|
|
325
|
+
params: tuple = ()
|
|
326
|
+
if limit is not None:
|
|
327
|
+
sql += " LIMIT ?"
|
|
328
|
+
params = (limit,)
|
|
329
|
+
out = []
|
|
330
|
+
for row in self._db.execute(sql, params).fetchall():
|
|
331
|
+
record = dict(row)
|
|
332
|
+
record["stages"] = json.loads(record.pop("stages_json"))
|
|
333
|
+
out.append(record)
|
|
334
|
+
return out
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.13.0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.12.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|