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.
Files changed (24) hide show
  1. {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
  2. {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/alissa.py +49 -5
  3. {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/ghclient.py +10 -0
  4. {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/loop.py +151 -32
  5. alissa_tools_github_reviewloop-0.8.0/src/main/alissa/tools/github/reviewloop/state.py +184 -0
  6. alissa_tools_github_reviewloop-0.8.0/src/main/alissa/tools/github/reviewloop/version +1 -0
  7. {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
  8. alissa_tools_github_reviewloop-0.7.0/src/main/alissa/tools/github/reviewloop/state.py +0 -115
  9. alissa_tools_github_reviewloop-0.7.0/src/main/alissa/tools/github/reviewloop/version +0 -1
  10. {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/MANIFEST.in +0 -0
  11. {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/README.md +0 -0
  12. {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/requirements.txt +0 -0
  13. {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/setup.cfg +0 -0
  14. {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/setup.py +0 -0
  15. {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/__init__.py +0 -0
  16. {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/__main__.py +0 -0
  17. {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/config.py +0 -0
  18. {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/proc.py +0 -0
  19. {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/prreview.py +0 -0
  20. {alissa_tools_github_reviewloop-0.7.0 → alissa_tools_github_reviewloop-0.8.0}/src/main/alissa/tools/github/reviewloop/version.py +0 -0
  21. {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
  22. {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
  23. {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
  24. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-reviewloop
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVIEWLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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 kill_session(self, session: str, *, dry_run: bool = False) -> None:
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
- def _reap_finished(self, repo: str, number: int, completed: int) -> None:
248
- """Kill the managed session of every round whose review has landed.
249
-
250
- A round `<= completed` is done (its review was submitted), so its session
251
- is finished and should not hold a worker slot. The reviewer self-kills as
252
- the fast path; this is the backstop for when it forgets. Idempotent — each
253
- session is killed at most once (recorded in the ledger) — never runs in
254
- dry-run, and best-effort: a reap failure (or an already-gone session) must
255
- not take the poll down.
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
- if self.config.dry_run or completed < 1:
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
- for round_ in range(1, completed + 1):
260
- row = self.state.get_spawn(repo, number, round_)
261
- if row is None or self.state.is_reaped(row["session"]):
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(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", session)
327
+ log.exception("failed to reap session %s", ses.name)
268
328
  continue
269
- self.state.record_reap(session)
270
- log.info("reaped finished reviewer session %s (round %d done)", session, round_)
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
- self.poll_once()
447
- backoff = self.config.poll_interval
448
- except RateLimited as exc:
449
- backoff = min(backoff * 2, 900)
450
- log.warning("rate limited (%s) backing off %ds", exc, backoff)
451
- except CommandError as exc:
452
- backoff = min(backoff * 2, 900)
453
- log.error("poll failed: %s retrying in %ds", exc, backoff)
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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-reviewloop
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVIEWLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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()