alissa-tools-github-revloop 0.12.0__py3-none-any.whl

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.
@@ -0,0 +1,826 @@
1
+ """The watcher loop.
2
+
3
+ One pass = poll GitHub for pending review requests, decide per PR whether a
4
+ fresh reviewer round is owed, and enqueue it. Rounds are derived from GitHub
5
+ (one *substantive* submitted review per round -- empty-bodied records are
6
+ inline-comment artifacts, not rounds), not from local bookkeeping. Convergence
7
+ comes from either the GitHub review state or the CR6 verdict envelope on the
8
+ Alissa review task.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import re
15
+ import secrets
16
+ import time
17
+ from dataclasses import dataclass
18
+ from enum import Enum
19
+ from pathlib import Path
20
+
21
+ from .alissa import VERDICT_APPROVE, Alissa, Task
22
+ from .config import HUB_ADD, ON_MISSING_SKIP, Config
23
+ from .ghclient import GitHub, PullRequest, RateLimited, Review
24
+ from .proc import CommandError
25
+ from .state import State
26
+
27
+ log = logging.getLogger(__name__)
28
+
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 --
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.
36
+ STALE_ROUND_SECONDS = 90 * 60
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
+
57
+ # The closing contract is spelled out in both directives (not just the skill)
58
+ # because it is the reviewer's most-skipped step: on re-review, sessions produce
59
+ # findings but never register the review on the PR, or stop without a verdict.
60
+ _CLOSE_THE_ROUND = (
61
+ "CLOSE THE ROUND — both are mandatory or the round does not count: "
62
+ "(1) SUBMIT your review so it lands as one registered review record ON the "
63
+ "PR (gh pr review / the reviews API) and confirm it with "
64
+ "`gh api repos/<org>/<repo>/pulls/<n>/reviews` — findings left only in your "
65
+ "session do not exist; (2) end with a decisive verdict — approve OR "
66
+ "request_changes, never neither, never comment-only. You are read-only: "
67
+ "never commit or fix, even a one-character typo — a needed fix IS "
68
+ "request_changes. "
69
+ )
70
+
71
+ # Reviewers are one-shot per round (CR3), so a finished session should not linger
72
+ # holding a worker slot. The daemon reaps it as a backstop, but the fast path is
73
+ # the reviewer releasing its own slot as its very last action. {session} is the
74
+ # reviewer's own managed session name, injected at spawn.
75
+ _RELEASE_SLOT = (
76
+ "FINALLY, and only once the round is fully closed above (review registered "
77
+ "AND verdict recorded), release your worker slot as your last action: run "
78
+ "`alissa tmux kill {session}`. Do nothing after it."
79
+ )
80
+
81
+ ROUND_1_DIRECTIVE = (
82
+ "You are a PR REVIEWER, not an implementer. {assignment} "
83
+ "Load the alissa-code-review skill and follow procedures/review-a-pr.md: "
84
+ "hydrate the task and the PR it names, review per the rubric, post "
85
+ "severity-tagged comments via gh pr review, record the verdict evidence, "
86
+ "move the task to pending_validation. "
87
+ + _CLOSE_THE_ROUND +
88
+ "NEVER push commits, merge, or change PR state. "
89
+ "Do NOT create further ali-* sessions. "
90
+ + _RELEASE_SLOT
91
+ )
92
+
93
+ ROUND_K_DIRECTIVE = (
94
+ "You are a PR REVIEWER, not an implementer — round {round} of a review loop "
95
+ "(cap {cap}). {assignment} "
96
+ "Load the alissa-code-review skill and follow procedures/review-a-pr.md "
97
+ "including its round-k section: verify the triage of every prior finding, "
98
+ "verify the fixes, sweep the new diff with the full rubric, record a "
99
+ "round-{round} verdict envelope, move the task to pending_validation. "
100
+ + _CLOSE_THE_ROUND +
101
+ "NEVER push commits, merge, or change PR state. "
102
+ "Do NOT create further ali-* sessions. "
103
+ + _RELEASE_SLOT
104
+ )
105
+
106
+ ESCALATION_COMMENT = (
107
+ "**Review loop cap-out (CR9)** — {rounds} rounds ran on this PR without "
108
+ "converging on `approve`. Per the alissa-code-review skill the loop does not "
109
+ "run past the cap and never silently merges; this needs an operator decision "
110
+ "(merge with a recorded waiver, direct specific fixes and re-enter with a "
111
+ "fresh cap, or park it).\n\n"
112
+ "Last verdict: `{last_state}` at `{sha}`."
113
+ )
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 hidden marker that identifies THE activity comment on a PR. Find-or-create
127
+ # keys on it (plus own authorship -- anyone can paste the marker into their own
128
+ # comment, and a spoofed marker must never be PATCHed), so every append lands in
129
+ # the same single comment however many rounds run.
130
+ ACTIVITY_MARKER = "<!-- alissa-revloop:activity -->"
131
+
132
+ ACTIVITY_HEADER = (
133
+ ACTIVITY_MARKER + "\n"
134
+ "**Review-loop activity** — mechanical spawn/round log; the daemon appends "
135
+ "a line each time it queues (or defers) a reviewer round on this PR."
136
+ )
137
+
138
+ # The ping-ledger kind prefix for the stalled-deferral operator ping. Unlike
139
+ # the cap-out escalation (terminal per head), a stall can recur, so the kind
140
+ # is narrowed per episode -- see stalled_kind.
141
+ ESCALATION_STALLED = "stalled"
142
+
143
+
144
+ def deferral_activity_kind(session: str) -> str:
145
+ """The ping-ledger kind that dedupes ONE deferral episode's activity line.
146
+
147
+ A deferral is re-decided every poll, so appending per decision would grow
148
+ the activity comment by one identical line a minute for as long as the
149
+ session holds out. One line per episode carries the same information --
150
+ "the daemon is deferring behind this session" -- and the session name is
151
+ the episode identity (nonce-unique per spawn), exactly as stalled_kind
152
+ reasons for the operator ping. Recorded only after the append lands, so a
153
+ transient failure retries next poll.
154
+ """
155
+ return f"activity-deferred:{session}"
156
+
157
+
158
+ def stalled_kind(session: str) -> str:
159
+ """The ping-ledger kind that dedupes ONE deferral episode's operator ping.
160
+
161
+ Devloop's stalled_kind reasoning, transposed: a stall can recur -- every
162
+ spawn of every round can wedge mid-flight, and episode k's ping must not
163
+ silence episode k+1's. Keyed on the bare kind (or even on the round), the
164
+ re-enqueue of a round that wedges AGAIN would defer silently forever. The
165
+ session name already IS the episode identity -- nonce-unique per spawn
166
+ (see session_name) -- so it folds into the key. Delivery contract: the
167
+ ledger row lands only AFTER the comment posts (see _escalate_stalled), so
168
+ a transient comment failure retries next poll and the ping lands exactly
169
+ once per episode.
170
+ """
171
+ return f"{ESCALATION_STALLED}:{session}"
172
+
173
+
174
+ class Action(str, Enum):
175
+ SPAWNED = "spawned"
176
+ IN_FLIGHT = "in-flight"
177
+ CONVERGED = "converged"
178
+ CAPPED = "capped"
179
+ ESCALATED = "escalated"
180
+ SKIPPED = "skipped"
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class Decision:
185
+ action: Action
186
+ reason: str = ""
187
+ round: int | None = None
188
+
189
+
190
+ def session_name(pr: PullRequest, round_: int) -> str:
191
+ """A tmux-safe reviewer session name, unique per spawn.
192
+
193
+ The `review-<repo>-pr<n>-r<round>` prefix stays human-readable, but a short
194
+ random nonce is appended so a re-used or miscounted round number can never
195
+ collide with a still-live session (a collision wedges the worker -- the
196
+ original 'stuck' failure). Safe to be non-deterministic: the generated name
197
+ is recorded in the spawn ledger and is what gets reaped / self-killed, so the
198
+ daemon never re-derives it.
199
+ """
200
+ repo = re.sub(r"[^A-Za-z0-9-]", "-", pr.repo).strip("-").lower()
201
+ return f"review-{repo}-pr{pr.number}-r{round_}-{secrets.token_hex(3)}"
202
+
203
+
204
+ class ReviewWatcher:
205
+ def __init__(
206
+ self,
207
+ config: Config,
208
+ github: GitHub | None = None,
209
+ alissa: Alissa | None = None,
210
+ state: State | None = None,
211
+ ):
212
+ self.config = config
213
+ self.github = github or GitHub(config.reviewer_login)
214
+ self.alissa = alissa or Alissa()
215
+ self.state = state or State(config.state_db)
216
+
217
+ # -- per-PR decision ---------------------------------------------------
218
+
219
+ def evaluate(self, owner: str, repo: str, number: int) -> Decision:
220
+ pr = self.github.pull_request(owner, repo, number)
221
+
222
+ # CR1: draft PRs are never reviewed. The search already filters these;
223
+ # this catches a flip back to draft between search and fetch.
224
+ if pr.draft:
225
+ return Decision(Action.SKIPPED, "PR is a draft (CR1)")
226
+
227
+ if pr.author == self.github.login:
228
+ # GitHub rejects a self review-request, so this should be
229
+ # unreachable -- but a shared bot identity would land here.
230
+ return Decision(
231
+ Action.SKIPPED,
232
+ f"PR author is the reviewer identity ({pr.author}); "
233
+ "GitHub forbids self-review",
234
+ )
235
+
236
+ my_reviews = self.github.my_reviews(owner, repo, number)
237
+
238
+ # The review task (CR2) is the round record: one verdict envelope per
239
+ # round (CR7), so counting envelopes is the authoritative "rounds
240
+ # completed" -- immune to the GitHub heuristics that drift. A round whose
241
+ # review has an empty body undercounts (round_ repeats -> the session name
242
+ # collides -> the worker wedges); two reviews in one cycle overcount.
243
+ # Fall back to the substantive-review count only before the review task
244
+ # exists (round 1). Looked up here (not in _spawn) because both the count
245
+ # and convergence need it.
246
+ task = self.alissa.find_review_task(owner, repo, number)
247
+ completed = (
248
+ self.alissa.count_verdicts(task.ref) if task is not None
249
+ else len(my_reviews)
250
+ )
251
+
252
+ converged = self._convergence_reason(my_reviews, task, pr.head_sha)
253
+ if converged is not None:
254
+ return Decision(Action.CONVERGED, converged, completed)
255
+
256
+ # CR9: never queue round cap+1.
257
+ if completed >= self.config.round_cap:
258
+ if self.state.escalated(pr.full_name, number, pr.head_sha):
259
+ return Decision(Action.CAPPED, "already escalated", completed)
260
+ self._escalate(pr, my_reviews[-1].state if my_reviews else "none", completed)
261
+ return Decision(Action.ESCALATED, f"{completed} rounds, no approve", completed)
262
+
263
+ round_ = completed + 1
264
+
265
+ age = self.state.spawn_age(pr.full_name, number, round_)
266
+ if age is not None and age < STALE_ROUND_SECONDS:
267
+ return Decision(Action.IN_FLIGHT, f"round {round_} enqueued {int(age)}s ago", round_)
268
+ if age is not None:
269
+ deferred = self._defer_stale_round(pr, round_, age)
270
+ if deferred is not None:
271
+ return deferred
272
+ log.warning(
273
+ "%s round %d has been in flight %.0f min with no submitted review "
274
+ "and its session is gone or finished — re-enqueuing (reviewer "
275
+ "session presumed dead)",
276
+ pr.slug,
277
+ round_,
278
+ age / 60,
279
+ )
280
+
281
+ return self._spawn(pr, round_, task, reenqueued=age is not None)
282
+
283
+ def _defer_stale_round(self, pr: PullRequest, round_: int, age: float) -> Decision | None:
284
+ """The liveness signal under the stale timer: a deferral, or None to
285
+ respawn.
286
+
287
+ Staleness needs TWO signals, not one: the ledger timer says the
288
+ newest spawn is old, but elapsed time alone cannot tell a dead
289
+ session from a slow one -- a thorough round can outlast
290
+ STALE_ROUND_SECONDS, and a timer-only re-enqueue respawns a reviewer
291
+ over the still-working first one; two sessions review the same
292
+ round and both submit. So before respawning, consult external
293
+ evidence of life: the round's recorded session in the live list
294
+ (the reap sweep's own probe). Busy, or idle without a real quiet
295
+ period (mid-close-out between turns; see REAP_QUIET_SECONDS) -> the
296
+ round is alive, defer with a reason. Gone, or idle-finished -> dead,
297
+ respawn (the sweep separately handles any corpse). An unprobeable
298
+ live list defers too: respawning on missing evidence is exactly the
299
+ double-spend, and the probe retries next poll.
300
+
301
+ The deferral is floored, not unbounded: past STALLED_DEFER_MULTIPLE
302
+ stale windows with the session still alive, one operator ping per
303
+ deferral episode (stalled_kind) -- then keep deferring. This method
304
+ never respawns over a live session; only the operator killing the
305
+ session (or it finishing/dying) unblocks the respawn.
306
+ """
307
+ row = self.state.get_spawn(pr.full_name, pr.number, round_)
308
+ if row is None: # age came from this row; belt and braces
309
+ return None
310
+ session = row["session"]
311
+ try:
312
+ live = {s.name: s for s in self.alissa.list_review_sessions()}
313
+ except CommandError as exc:
314
+ log.warning(
315
+ "%s round %d is stale but the session list is unavailable (%s) "
316
+ "— deferring the respawn rather than risking a double-spawned "
317
+ "round; the probe retries next poll",
318
+ pr.slug,
319
+ round_,
320
+ exc,
321
+ )
322
+ return Decision(
323
+ Action.IN_FLIGHT,
324
+ f"round {round_} is stale but liveness is unprobeable — deferring",
325
+ round_,
326
+ )
327
+
328
+ ses = live.get(session)
329
+ if ses is None:
330
+ return None # session gone -> presumed dead -> respawn
331
+ if ses.is_idle and time.time() - ses.last_activity >= REAP_QUIET_SECONDS:
332
+ return None # idle-finished: it died without submitting -> respawn
333
+
334
+ if (
335
+ age >= STALLED_DEFER_MULTIPLE * STALE_ROUND_SECONDS
336
+ and not self.state.pinged(pr.full_name, pr.number, stalled_kind(session))
337
+ ):
338
+ self._escalate_stalled(pr, round_, session, age)
339
+
340
+ life = "active" if ses.is_idle else "busy"
341
+ if not self.state.pinged(pr.full_name, pr.number, deferral_activity_kind(session)):
342
+ appended = self._append_activity(
343
+ pr,
344
+ self._activity_line(
345
+ session, round_, f"deferred — session `{session}` still {life}"
346
+ ),
347
+ )
348
+ if appended:
349
+ self.state.record_ping(
350
+ pr.full_name, pr.number, deferral_activity_kind(session)
351
+ )
352
+
353
+ return Decision(
354
+ Action.IN_FLIGHT,
355
+ f"round {round_} is stale ({int(age / 60)} min) but session "
356
+ f"{session} is still {life} — not "
357
+ f"respawning over a live reviewer",
358
+ round_,
359
+ )
360
+
361
+ def _convergence_reason(
362
+ self, my_reviews: list[Review], task: Task | None, head_sha: str
363
+ ) -> str | None:
364
+ """Why the loop is done, or None if it is not.
365
+
366
+ Two independent signals, because neither alone is sufficient:
367
+
368
+ * The GitHub review state. Authoritative when it says APPROVED, but
369
+ reviewers work in comment mode, which can only ever produce
370
+ COMMENTED -- #210 has zero APPROVED records across its whole history.
371
+ On its own this made convergence unreachable: every PR, however
372
+ clean, ran to the round cap and escalated.
373
+ * The CR6 verdict envelope on the Alissa review task. The review skill
374
+ declares this the verdict of record, and unlike the GitHub state it
375
+ can actually express approval, so it is the signal that closes the
376
+ loop in practice.
377
+
378
+ BOTH are bound to the current head. An approval means "this code is
379
+ good", so once the implementer pushes past the reviewed commit it is
380
+ about old code and the next round is owed. Without this bind a stale
381
+ approve latches the loop shut forever -- #227: round 1 approved
382
+ `fa304de`, the implementer pushed `fd500fc` and re-requested (and even
383
+ dismissed the approve), yet the envelope still read approve and no round
384
+ 2 was ever queued.
385
+ """
386
+ if not my_reviews:
387
+ return None
388
+
389
+ # New commits since the newest review -> its verdict is about old code.
390
+ # A falsy commit_id (older records lack one) can't be checked, so it
391
+ # falls through rather than blocking convergence.
392
+ newest = my_reviews[-1]
393
+ if newest.commit_id and newest.commit_id != head_sha:
394
+ return None
395
+
396
+ if newest.state == "APPROVED":
397
+ return "last GitHub review state is APPROVED"
398
+
399
+ # Only checkable once a review task exists; before that there is
400
+ # nowhere for a verdict to have been recorded.
401
+ if task is not None and self.alissa.latest_verdict(task.ref) == VERDICT_APPROVE:
402
+ return f"newest verdict envelope on {task.ref} reads approve"
403
+
404
+ return None
405
+
406
+ # -- reap sweep --------------------------------------------------------
407
+
408
+ def sweep_sessions(self) -> None:
409
+ """Kill the managed session of every finished round. Runs every poll.
410
+
411
+ The predecessor of this sweep ran inside evaluate(), which is fed by
412
+ the review-requested:@me search -- and submitting a review CLEARS the
413
+ request, so a finished round's PR vanished from the search at exactly
414
+ the moment its session became reapable; terminal (approved) rounds
415
+ were never reaped and idle reviewer sessions accumulated in the
416
+ worker. The sweep instead starts from the live session list, which
417
+ cannot lose a finished session, and works back to the round via the
418
+ spawn ledger. It must stay search-independent: never move it (back)
419
+ into the evaluate() path.
420
+
421
+ Every-poll cost, honestly: one `alissa tmux ls` when no review-*
422
+ session is live; otherwise one PR fetch per distinct PR with a live
423
+ idle quiet session, plus -- per distinct (PR, task ref) among its
424
+ rows -- exactly one of `alissa task get <ref>` (the row carries a
425
+ task ref) or the reviews fetch (it does not). The ledger ref is used
426
+ deliberately instead of
427
+ find_review_task: that would fetch the actor's ENTIRE task list per
428
+ PR, and its open-status filter would drop a human-validated review
429
+ task back onto the racier GitHub-count fallback. Only individual
430
+ sessions are ever killed (`alissa tmux kill <name>`) -- never the
431
+ server. Best-effort throughout: an undecidable session is spared and
432
+ looked at again next poll.
433
+ """
434
+ try:
435
+ sessions = self.alissa.list_review_sessions()
436
+ except CommandError as exc:
437
+ log.warning("reap sweep skipped: could not list sessions: %s", exc)
438
+ return
439
+
440
+ # Per-sweep memos. The PR fetch is keyed per distinct PR; the round
441
+ # count additionally keys on the task ref, because two spawns of one
442
+ # PR can disagree on it (a round-1 row recorded before the review
443
+ # task existed carries None). None = undecidable this pass.
444
+ prs: dict[tuple[str, int], PullRequest | None] = {}
445
+ completed_cache: dict[tuple[str, int, str | None], float | None] = {}
446
+
447
+ for ses in sessions:
448
+ if not ses.is_idle:
449
+ # A busy session is still doing something (reviewing, or
450
+ # closing out its round) -- never yank the slot from under it.
451
+ continue
452
+ if time.time() - ses.last_activity < REAP_QUIET_SECONDS:
453
+ # Idle but recently active: likely mid-close-out (the review
454
+ # is submitted before the envelope and task move land). Wait
455
+ # for a real quiet period; see REAP_QUIET_SECONDS.
456
+ continue
457
+ row = self.state.find_spawn_by_session(ses.name)
458
+ if row is None:
459
+ # Not in our ledger: another workspace's daemon (or a human)
460
+ # owns it. Not ours to judge.
461
+ continue
462
+ pr_key = (row["repo"], row["number"])
463
+ if pr_key not in prs:
464
+ prs[pr_key] = self._sweep_pr(row["repo"], row["number"])
465
+ pr = prs[pr_key]
466
+ if pr is None:
467
+ continue # fetch failed -- spare everything on this PR
468
+ key = (row["repo"], row["number"], row["task_ref"])
469
+ if key not in completed_cache:
470
+ completed_cache[key] = self._completed_rounds(pr, row["task_ref"])
471
+ completed = completed_cache[key]
472
+ if completed is None or row["round"] > completed:
473
+ continue # undecidable, or the round is still in flight
474
+ if self.config.dry_run:
475
+ log.info(
476
+ "[dry-run] would reap finished reviewer session %s (round %d done)",
477
+ ses.name, row["round"],
478
+ )
479
+ continue
480
+ try:
481
+ self.alissa.kill_session(ses.name)
482
+ except Exception: # pragma: no cover - defence in depth
483
+ log.exception("failed to reap session %s", ses.name)
484
+ continue
485
+ # Bookkeeping only -- deliberately never consulted before a kill.
486
+ # The live list is the authority; gating on the reaps table would
487
+ # spare any session killed behind the ledger's back.
488
+ self.state.record_reap(ses.name)
489
+ log.info(
490
+ "reaped finished reviewer session %s (round %d done)",
491
+ ses.name, row["round"],
492
+ )
493
+
494
+ def _sweep_pr(self, repo_slug: str, number: int) -> PullRequest | None:
495
+ """One PR fetch for the sweep; the caller memoizes per distinct PR.
496
+
497
+ None = the fetch failed -- every session on that PR is spared this
498
+ pass and looked at again next poll. RateLimited propagates so
499
+ run_forever backs off instead of hammering the API once per session.
500
+ """
501
+ owner, _, repo = repo_slug.partition("/")
502
+ try:
503
+ return self.github.pull_request(owner, repo, number)
504
+ except RateLimited:
505
+ raise
506
+ except CommandError as exc:
507
+ log.warning("reap sweep: could not fetch %s#%d: %s", repo_slug, number, exc)
508
+ return None
509
+
510
+ def _completed_rounds(self, pr: PullRequest, task_ref: str | None) -> float | None:
511
+ """How many rounds of this PR are over, judged from GitHub/task state.
512
+
513
+ A closed or merged PR terminates every round, so it reports infinity.
514
+ Otherwise rounds completed = verdict envelopes on the review task (the
515
+ authoritative round record), addressed by the task ref the ledger
516
+ captured at spawn time -- NOT find_review_task, which would fetch the
517
+ whole task list and whose open-status filter loses validated tasks.
518
+ The substantive-review count is the fallback only for spawns recorded
519
+ before any review task existed. None means "could not tell" -- the
520
+ sweep spares the session and retries next poll.
521
+ """
522
+ if pr.is_terminal:
523
+ return float("inf")
524
+ if task_ref:
525
+ # count_verdicts never raises; unreadable evidence degrades to 0,
526
+ # which spares the session (round >= 1 > 0).
527
+ return self.alissa.count_verdicts(task_ref)
528
+ try:
529
+ return len(self.github.my_reviews(pr.owner, pr.repo, pr.number))
530
+ except RateLimited:
531
+ raise
532
+ except CommandError as exc:
533
+ log.warning("reap sweep: could not count reviews on %s: %s", pr.slug, exc)
534
+ return None
535
+
536
+ # -- actions -----------------------------------------------------------
537
+
538
+ def _spawn(
539
+ self, pr: PullRequest, round_: int, task: Task | None, *, reenqueued: bool = False
540
+ ) -> Decision:
541
+ if task is None:
542
+ if self.config.on_missing_review_task == ON_MISSING_SKIP:
543
+ return Decision(
544
+ Action.SKIPPED, "no open Alissa review task (CR2)", round_
545
+ )
546
+ log.warning(
547
+ "%s has no open Alissa review task (CR2) — spawning against the PR "
548
+ "URL; the reviewer must create or locate one before recording a verdict",
549
+ pr.slug,
550
+ )
551
+ assignment = (
552
+ f"Review the GitHub PR {pr.url} . There is no Alissa review task for "
553
+ f"it yet — locate the origin task from the PR and create the downstream "
554
+ f"review task per CR2 before recording your verdict."
555
+ )
556
+ else:
557
+ assignment = f"You've been assigned Alissa review task {task.ref}."
558
+
559
+ name = session_name(pr, round_)
560
+ template = ROUND_1_DIRECTIVE if round_ == 1 else ROUND_K_DIRECTIVE
561
+ directive = template.format(
562
+ assignment=assignment, round=round_, cap=self.config.round_cap, session=name
563
+ )
564
+
565
+ hub, problem = self._ensure_hub(pr)
566
+ if problem is not None:
567
+ return Decision(Action.SKIPPED, problem, round_)
568
+
569
+ self.alissa.enqueue_reviewer(
570
+ session=name,
571
+ directive=directive,
572
+ cwd=hub,
573
+ agent=self.config.agent_profile,
574
+ task_ref=task.ref if task else None,
575
+ dry_run=self.config.dry_run,
576
+ )
577
+
578
+ if not self.config.dry_run:
579
+ self.state.record_spawn(
580
+ repo=pr.full_name,
581
+ number=pr.number,
582
+ round_=round_,
583
+ head_sha=pr.head_sha,
584
+ session=name,
585
+ task_ref=task.ref if task else None,
586
+ )
587
+
588
+ # AFTER the enqueue on purpose: the activity comment is telemetry and
589
+ # must never gate the spawn it reports on.
590
+ context = (
591
+ "re-enqueued — previous session presumed dead" if reenqueued else "spawned"
592
+ )
593
+ self._append_activity(pr, self._activity_line(name, round_, context))
594
+
595
+ return Decision(
596
+ Action.SPAWNED,
597
+ f"session {name} → {task.ref if task else 'no task'}",
598
+ round_,
599
+ )
600
+
601
+ def _ensure_hub(self, pr: PullRequest) -> tuple[Path, str | None]:
602
+ """Resolve the reviewer's cwd, hub-ifying the repo first if configured.
603
+
604
+ Returns (hub, problem). `problem` is non-None when the round cannot run.
605
+ """
606
+ hub = self.config.hub_for(pr.owner, pr.repo)
607
+ if hub.is_dir():
608
+ return hub, None
609
+
610
+ if self.config.on_missing_hub != HUB_ADD:
611
+ return hub, (
612
+ f"no worktree hub at {hub} — add the repo with "
613
+ f"`alissa code workspace add {pr.full_name}`, or set "
614
+ f"on_missing_hub='add' (requires a repos allowlist)"
615
+ )
616
+
617
+ # Guarded twice: config.load() rejects 'add' without an allowlist, and
618
+ # poll_once() only reaches here for watched repos. Belt and braces --
619
+ # this path clones code onto the machine and opens it as an agent cwd.
620
+ if not self.config.watches(pr.full_name):
621
+ return hub, f"{pr.full_name} is not in the repos allowlist"
622
+
623
+ if not self.config.manifest_path.is_file():
624
+ return hub, (
625
+ f"{self.config.workspace_root} is not an Alissa Code Workspace "
626
+ f"(no alissa-workspace.yaml) — run `alissa code workspace init`"
627
+ )
628
+
629
+ try:
630
+ self.alissa.add_repo_to_workspace(
631
+ pr.owner,
632
+ pr.repo,
633
+ self.config.workspace_root,
634
+ dry_run=self.config.dry_run,
635
+ )
636
+ except CommandError as exc:
637
+ return hub, f"could not hub-ify {pr.full_name}: {exc}"
638
+
639
+ if self.config.dry_run:
640
+ return hub, None
641
+ if not hub.is_dir():
642
+ return hub, (
643
+ f"`alissa code workspace add {pr.full_name}` reported success but "
644
+ f"{hub} still does not exist — check hub_template against the "
645
+ f"manifest's `dir:` override"
646
+ )
647
+ return hub, None
648
+
649
+ def preflight(self) -> list[str]:
650
+ """Startup checks. Returns warnings; raises on anything fatal."""
651
+ warnings: list[str] = []
652
+
653
+ # Fatal: a mismatched identity silently breaks round counting.
654
+ login = self.github.verify_identity()
655
+ log.info("reviewing as GitHub user %s (from the gh token)", login)
656
+
657
+ if not self.config.workspace_root.is_dir():
658
+ warnings.append(f"workspace_root {self.config.workspace_root} does not exist")
659
+ elif not self.config.manifest_path.is_file():
660
+ warnings.append(
661
+ f"{self.config.workspace_root} has no alissa-workspace.yaml — it is "
662
+ f"not an Alissa Code Workspace yet (`alissa code workspace init`)"
663
+ )
664
+
665
+ if not self.config.dry_run and not self.alissa.worker_running():
666
+ warnings.append(
667
+ "`alissa worker` does not appear to be running — queued reviewer "
668
+ "sessions will not spawn until it is (`alissa worker start`)"
669
+ )
670
+
671
+ return warnings
672
+
673
+ def _activity_line(self, session: str, round_: int, context: str) -> str:
674
+ ts = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
675
+ return f"- {ts} — `{session}` — round {round_} of {self.config.round_cap} — {context}"
676
+
677
+ def _append_activity(self, pr: PullRequest, line: str) -> bool:
678
+ """Append one line to THE activity comment on the PR; True if it landed.
679
+
680
+ Find-or-create: the PR's issue comments are filtered to OWN authorship
681
+ AND the hidden marker, and the line is PATCH-appended to the first
682
+ match; with no match, one marker-carrying comment is created. A marker
683
+ pasted by anyone else fails the author filter and is never touched.
684
+
685
+ Best-effort by contract: this is telemetry about a spawn that has
686
+ already happened, so no failure here may surface to the caller. The
687
+ except is deliberately broad -- _api turns rate-limit errors into
688
+ RateLimited (not CommandError), and letting that fly out of evaluate()
689
+ would fail the whole poll pass over a log line.
690
+ """
691
+ if self.config.dry_run:
692
+ log.info("[dry-run] would append activity line on %s: %s", pr.slug, line)
693
+ return False
694
+ try:
695
+ mine = [
696
+ c
697
+ for c in self.github.issue_comments(pr.owner, pr.repo, pr.number)
698
+ if c.author == self.github.login and ACTIVITY_MARKER in c.body
699
+ ]
700
+ if mine:
701
+ self.github.update_comment(
702
+ pr.owner, pr.repo, mine[0].id, mine[0].body + "\n" + line
703
+ )
704
+ else:
705
+ self.github.comment(
706
+ pr.owner, pr.repo, pr.number, ACTIVITY_HEADER + "\n" + line
707
+ )
708
+ except Exception as exc:
709
+ log.warning("could not append activity line on %s: %s", pr.slug, exc)
710
+ return False
711
+ return True
712
+
713
+ def _escalate_stalled(
714
+ self, pr: PullRequest, round_: int, session: str, age: float
715
+ ) -> None:
716
+ """Operator ping when the liveness deferral itself runs long: the
717
+ session showing life is the only thing holding the respawn back, so
718
+ a human must check whether it is progressing or wedged. Posted once
719
+ per deferral EPISODE (the ping ledger row is keyed
720
+ stalled_kind(session); a re-enqueued round that stalls again is a
721
+ new session, so it pings again), and the row is recorded only AFTER
722
+ the comment posts: this ping is the operator's only signal for the
723
+ episode, so a transient comment failure must retry next poll --
724
+ exactly-once delivered, unlike the cap-out page (a terminal state,
725
+ recorded despite failure). The decision stays a deferral either way
726
+ -- this comments, it never respawns."""
727
+ body = STALLED_COMMENT.format(
728
+ round=round_,
729
+ minutes=int(age / 60),
730
+ stale=STALE_ROUND_SECONDS // 60,
731
+ session=session,
732
+ )
733
+ log.warning(
734
+ "STALLED %s round %d has been deferred %.0f min behind live session "
735
+ "%s — escalating to operator (once per episode)",
736
+ pr.slug,
737
+ round_,
738
+ age / 60,
739
+ session,
740
+ )
741
+
742
+ if self.config.dry_run:
743
+ log.info("[dry-run] would comment on %s:\n%s", pr.slug, body)
744
+ return
745
+
746
+ try:
747
+ self.github.comment(pr.owner, pr.repo, pr.number, body)
748
+ except CommandError as exc:
749
+ log.error(
750
+ "could not post the stalled-round comment on %s: %s — not "
751
+ "recording the episode; the ping retries next poll",
752
+ pr.slug,
753
+ exc,
754
+ )
755
+ return
756
+ self.state.record_ping(pr.full_name, pr.number, stalled_kind(session))
757
+
758
+ def _escalate(self, pr: PullRequest, last_state: str, rounds: int) -> None:
759
+ body = ESCALATION_COMMENT.format(
760
+ rounds=rounds, last_state=last_state.lower(), sha=pr.head_sha[:8]
761
+ )
762
+ log.error("CAP-OUT %s after %d rounds — escalating to operator", pr.slug, rounds)
763
+
764
+ if self.config.dry_run:
765
+ log.info("[dry-run] would comment on %s:\n%s", pr.slug, body)
766
+ return
767
+
768
+ try:
769
+ self.github.comment(pr.owner, pr.repo, pr.number, body)
770
+ except CommandError as exc:
771
+ log.error("could not post escalation comment on %s: %s", pr.slug, exc)
772
+ self.state.record_escalation(pr.full_name, pr.number, pr.head_sha)
773
+
774
+ # -- polling -----------------------------------------------------------
775
+
776
+ def poll_once(self) -> list[tuple[str, Decision]]:
777
+ # Sweep BEFORE evaluating: a full worker is exactly when a fresh spawn
778
+ # needs the slot a finished session is squatting on. Deliberately not
779
+ # inside the per-request loop below — the sweep must reach sessions
780
+ # whose PR no longer appears in the search at all.
781
+ self.sweep_sessions()
782
+
783
+ requests = self.github.review_requests(self.config.repos)
784
+ log.info("%d PR(s) with a review pending from %s", len(requests), self.github.login)
785
+
786
+ results = []
787
+ for owner, repo, number in requests:
788
+ slug = f"{owner}/{repo}#{number}"
789
+ if not self.config.watches(f"{owner}/{repo}"):
790
+ continue
791
+ try:
792
+ decision = self.evaluate(owner, repo, number)
793
+ except RateLimited:
794
+ raise
795
+ except CommandError as exc:
796
+ log.error("%s: %s", slug, exc)
797
+ decision = Decision(Action.SKIPPED, str(exc))
798
+
799
+ level = logging.INFO if decision.action != Action.SKIPPED else logging.DEBUG
800
+ log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
801
+ results.append((slug, decision))
802
+ return results
803
+
804
+ def run_forever(self) -> None:
805
+ # preflight() is the caller's responsibility -- the CLI runs it once for
806
+ # every mode, so calling it here too would double every check.
807
+ backoff = self.config.poll_interval
808
+ while True:
809
+ # The sleep lives INSIDE the KeyboardInterrupt guard: with a 60s
810
+ # poll interval (up to 900s backing off) the loop spends nearly
811
+ # all its wall-clock sleeping, so a real Ctrl-C almost always
812
+ # lands there and must hit the same clean-exit path.
813
+ try:
814
+ try:
815
+ self.poll_once()
816
+ backoff = self.config.poll_interval
817
+ except RateLimited as exc:
818
+ backoff = min(backoff * 2, 900)
819
+ log.warning("rate limited (%s) — backing off %ds", exc, backoff)
820
+ except CommandError as exc:
821
+ backoff = min(backoff * 2, 900)
822
+ log.error("poll failed: %s — retrying in %ds", exc, backoff)
823
+ time.sleep(backoff)
824
+ except KeyboardInterrupt:
825
+ log.info("stopping")
826
+ return