alissa-tools-github-reviewloop 0.7.0__tar.gz → 0.9.0__tar.gz

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