alissa-tools-github-reviewloop 0.8.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 (23) hide show
  1. {alissa_tools_github_reviewloop-0.8.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.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/loop.py +163 -2
  3. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/state.py +34 -0
  4. alissa_tools_github_reviewloop-0.9.0/src/main/alissa/tools/github/reviewloop/version +1 -0
  5. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0/src/main/alissa_tools_github_reviewloop.egg-info}/PKG-INFO +1 -1
  6. alissa_tools_github_reviewloop-0.8.0/src/main/alissa/tools/github/reviewloop/version +0 -1
  7. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/MANIFEST.in +0 -0
  8. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/README.md +0 -0
  9. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/requirements.txt +0 -0
  10. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/setup.cfg +0 -0
  11. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/setup.py +0 -0
  12. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/__init__.py +0 -0
  13. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/__main__.py +0 -0
  14. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/alissa.py +0 -0
  15. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/config.py +0 -0
  16. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/ghclient.py +0 -0
  17. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/proc.py +0 -0
  18. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/prreview.py +0 -0
  19. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa/tools/github/reviewloop/version.py +0 -0
  20. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa_tools_github_reviewloop.egg-info/SOURCES.txt +0 -0
  21. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa_tools_github_reviewloop.egg-info/dependency_links.txt +0 -0
  22. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.9.0}/src/main/alissa_tools_github_reviewloop.egg-info/entry_points.txt +0 -0
  23. {alissa_tools_github_reviewloop-0.8.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.8.0
3
+ Version: 0.9.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVIEWLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -27,9 +27,25 @@ 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
+
33
49
  # The sweep only reaps a session that has been idle AND quiet this long. The
34
50
  # GitHub review count increments the moment a review is submitted, but the
35
51
  # reviewer still has close-out work after that (CR6 envelope, task status) --
@@ -96,6 +112,38 @@ ESCALATION_COMMENT = (
96
112
  "Last verdict: `{last_state}` at `{sha}`."
97
113
  )
98
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
+
99
147
 
100
148
  class Action(str, Enum):
101
149
  SPAWNED = "spawned"
@@ -192,9 +240,13 @@ class ReviewWatcher:
192
240
  if age is not None and age < STALE_ROUND_SECONDS:
193
241
  return Decision(Action.IN_FLIGHT, f"round {round_} enqueued {int(age)}s ago", round_)
194
242
  if age is not None:
243
+ deferred = self._defer_stale_round(pr, round_, age)
244
+ if deferred is not None:
245
+ return deferred
195
246
  log.warning(
196
247
  "%s round %d has been in flight %.0f min with no submitted review "
197
- "— re-enqueuing (reviewer session presumed stalled)",
248
+ "and its session is gone or finished — re-enqueuing (reviewer "
249
+ "session presumed dead)",
198
250
  pr.slug,
199
251
  round_,
200
252
  age / 60,
@@ -202,6 +254,70 @@ class ReviewWatcher:
202
254
 
203
255
  return self._spawn(pr, round_, task)
204
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
+
205
321
  def _convergence_reason(
206
322
  self, my_reviews: list[Review], task: Task | None, head_sha: str
207
323
  ) -> str | None:
@@ -505,6 +621,51 @@ class ReviewWatcher:
505
621
 
506
622
  return warnings
507
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
+
508
669
  def _escalate(self, pr: PullRequest, last_state: str, rounds: int) -> None:
509
670
  body = ESCALATION_COMMENT.format(
510
671
  rounds=rounds, last_state=last_state.lower(), sha=pr.head_sha[:8]
@@ -44,6 +44,14 @@ CREATE TABLE IF NOT EXISTS reaps (
44
44
  session TEXT NOT NULL PRIMARY KEY,
45
45
  reaped_at INTEGER NOT NULL
46
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
+ );
47
55
  """
48
56
 
49
57
 
@@ -168,6 +176,32 @@ class State:
168
176
  )
169
177
  self._db.commit()
170
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
+
171
205
  def escalated(self, repo: str, number: int, head_sha: str) -> bool:
172
206
  row = self._db.execute(
173
207
  "SELECT 1 FROM escalations WHERE repo=? AND number=? AND head_sha=?",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-reviewloop
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVIEWLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera