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.
Files changed (23) hide show
  1. {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
  2. {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/loop.py +114 -3
  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
  4. alissa_tools_github_revloop-0.13.0/src/main/alissa/tools/github/revloop/version +1 -0
  5. {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
  6. alissa_tools_github_revloop-0.12.0/src/main/alissa/tools/github/revloop/version +0 -1
  7. {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/MANIFEST.in +0 -0
  8. {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/README.md +0 -0
  9. {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/requirements.txt +0 -0
  10. {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/setup.cfg +0 -0
  11. {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/setup.py +0 -0
  12. {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  13. {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  14. {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  15. {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/config.py +0 -0
  16. {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  17. {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  18. {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  19. {alissa_tools_github_revloop-0.12.0 → alissa_tools_github_revloop-0.13.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  20. {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
  21. {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
  22. {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
  23. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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) -> None:
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
- self.sweep_sessions()
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera