alissa-tools-github-devloop 0.1.0__py3-none-any.whl → 0.3.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.
@@ -21,6 +21,61 @@ deferring: a session that died with its draft PR open must not be silent
21
21
  forever. The local
22
22
  ledger only counts attempts, ages the newest spawn so a dead session can
23
23
  be retried, and remembers which escalation kinds were already raised.
24
+
25
+ The same pass then runs the REVIEW-RESPONSE edge (fix_rounds_enabled): open
26
+ PRs authored by the token identity whose newest substantive review verdict is
27
+ request_changes, with no pending re-request and no push since that review --
28
+ the ball is demonstrably in the author's court and no session is answering.
29
+ Like the issue edge, the state is derived from GitHub fresh each pass (no
30
+ local PR list, no diffing); the fix_spawns ledger only dedupes one fix
31
+ session per (PR, round), ages it for retry, and caps attempts per round.
32
+ Fix-retry staleness follows the issue edge's two-signal doctrine: the timer
33
+ alone cannot tell a dead fix session from a live one still in its triage
34
+ phase (FIX_DIRECTIVE's load-bearing order puts every CR8 reply BEFORE the
35
+ first push), so the retry is deferred while the author's own review activity
36
+ postdates the review being answered -- the zero-body records those inline
37
+ replies create are the live session's footprint, read from the reviews list
38
+ already in hand. That deferral has the same floor as the issue edge's, and
39
+ needs it more: review records are immutable, so unlike a linked PR (which
40
+ closes) the author-activity signal NEVER clears -- a session that died
41
+ right after its triage replies latches the deferral forever, with the
42
+ retry and the cap-out (below the deferral) both unreachable. Past
43
+ STALLED_DEFER_MULTIPLE stale windows the loop posts one "fix stalled"
44
+ operator comment per deferral episode (per round AND attempt, so a later
45
+ episode pings again) and keeps deferring -- it never respawns over author
46
+ activity. A fix cap-out pages the operator ON the PR: in the capped
47
+ state there is provably no pending re-request, so the reviewer daemon
48
+ (whose only entry point is a review-requested:@me search) cannot see the PR
49
+ -- this edge is the last automation standing and must not stop silently. The
50
+ fix session's terminal act is the re-request, which clears the edge
51
+ server-side and re-arms the reviewer daemon -- and a LIVE dev session
52
+ blocking in alissa-pr-review holds the edge closed the same way, because its
53
+ own re-request is pending for exactly as long as it blocks. Verdicts mirror
54
+ reviewloop's semantics (the two daemons must never disagree): the GitHub
55
+ review state is authoritative where it can speak (APPROVED /
56
+ CHANGES_REQUESTED); COMMENTED-mode reviews defer to the CR6 verdict envelope
57
+ via an injectable reader, and with no readable verdict the edge stays closed
58
+ -- a fix session is never spawned on an ambiguous verdict.
59
+
60
+ Finally, every pass runs the SESSION REAPER: finished `develop-*`/`fix-*`
61
+ worker sessions otherwise idle in tmux forever (observed live on the reviewer
62
+ daemon, 2026-07-22 -- and dev workers are worse, because they block through
63
+ entire review loops). Both directives tell the session to kill itself as its
64
+ last act, but that self-kill is UNRELIABLE by assumption -- a session that
65
+ crashes, stalls, or is superseded never reaches its last act -- so the sweep
66
+ is the guarantee, not the fast path. The sweep is deliberately
67
+ SEARCH-INDEPENDENT, the reviewer daemon's structural lesson baked in: its
68
+ `_reap_finished` keyed off the poll's search results and starved -- it became
69
+ unreachable the moment the trigger state cleared, which is exactly when
70
+ sessions finish. Here the sweep runs unconditionally at the top of poll_once,
71
+ BEFORE the searches, off the tmux list and the local ledgers alone: it lists
72
+ `develop-*`/`fix-*` sessions via `alissa tmux ls`, cross-references each
73
+ against the spawn/fix ledgers and GitHub state (issue closed / PR closed or
74
+ merged / attempt or round superseded), and kills the idle finished ones via
75
+ `alissa tmux kill <name>` -- one session at a time, never the server. Busy
76
+ sessions and fresh in-flight ones (ledger row younger than stale_minutes) are
77
+ spared, dry-run only logs, and kills are best-effort: a CLI without
78
+ `tmux kill` degrades to a once-per-process warning.
24
79
  """
25
80
 
26
81
  from __future__ import annotations
@@ -28,13 +83,24 @@ from __future__ import annotations
28
83
  import logging
29
84
  import re
30
85
  import time
86
+ from collections import Counter
31
87
  from dataclasses import dataclass
32
88
  from enum import Enum
33
89
  from pathlib import Path
90
+ from typing import Callable
34
91
 
35
92
  from .alissa import Alissa
36
93
  from .config import HUB_ADD, ON_MISSING_SKIP, ON_MISSING_WARN, Config
37
- from .ghclient import AssignmentRejected, GitHub, Issue, RateLimited, parse_task_ref
94
+ from .ghclient import (
95
+ SUBMITTED_STATES,
96
+ AssignmentRejected,
97
+ GitHub,
98
+ Issue,
99
+ PullRequest,
100
+ RateLimited,
101
+ Review,
102
+ parse_task_ref,
103
+ )
38
104
  from .proc import CommandError
39
105
  from .state import State
40
106
 
@@ -64,18 +130,56 @@ DEV_DIRECTIVE = (
64
130
  "self-contained spec and its acceptance criteria are authoritative. "
65
131
  "{task_instruction} "
66
132
  "Golden path: your cwd is the repo's worktree hub ROOT — create a task "
67
- "worktree with `git worktree add TASK-<n>-<DESC>` from there (one task = "
68
- "one branch = one worktree; never clone, never work directly on main). "
133
+ "worktree with `git worktree add TASK-<n>-<DESC>`, where <n> is YOUR "
134
+ "IMPLEMENTATION task's number (the downstream task YOU created, NOT the "
135
+ "origin task's — first-run lesson: a worker branch named after the "
136
+ "origin collides with every sibling of that origin), from there (one "
137
+ "task = one branch = one worktree; never clone, never work directly on "
138
+ "main). "
69
139
  "Implement to the issue's acceptance criteria and VERIFY locally (run the "
70
140
  "repo's test/style/type scripts) before publishing. Open a DRAFT PR whose "
71
- "body contains `Closes #{number}` and the task ref, and attach the PR URL "
72
- "to the task as evidence. Then drive the review handoff with the "
73
- "alissa-pr-review skill, requesting reviewer(s): {reviewers}. In every "
74
- "review round the ORDER is load-bearing: post your CR8 triage replies "
75
- "FIRST, then push the fixes and re-request review the review daemon "
76
- "re-enters on head movement, so pushing before the triage lands orphans "
77
- "your replies. NEVER merge, never push to main, and do NOT create further "
78
- "ali-* sessions this loop is depth-1."
141
+ "body contains `Closes #{number}` and BOTH task refs the origin task "
142
+ "AND your implementation task: the PR body is the canonical cross-actor "
143
+ "record, and each actor's tasks live in its own partition — and attach "
144
+ "the PR URL to the task as evidence. Then drive the review handoff with "
145
+ "the alissa-pr-review skill, requesting reviewer(s): {reviewers}. In "
146
+ "every review round the ORDER is load-bearing: post your CR8 triage "
147
+ "replies FIRST, then push the fixes and re-request review the review "
148
+ "daemon re-enters on head movement, so pushing before the triage lands "
149
+ "orphans your replies. NEVER merge, never push to main, and do NOT "
150
+ "create further ali-* sessions — this loop is depth-1. Once your task is "
151
+ "pending_validation and the handoff is complete, your last act: run "
152
+ "`alissa tmux kill {session}` to end this session and free the machine "
153
+ "(best-effort — the daemon's reaper sweep collects stragglers)."
154
+ )
155
+
156
+ FIX_DIRECTIVE = (
157
+ "You are an IMPLEMENTER in a fix round, not a reviewer. PR {pr_url} "
158
+ "received a `request_changes` review (round {round}; this is fix attempt "
159
+ "{attempt} of {cap}) and no developer session is answering it — you are "
160
+ "the fresh triage-and-fix session. The PR body carries BOTH task refs "
161
+ "(origin + implementation): locate YOUR implementation task from it and "
162
+ "keep its status honest — in_progress while you work, pending_validation "
163
+ "at handoff. If a ref is missing from the PR body, restoring both is part "
164
+ "of your job: the PR body is the canonical cross-actor record. "
165
+ "Golden path: your cwd is the repo's worktree hub ROOT and the PR's head "
166
+ "branch `{head_ref}` already exists — re-attach its worktree with `git "
167
+ "worktree add {head_ref} {head_ref}` if it is missing, and work ONLY on "
168
+ "that branch (never a new branch, never a clone, never main). "
169
+ "FIRST verify the triage of every prior review finding per CR8: exactly "
170
+ "one reasoned `[triage:*]` reply on each finding's thread — post any that "
171
+ "are missing. Then fix the pursued findings on the same branch and VERIFY "
172
+ "locally (run the repo's test/style/type scripts). The ORDER is "
173
+ "load-bearing: triage replies FIRST, then push the fixes, then re-request "
174
+ "review from {reviewer} (`gh pr edit {number} --add-reviewer {reviewer}`) "
175
+ "— the review daemon re-enters on head movement, so pushing before the "
176
+ "triage lands orphans your replies, and the RE-REQUEST IS YOUR TERMINAL "
177
+ "STEP: it clears this fix edge server-side and re-arms the reviewer "
178
+ "daemon, so set your task pending_validation before it and do nothing "
179
+ "after it except your last act: run `alissa tmux kill {session}` to end "
180
+ "this session and free the machine (best-effort — the daemon's reaper "
181
+ "sweep collects stragglers). NEVER merge, never push to main, and do NOT "
182
+ "create further ali-* sessions — this loop is depth-1."
79
183
  )
80
184
 
81
185
  ESCALATION_COMMENT = (
@@ -96,6 +200,29 @@ STALLED_COMMENT = (
96
200
  "attempt while the PR is open."
97
201
  )
98
202
 
203
+ FIX_CAP_COMMENT = (
204
+ "**Fix loop cap-out** — {attempts} fix attempt(s) were spawned on review "
205
+ "round {round} of this PR without any of them completing the round "
206
+ "(attempt cap: {cap}). The daemon will not spawn another for this round, "
207
+ "and no other automation can reach the PR in this state: with no pending "
208
+ "re-request the reviewer daemon's `review-requested:@me` search cannot "
209
+ "see it. This needs an operator decision: finish the round by hand "
210
+ "(CR8 triage replies, fixes, re-request review), raise `attempt_cap` to "
211
+ "re-enter the loop, or close the PR to park the work."
212
+ )
213
+
214
+ FIX_STALLED_COMMENT = (
215
+ "**Fix loop stalled?** — fix attempt {attempts} on review round {round} "
216
+ "has been in flight {minutes} min. The author's own triage replies "
217
+ "postdate the review being answered, so the daemon keeps deferring the "
218
+ "retry — but review records never expire, so a fix session that died "
219
+ "AFTER posting its replies looks alive forever: is that session alive? "
220
+ "The daemon will keep deferring (it never respawns over author "
221
+ "activity). Operator options: check (and if dead, kill) the fix "
222
+ "session and finish the round by hand (fixes, re-request review), or "
223
+ "close the PR to park the work."
224
+ )
225
+
99
226
  ASSIGNMENT_REJECTED_COMMENT = (
100
227
  "**Dev loop cannot start** — the daemon tried to self-assign this issue "
101
228
  "as its in-flight marker, but GitHub silently dropped the assignment: "
@@ -115,6 +242,50 @@ ASSIGNMENT_REJECTED_COMMENT = (
115
242
  ESCALATION_CAP = "cap"
116
243
  ESCALATION_ASSIGNMENT = "assignment"
117
244
  ESCALATION_STALLED = "stalled"
245
+ ESCALATION_FIX_CAP = "fix-cap"
246
+ ESCALATION_FIX_STALLED = "fix-stalled"
247
+
248
+ # CR6 verdict words, spelled exactly as reviewloop's alissa.py spells them --
249
+ # the envelope tie-breaker (see DevWatcher.envelope_verdict) must return
250
+ # these strings, so the two daemons read one vocabulary.
251
+ VERDICT_APPROVE = "approve"
252
+ VERDICT_REQUEST_CHANGES = "request_changes"
253
+
254
+
255
+ def fix_cap_kind(round_: int) -> str:
256
+ """The ledger kind that dedupes ONE round's fix cap-out operator page.
257
+
258
+ Folded per round like stalled_kind: round k capping out must not silence
259
+ round k+1's budget (a fresh review round re-opens the edge with fresh
260
+ attempts), and the `fix-cap` prefix keeps PR rows from ever colliding
261
+ with issue-edge kinds when an issue and a PR share a number. The row
262
+ dedupes FIX_CAP_COMMENT to once per round — commenting, not just
263
+ recording, because the capped state has provably NO pending re-request
264
+ (the guard above the cap check returns first otherwise), and reviewloop's
265
+ only entry point is a `review-requested:@me` search: the two conditions
266
+ are mutually exclusive by construction, so at cap-out the PR is invisible
267
+ to the reviewer daemon and this comment is the operator's only signal.
268
+ """
269
+ return f"{ESCALATION_FIX_CAP}:r{round_}"
270
+
271
+
272
+ def fix_stalled_kind(round_: int, attempt: int) -> str:
273
+ """The ledger kind for ONE fix-deferral episode's stalled ping.
274
+
275
+ stalled_kind's episode reasoning, transposed: the author-activity
276
+ deferral can latch on every attempt of every round (any fix session can
277
+ die after its triage replies), and episode (k, j)'s ping must not
278
+ silence a later one -- a fresh round re-arms the edge with fresh
279
+ attempts, and a later attempt of the same round can stall behind its
280
+ own replies after attempt 1 stalled behind its. So the round AND the
281
+ attempt both fold into the key (`fix-stalled:r<k>a<j>`); the `fix-`
282
+ prefix keeps PR rows from ever colliding with issue-edge stalled rows
283
+ when an issue and a PR share a number. Delivery contract as
284
+ stalled_kind's: the row lands only AFTER the comment posts, so a
285
+ transient failure retries next poll and the ping lands exactly once per
286
+ episode.
287
+ """
288
+ return f"{ESCALATION_FIX_STALLED}:r{round_}a{attempt}"
118
289
 
119
290
 
120
291
  def stalled_kind(attempt: int) -> str:
@@ -125,10 +296,12 @@ def stalled_kind(attempt: int) -> str:
125
296
  keyed on the bare kind, the TERMINAL attempt stalling would defer
126
297
  silently forever, with the cap-out unreachable behind the deferral.
127
298
  `kind` is free-form TEXT, so the attempt number folds into the key (no
128
- schema change): one ping per (issue, attempt). A side benefit: the
129
- record-even-if-the-comment-fails contract now costs at most one lost
130
- ping per EPISODE, where a bare-kind row would have silenced the issue
131
- permanently after a single transient comment failure.
299
+ schema change): one ping per (issue, attempt). Delivery contract: unlike
300
+ cap/assignment (terminal-state pages, recorded despite a failed comment
301
+ so the spawn stop is never retried every poll), a stalled row is
302
+ recorded only AFTER the comment posts -- the ping is the operator's ONLY
303
+ signal for this episode, so a transient failure retries next poll and
304
+ the ping lands exactly once.
132
305
  """
133
306
  return f"{ESCALATION_STALLED}:a{attempt}"
134
307
 
@@ -177,6 +350,51 @@ def session_name(issue: Issue, attempt: int) -> str:
177
350
  return f"develop-{owner}-{repo}-i{issue.number}-a{attempt}"
178
351
 
179
352
 
353
+ def fix_session_name(pr: PullRequest, round_: int, attempt: int) -> str:
354
+ """A tmux-safe fix-session name:
355
+ `fix-<owner>-<repo>-pr<n>-r<round>-a<attempt>`.
356
+
357
+ Deterministic like session_name (no nonce): the attempt number comes from
358
+ the fix ledger's per-round row count, so every spawn gets a fresh name.
359
+ The owner is folded in for the same reason as the issue edge (the P3
360
+ session-name lesson): fix counts are keyed per `owner/repo`, so two
361
+ owners' PR #16 can both sit at round 1 attempt 1, and a repo-only name
362
+ would collide.
363
+ """
364
+ owner = re.sub(r"[^A-Za-z0-9-]", "-", pr.owner).strip("-").lower()
365
+ repo = re.sub(r"[^A-Za-z0-9-]", "-", pr.repo).strip("-").lower()
366
+ return f"fix-{owner}-{repo}-pr{pr.number}-r{round_}-a{attempt}"
367
+
368
+
369
+ def no_envelope_verdict(owner: str, repo: str, number: int) -> "str | None":
370
+ """The default (stub) envelope tie-breaker: no verdict is ever readable.
371
+
372
+ COMMENTED-mode reviews cannot express a verdict in GitHub state; the
373
+ verdict of record then lives in the CR6 envelope on the Alissa review
374
+ task (reviewloop's `Alissa.latest_verdict` reads it). This stub is
375
+ permanent BY POLICY, not a scope deferral: the reader is blocked on
376
+ cross-actor task visibility -- the three daemon identities deliberately
377
+ do not share Alissa actors (least-privilege plus review independence,
378
+ operator decision 2026-07-22), so the developer identity cannot read
379
+ the reviewer's review task at all (GET returns 404, downstream POST
380
+ 403 NOT_OWNER). TODO(blocked on cross-actor observer grants -- a
381
+ Studio-side platform ask): when that grant lands, this seam takes a
382
+ real envelope reader -- semantics per reviewloop's
383
+ alissa.tools.github.reviewloop.alissa.Alissa.latest_verdict (the CR2
384
+ review task by title `Review PR <owner>/<repo>#<n>`, newest CR6
385
+ `Review verdict: ... — <word>` evidence envelope), so COMMENTED-mode
386
+ reviewers close this edge exactly like they close reviewloop's. Until
387
+ then a COMMENTED-only history conservatively reads as NO verdict (the
388
+ edge never spawns on an ambiguous verdict) -- which also means a
389
+ substantive COMMENTED follow-up landing after a CHANGES_REQUESTED
390
+ demotes that round to no-verdict and closes the edge: accepted as the
391
+ conservative side of the same policy (never spawn on a guess); the
392
+ round stays operator-recoverable (the reviewer re-requests or an
393
+ operator drives it), and the observer-grant reader above is the fix.
394
+ """
395
+ return None
396
+
397
+
180
398
  class DevWatcher:
181
399
  def __init__(
182
400
  self,
@@ -184,11 +402,19 @@ class DevWatcher:
184
402
  github: GitHub | None = None,
185
403
  alissa: Alissa | None = None,
186
404
  state: State | None = None,
405
+ envelope_verdict: "Callable[[str, str, int], str | None] | None" = None,
187
406
  ):
188
407
  self.config = config
189
408
  self.github = github or GitHub(config.developer_login)
190
409
  self.alissa = alissa or Alissa()
191
410
  self.state = state or State(config.state_db)
411
+ # The COMMENTED-mode verdict tie-breaker (see no_envelope_verdict):
412
+ # injectable so the future Alissa-envelope reader is one constructor
413
+ # argument, not a rewrite of the verdict logic.
414
+ self.envelope_verdict = envelope_verdict or no_envelope_verdict
415
+ # The reaper's warn-once latch for a CLI without `tmux kill` (kills
416
+ # are best-effort; the condition is per-process, not per-session).
417
+ self._kill_warned = False
192
418
 
193
419
  # -- per-issue decision ------------------------------------------------
194
420
 
@@ -308,7 +534,7 @@ class DevWatcher:
308
534
  # -- actions -----------------------------------------------------------
309
535
 
310
536
  def _spawn(self, issue: Issue, attempt: int, task_number: int | None) -> Decision:
311
- hub, problem = self._ensure_hub(issue)
537
+ hub, problem = self._ensure_hub(issue.owner, issue.repo)
312
538
  if problem is not None:
313
539
  return Decision(Action.SKIPPED, problem, attempt)
314
540
 
@@ -322,6 +548,7 @@ class DevWatcher:
322
548
  "none resolved — follow the alissa-pr-review skill's reviewer "
323
549
  "resolution"
324
550
  )
551
+ name = session_name(issue, attempt)
325
552
  directive = DEV_DIRECTIVE.format(
326
553
  issue_url=issue.url,
327
554
  number=issue.number,
@@ -329,8 +556,8 @@ class DevWatcher:
329
556
  cap=self.config.attempt_cap,
330
557
  task_instruction=task_instruction,
331
558
  reviewers=reviewers,
559
+ session=name,
332
560
  )
333
- name = session_name(issue, attempt)
334
561
 
335
562
  # assign_self FIRST: the assignment is the server-side in-flight
336
563
  # marker, so it must land before anything is enqueued -- a crash
@@ -405,7 +632,11 @@ class DevWatcher:
405
632
  being open is the only thing holding the retry (and the cap-out)
406
633
  back, so a human must check whether the session behind it is still
407
634
  alive. Posted once per deferral EPISODE (the ledger row is keyed
408
- stalled_kind(attempts), so a later attempt stalling pings again).
635
+ stalled_kind(attempts), so a later attempt stalling pings again),
636
+ and the row is recorded only AFTER the comment posts: this ping is
637
+ the operator's only signal for the episode, so a transient comment
638
+ failure must retry next poll -- exactly once delivered, unlike the
639
+ cap/assignment pages (terminal states, recorded despite failure).
409
640
  The decision stays IN_FLIGHT either way -- this comments, it never
410
641
  respawns."""
411
642
  body = STALLED_COMMENT.format(
@@ -428,10 +659,12 @@ class DevWatcher:
428
659
  self.github.comment(issue.owner, issue.repo, issue.number, body)
429
660
  except CommandError as exc:
430
661
  log.error(
431
- "could not post the stalled-deferral comment on %s: %s",
662
+ "could not post the stalled-deferral comment on %s: %s"
663
+ "not recording the episode; the ping retries next poll",
432
664
  issue.issue_slug,
433
665
  exc,
434
666
  )
667
+ return
435
668
  self.state.record_escalation(
436
669
  issue.full_name, issue.number, stalled_kind(attempts)
437
670
  )
@@ -458,18 +691,391 @@ class DevWatcher:
458
691
  )
459
692
  self.state.record_escalation(issue.full_name, issue.number, ESCALATION_CAP)
460
693
 
461
- def _ensure_hub(self, issue: Issue) -> tuple[Path, str | None]:
462
- """Resolve the developer's cwd (the hub ROOT), hub-ifying the repo
463
- first if configured. Returns (hub, problem); `problem` is non-None
464
- when the spawn cannot run."""
465
- hub = self.config.hub_for(issue.owner, issue.repo)
694
+ # -- the review-response edge (fix rounds) -----------------------------
695
+
696
+ def evaluate_pr(self, owner: str, repo: str, number: int) -> Decision:
697
+ """Is a fix session owed on this PR? The edge is TRUE only when ALL
698
+ hold: the PR is open, non-draft, authored by the token identity; a
699
+ substantive review by the reviewer set exists; its newest verdict
700
+ reads request_changes; NO re-request is pending (the ball would be in
701
+ a reviewer's court -- and a live dev session blocking in
702
+ alissa-pr-review holds its own re-request pending, which is what
703
+ keeps this edge from double-spawning over it); and NO push landed
704
+ after that review (a push means an implementer is already answering
705
+ it). Everything is derived fresh from GitHub -- the ledger only
706
+ dedupes, ages, and caps the spawn itself."""
707
+ pr = self.github.pull_request(owner, repo, number)
708
+
709
+ # Closed/merged between search and fetch: nothing left to fix.
710
+ if pr.state != "open":
711
+ return Decision(Action.SKIPPED, "PR closed between search and fetch")
712
+
713
+ # The search carries draft:false, but a PR can flip back to draft
714
+ # between search and fetch -- and a draft is out of review by
715
+ # definition (same belt-and-braces as reviewloop's CR1 re-check).
716
+ if pr.draft:
717
+ return Decision(Action.SKIPPED, "PR is a draft — not in review")
718
+
719
+ # Defense in depth: the search is author:@me already, but a foreign
720
+ # PR must never grow a fix session pushing to its branch.
721
+ if pr.author != self.github.login:
722
+ return Decision(
723
+ Action.SKIPPED,
724
+ f"PR author is {pr.author!r}, not the developer identity — "
725
+ f"not this daemon's to fix",
726
+ )
727
+
728
+ # A pending re-request means a review round is in flight server-side:
729
+ # either a live session already re-requested (its terminal step) or
730
+ # the reviewer daemon is being awaited. Either way the ball is NOT in
731
+ # the author's court. This is also the tier-1 interplay guard: a dev
732
+ # session blocking in alissa-pr-review keeps its re-request pending
733
+ # for as long as it blocks, so the edge stays closed under it.
734
+ if pr.requested_reviewers:
735
+ return Decision(
736
+ Action.SKIPPED,
737
+ f"review re-request pending "
738
+ f"({', '.join(pr.requested_reviewers)}) — the ball is in the "
739
+ f"reviewer's court",
740
+ )
741
+
742
+ # One reviews() fetch serves both the round arithmetic (the filtered
743
+ # reviewer-set view) and the liveness probe below (the raw records,
744
+ # author's own included) — the probe must cost no extra API call.
745
+ records = self.github.reviews(pr.owner, pr.repo, pr.number)
746
+ reviews = self._reviewer_reviews(pr, records)
747
+ if not reviews:
748
+ return Decision(
749
+ Action.SKIPPED,
750
+ "no substantive review by the reviewer set yet — nothing to "
751
+ "respond to",
752
+ )
753
+
754
+ # A push newer than the newest review means SOMEONE is already
755
+ # answering it (a live session mid-fix-round, or a human). Checked
756
+ # the way reviewloop binds verdicts to heads: the review's commit_id
757
+ # is the head it reviewed, so commit_id != head_sha means the head
758
+ # moved after the review. A falsy commit_id (older records lack one)
759
+ # cannot be checked and falls through, same as reviewloop.
760
+ newest = reviews[-1]
761
+ if newest.commit_id and newest.commit_id != pr.head_sha:
762
+ return Decision(
763
+ Action.SKIPPED,
764
+ f"head {pr.head_sha[:8]} is newer than the reviewed commit "
765
+ f"{newest.commit_id[:8]} — an implementer is already active",
766
+ )
767
+
768
+ verdict = self._verdict(pr, newest)
769
+ if verdict == VERDICT_APPROVE:
770
+ return Decision(
771
+ Action.SKIPPED, "verdict is approve — the merge is the operator's"
772
+ )
773
+ if verdict != VERDICT_REQUEST_CHANGES:
774
+ # COMMENTED/DISMISSED with no readable envelope: ambiguous, and a
775
+ # fix session is never spawned on an ambiguous verdict.
776
+ return Decision(
777
+ Action.SKIPPED,
778
+ f"newest review state {newest.state} carries no readable "
779
+ f"verdict (no envelope) — conservative no-op",
780
+ )
781
+
782
+ # One completed round per substantive review (reviewloop's round
783
+ # arithmetic): the count IS the round this fix session answers.
784
+ round_ = len(reviews)
785
+ attempts = self.state.fix_attempt_count(pr.full_name, number, round_)
786
+ age = self.state.fix_spawn_age(pr.full_name, number, round_)
787
+
788
+ if age is not None and age < self.config.stale_minutes * 60:
789
+ return Decision(
790
+ Action.IN_FLIGHT,
791
+ f"fix attempt {attempts} for round {round_} enqueued "
792
+ f"{int(age)}s ago",
793
+ attempts,
794
+ )
795
+
796
+ # Fix-retry staleness needs TWO signals, the issue edge's doctrine
797
+ # verbatim (see the linked-PR probe in evaluate()): the ledger timer
798
+ # says the newest fix spawn is old, but elapsed time alone cannot
799
+ # tell a dead fix session from a live one still in its triage phase
800
+ # -- FIX_DIRECTIVE's load-bearing order puts every CR8 reply BEFORE
801
+ # the first push, so for that whole phase the head has not moved and
802
+ # the timer is the only thing speaking. A respawn over a live session
803
+ # lands on the SAME branch in the SAME worktree, and CR8 replies are
804
+ # not idempotent (duplicate triage bounces the next round). The
805
+ # second signal is already in hand: each inline triage reply creates
806
+ # a zero-body review record authored by the PR author, so author
807
+ # activity newer than the review being answered means a session is
808
+ # demonstrably triaging -- defer the retry (and the cap-out behind
809
+ # it), exactly as the issue edge defers behind a linked open PR. No
810
+ # author activity + stale falls through to the respawn below.
811
+ # The deferral is floored, not unbounded, and needs the floor MORE
812
+ # than the issue edge does: a linked PR closes when the work dies,
813
+ # but review records are immutable -- once the session is gone its
814
+ # triage replies latch this deferral forever, with the respawn AND
815
+ # the cap-out below both unreachable. Past STALLED_DEFER_MULTIPLE
816
+ # stale windows the loop posts one "fix stalled" operator comment
817
+ # per deferral episode (fix_stalled_kind: per round AND attempt)
818
+ # and keeps deferring -- it never respawns over author activity.
819
+ if age is not None:
820
+ author_activity = any(
821
+ r.author == pr.author and r.submitted_at > newest.submitted_at
822
+ for r in records
823
+ )
824
+ if author_activity:
825
+ stalled_after = STALLED_DEFER_MULTIPLE * self.config.stale_minutes * 60
826
+ if age >= stalled_after and not self.state.escalated(
827
+ pr.full_name, number, fix_stalled_kind(round_, attempts)
828
+ ):
829
+ self._escalate_fix_stalled(pr, round_, attempts, age)
830
+ return Decision(
831
+ Action.IN_FLIGHT,
832
+ f"fix attempt {attempts} for round {round_} is stale "
833
+ f"({int(age / 60)} min) but the author's own review "
834
+ f"activity postdates the review being answered — a live "
835
+ f"session is already triaging (its CR8 replies are the "
836
+ f"footprint); not respawning",
837
+ attempts,
838
+ )
839
+
840
+ # The per-round budget ran dry: stop and page the operator ON the
841
+ # PR. This edge cannot defer to the reviewer daemon here -- its only
842
+ # entry point is a `review-requested:@me` search, and this branch is
843
+ # reachable only with NO pending re-request (the guard above returns
844
+ # first otherwise), so at cap-out the PR is invisible to reviewloop
845
+ # and FIX_CAP_COMMENT is the operator's only signal. Deduped by the
846
+ # fix-cap:r<k> row: once per round.
847
+ if attempts >= self.config.attempt_cap:
848
+ kind = fix_cap_kind(round_)
849
+ if self.state.escalated(pr.full_name, number, kind):
850
+ return Decision(
851
+ Action.CAPPED, f"round {round_} already capped", attempts
852
+ )
853
+ self._escalate_fix_cap(pr, round_, attempts)
854
+ return Decision(
855
+ Action.ESCALATED,
856
+ f"{attempts} fix attempts on round {round_}, cap "
857
+ f"{self.config.attempt_cap} — operator paged on the PR (no "
858
+ f"pending re-request, so the reviewer daemon cannot see it)",
859
+ attempts,
860
+ )
861
+
862
+ if age is not None:
863
+ log.warning(
864
+ "%s fix attempt %d (round %d) has been in flight %.0f min "
865
+ "with no re-request — re-enqueuing as attempt %d (fix "
866
+ "session presumed dead)",
867
+ pr.pr_slug,
868
+ attempts,
869
+ round_,
870
+ age / 60,
871
+ attempts + 1,
872
+ )
873
+
874
+ return self._spawn_fix(pr, round_, attempts + 1, newest)
875
+
876
+ def _reviewer_reviews(
877
+ self, pr: PullRequest, records: list[Review]
878
+ ) -> list[Review]:
879
+ """Substantive submitted reviews by the reviewer set, oldest first --
880
+ one per completed round, exactly reviewloop's `my_reviews` rule
881
+ (SUBMITTED_STATES, empty bodies dropped as inline-comment artifacts,
882
+ sorted by submitted_at) so the two daemons count the same rounds.
883
+ `records` is the raw fetched list -- fetched ONCE by evaluate_pr,
884
+ which also feeds the unfiltered records to the liveness probe.
885
+
886
+ Reviewloop filters to ITS OWN login; the mirror image here is the
887
+ configured reviewers (casefolded -- the set is operator-typed, and
888
+ GitHub logins are case-insensitive). With no reviewers resolved the
889
+ filter falls back to any non-author reviewer: the reviewer set is
890
+ unknown, and a substantive non-author review is still a round. The
891
+ author's own records never count on either side.
892
+ """
893
+ allowed = {r.casefold() for r in self.config.reviewers}
894
+ mine = [
895
+ r
896
+ for r in records
897
+ if r.author
898
+ and r.author != pr.author
899
+ and r.state in SUBMITTED_STATES
900
+ and r.is_substantive
901
+ and (not allowed or r.author.casefold() in allowed)
902
+ ]
903
+ return sorted(mine, key=lambda r: r.submitted_at)
904
+
905
+ def _escalate_fix_cap(self, pr: PullRequest, round_: int, attempts: int) -> None:
906
+ """Operator page for a fix cap-out, posted ON the PR (the issue
907
+ edge's _escalate shape): log, comment (dry-run gated), and record
908
+ the fix-cap:r<k> dedupe row even if the comment fails -- at most one
909
+ lost page per round, the same trade every other escalation makes."""
910
+ body = FIX_CAP_COMMENT.format(
911
+ attempts=attempts, round=round_, cap=self.config.attempt_cap
912
+ )
913
+ log.error(
914
+ "FIX CAP-OUT %s round %d after %d fix attempt(s) — escalating to "
915
+ "operator on the PR (no pending re-request, so the reviewer "
916
+ "daemon cannot see it)",
917
+ pr.pr_slug,
918
+ round_,
919
+ attempts,
920
+ )
921
+
922
+ if self.config.dry_run:
923
+ log.info("[dry-run] would comment on %s:\n%s", pr.pr_slug, body)
924
+ return
925
+
926
+ try:
927
+ self.github.comment(pr.owner, pr.repo, pr.number, body)
928
+ except CommandError as exc:
929
+ log.error(
930
+ "could not post the fix cap-out comment on %s: %s",
931
+ pr.pr_slug,
932
+ exc,
933
+ )
934
+ self.state.record_escalation(pr.full_name, pr.number, fix_cap_kind(round_))
935
+
936
+ def _escalate_fix_stalled(
937
+ self, pr: PullRequest, round_: int, attempts: int, age: float
938
+ ) -> None:
939
+ """Operator ping when the author-activity deferral itself runs long
940
+ (_escalate_stalled's shape): the author's own triage replies are the
941
+ only thing holding the retry (and the cap-out) back, and because
942
+ review records are immutable that signal never clears on its own --
943
+ a human must check whether the session behind the replies is still
944
+ alive. Posted once per deferral EPISODE (the ledger row is keyed
945
+ fix_stalled_kind(round_, attempts), so a later episode pings
946
+ again); the row is recorded only AFTER the comment posts, the same
947
+ exactly-once delivery contract as _escalate_stalled -- a transient
948
+ failure retries next poll. The decision stays IN_FLIGHT either way
949
+ -- this comments, it never respawns."""
950
+ body = FIX_STALLED_COMMENT.format(
951
+ attempts=attempts, round=round_, minutes=int(age / 60)
952
+ )
953
+ log.warning(
954
+ "FIX STALLED %s fix attempt %d (round %d) has been deferred "
955
+ "%.0f min behind the author's own triage replies — escalating "
956
+ "to operator (once per episode)",
957
+ pr.pr_slug,
958
+ attempts,
959
+ round_,
960
+ age / 60,
961
+ )
962
+
963
+ if self.config.dry_run:
964
+ log.info("[dry-run] would comment on %s:\n%s", pr.pr_slug, body)
965
+ return
966
+
967
+ try:
968
+ self.github.comment(pr.owner, pr.repo, pr.number, body)
969
+ except CommandError as exc:
970
+ log.error(
971
+ "could not post the fix stalled-deferral comment on %s: %s — "
972
+ "not recording the episode; the ping retries next poll",
973
+ pr.pr_slug,
974
+ exc,
975
+ )
976
+ return
977
+ self.state.record_escalation(
978
+ pr.full_name, pr.number, fix_stalled_kind(round_, attempts)
979
+ )
980
+
981
+ def _verdict(self, pr: PullRequest, newest: Review) -> "str | None":
982
+ """The newest review's verdict, mirroring reviewloop's precedence:
983
+ the GitHub review state is authoritative where it can speak, and
984
+ COMMENTED-mode reviews (plus DISMISSED records, which erased their
985
+ state) defer to the CR6 envelope tie-breaker. None means no readable
986
+ verdict."""
987
+ if newest.state == "APPROVED":
988
+ return VERDICT_APPROVE
989
+ if newest.state == "CHANGES_REQUESTED":
990
+ return VERDICT_REQUEST_CHANGES
991
+ return self.envelope_verdict(pr.owner, pr.repo, pr.number)
992
+
993
+ def _spawn_fix(
994
+ self, pr: PullRequest, round_: int, attempt: int, review: Review
995
+ ) -> Decision:
996
+ # head_ref is interpolated into FIX_DIRECTIVE as both a worktree
997
+ # path and a shell token (`git worktree add {head_ref} {head_ref}`),
998
+ # and a PR's head branch is not strictly self-authored (anyone with
999
+ # push access can move it). This daemon only ever creates flat
1000
+ # `TASK-<n>-<DESC>` branches, so a ref outside that shape -- slashes
1001
+ # that nest the worktree path, whitespace or shell metacharacters
1002
+ # that reach an agent prompt -- is not this daemon's work anyway:
1003
+ # skip conservatively rather than sanitize.
1004
+ if not re.fullmatch(r"[A-Za-z0-9._-]+", pr.head_ref):
1005
+ log.warning(
1006
+ "%s head ref %r does not match the daemon's own branch shape "
1007
+ "— not spawning a fix session on a foreign-shaped branch",
1008
+ pr.pr_slug,
1009
+ pr.head_ref,
1010
+ )
1011
+ return Decision(
1012
+ Action.SKIPPED,
1013
+ f"head ref {pr.head_ref!r} does not match the daemon's own "
1014
+ f"branch shape ([A-Za-z0-9._-]+) — foreign-shaped branch, "
1015
+ f"not this daemon's work",
1016
+ attempt,
1017
+ )
1018
+
1019
+ hub, problem = self._ensure_hub(pr.owner, pr.repo)
1020
+ if problem is not None:
1021
+ return Decision(Action.SKIPPED, problem, attempt)
1022
+
1023
+ name = fix_session_name(pr, round_, attempt)
1024
+ directive = FIX_DIRECTIVE.format(
1025
+ pr_url=pr.url,
1026
+ number=pr.number,
1027
+ round=round_,
1028
+ attempt=attempt,
1029
+ cap=self.config.attempt_cap,
1030
+ head_ref=pr.head_ref,
1031
+ # Re-request the reviewer whose request_changes this round
1032
+ # answers -- precise, and always resolvable (the review exists).
1033
+ reviewer=review.author,
1034
+ session=name,
1035
+ )
1036
+
1037
+ # No self-assignment here: the PR's authorship IS the server-side
1038
+ # marker (the token already owns it), and the in-flight signal is the
1039
+ # fix ledger row plus, once the session re-requests, the pending
1040
+ # re-request that closes the edge.
1041
+ self.alissa.enqueue_developer(
1042
+ session=name,
1043
+ directive=directive,
1044
+ cwd=hub,
1045
+ agent=self.config.agent_profile,
1046
+ dry_run=self.config.dry_run,
1047
+ )
1048
+
1049
+ if not self.config.dry_run:
1050
+ self.state.record_fix_spawn(
1051
+ repo_slug=pr.full_name,
1052
+ number=pr.number,
1053
+ round_=round_,
1054
+ attempt=attempt,
1055
+ session=name,
1056
+ )
1057
+
1058
+ return Decision(
1059
+ Action.SPAWNED,
1060
+ f"fix session {name} → round {round_} ({review.author}: "
1061
+ f"request_changes)",
1062
+ attempt,
1063
+ )
1064
+
1065
+ def _ensure_hub(self, owner: str, repo: str) -> tuple[Path, str | None]:
1066
+ """Resolve a spawn's cwd (the hub ROOT), hub-ifying the repo first if
1067
+ configured. Shared by both edges (issue spawns and fix spawns), so it
1068
+ takes the bare owner/repo. Returns (hub, problem); `problem` is
1069
+ non-None when the spawn cannot run."""
1070
+ full_name = f"{owner}/{repo}"
1071
+ hub = self.config.hub_for(owner, repo)
466
1072
  if hub.is_dir():
467
1073
  return hub, None
468
1074
 
469
1075
  if self.config.on_missing_hub != HUB_ADD:
470
1076
  return hub, (
471
1077
  f"no worktree hub at {hub} — add the repo with "
472
- f"`alissa code workspace add {issue.full_name}`, or set "
1078
+ f"`alissa code workspace add {full_name}`, or set "
473
1079
  f"on_missing_hub='add' (requires a repos allowlist)"
474
1080
  )
475
1081
 
@@ -477,8 +1083,8 @@ class DevWatcher:
477
1083
  # and poll_once() only reaches here for watched repos. Belt and
478
1084
  # braces -- this path clones code onto the machine and opens it as an
479
1085
  # agent cwd.
480
- if not self.config.watches(issue.full_name):
481
- return hub, f"{issue.full_name} is not in the repos allowlist"
1086
+ if not self.config.watches(full_name):
1087
+ return hub, f"{full_name} is not in the repos allowlist"
482
1088
 
483
1089
  if not self.config.manifest_path.is_file():
484
1090
  return hub, (
@@ -488,19 +1094,19 @@ class DevWatcher:
488
1094
 
489
1095
  try:
490
1096
  self.alissa.add_repo_to_workspace(
491
- issue.owner,
492
- issue.repo,
1097
+ owner,
1098
+ repo,
493
1099
  self.config.workspace_root,
494
1100
  dry_run=self.config.dry_run,
495
1101
  )
496
1102
  except CommandError as exc:
497
- return hub, f"could not hub-ify {issue.full_name}: {exc}"
1103
+ return hub, f"could not hub-ify {full_name}: {exc}"
498
1104
 
499
1105
  if self.config.dry_run:
500
1106
  return hub, None
501
1107
  if not hub.is_dir():
502
1108
  return hub, (
503
- f"`alissa code workspace add {issue.full_name}` reported "
1109
+ f"`alissa code workspace add {full_name}` reported "
504
1110
  f"success but {hub} still does not exist — check hub_template "
505
1111
  f"against the manifest's `dir:` override"
506
1112
  )
@@ -545,12 +1151,168 @@ class DevWatcher:
545
1151
 
546
1152
  return warnings
547
1153
 
1154
+ # -- the session reaper ------------------------------------------------
1155
+
1156
+ def reap_finished(self) -> int:
1157
+ """The sweep: kill idle, finished `develop-*`/`fix-*` sessions.
1158
+ Returns how many were killed (or would be, under dry-run).
1159
+
1160
+ Runs EVERY pass, from the tmux list and the local ledgers alone --
1161
+ never from the polls' search results (the reviewer daemon's
1162
+ `_reap_finished` starved exactly there: keyed off the search, it
1163
+ became unreachable once the trigger state cleared, which is when
1164
+ sessions finish). "Finished" needs a positive signal per session:
1165
+ the issue closed, the PR closed/merged, or a newer attempt/round on
1166
+ the ledger superseded it. Everything else is spared -- busy
1167
+ sessions, fresh in-flight ones (row younger than stale_minutes, the
1168
+ same grace the retry logic uses), sessions this ledger does not know
1169
+ (another workspace's, or the documented enqueue-before-ledger crash
1170
+ window), and sessions whose GitHub check fails this pass. Kills go
1171
+ through `alissa tmux kill <name>` one session at a time (never the
1172
+ server) and are best-effort; dry-run only logs."""
1173
+ try:
1174
+ sessions = self.alissa.list_sessions()
1175
+ except CommandError as exc:
1176
+ log.warning("reaper: could not list sessions (%s) — skipping "
1177
+ "this sweep", exc)
1178
+ return 0
1179
+
1180
+ reaped = 0
1181
+ for entry in sessions:
1182
+ name = str(entry.get("name") or "")
1183
+ if not name.startswith(("develop-", "fix-")):
1184
+ continue
1185
+ if entry.get("live") is False:
1186
+ # A registered-but-gone session has no tmux session to kill;
1187
+ # `alissa tmux cleanup` owns purging stale registrations.
1188
+ continue
1189
+ try:
1190
+ verdict = self._session_finished(name)
1191
+ except CommandError as exc:
1192
+ log.warning(
1193
+ "reaper: could not cross-reference %s (%s) — spared "
1194
+ "this pass", name, exc,
1195
+ )
1196
+ continue
1197
+ if verdict is None:
1198
+ continue
1199
+ if entry.get("status") != "idle":
1200
+ log.debug(
1201
+ "reaper: %s is finished (%s) but not idle (%s) — spared",
1202
+ name, verdict, entry.get("status"),
1203
+ )
1204
+ continue
1205
+ if self.config.dry_run:
1206
+ log.info("[dry-run] would kill session %s (%s)", name, verdict)
1207
+ reaped += 1
1208
+ continue
1209
+ try:
1210
+ self.alissa.kill_session(name)
1211
+ except CommandError as exc:
1212
+ if not self._kill_warned:
1213
+ self._kill_warned = True
1214
+ log.warning(
1215
+ "reaper: could not kill %s (%s) — kills are "
1216
+ "best-effort (this CLI may lack `alissa tmux "
1217
+ "kill`); warning once, later failures log at DEBUG",
1218
+ name, exc,
1219
+ )
1220
+ else:
1221
+ log.debug("reaper: could not kill %s (%s)", name, exc)
1222
+ continue
1223
+ log.info("reaper: killed finished session %s (%s)", name, verdict)
1224
+ reaped += 1
1225
+ return reaped
1226
+
1227
+ def _session_finished(self, name: str) -> "str | None":
1228
+ """Why this worker session is finished, or None to spare it.
1229
+
1230
+ The cheap checks run first: the ledger lookup (no match -> spare:
1231
+ not provably ours), the fresh-in-flight grace (no GitHub call for a
1232
+ session younger than stale_minutes -- it may still be initializing,
1233
+ and a finished one waits at most one stale window for the sweep),
1234
+ and ledger supersession (a newer attempt/round exists, so this
1235
+ session's episode is over whatever GitHub says). Only a stale,
1236
+ non-superseded session costs a GitHub fetch, whose closed/merged
1237
+ state is the terminal signal. CommandError propagates to the caller
1238
+ (spare this pass); RateLimited propagates further out to
1239
+ run_forever's backoff, like every other GitHub call in the pass."""
1240
+ row = self.state.spawn_for_session(name)
1241
+ if row is not None:
1242
+ slug, number = row["repo_slug"], row["issue"]
1243
+ if time.time() - row["spawned_at"] < self.config.stale_minutes * 60:
1244
+ return None
1245
+ newest = self.state.attempt_count(slug, number)
1246
+ if row["attempt"] < newest:
1247
+ return f"superseded by attempt {newest}"
1248
+ owner, _, repo = slug.partition("/")
1249
+ if self.github.issue(owner, repo, number).state != "open":
1250
+ return "issue closed"
1251
+ return None
1252
+
1253
+ row = self.state.fix_spawn_for_session(name)
1254
+ if row is not None:
1255
+ slug, number = row["repo_slug"], row["number"]
1256
+ if time.time() - row["spawned_at"] < self.config.stale_minutes * 60:
1257
+ return None
1258
+ newest_round = self.state.max_fix_round(slug, number)
1259
+ if row["round"] < newest_round:
1260
+ return f"superseded by round {newest_round}"
1261
+ newest = self.state.fix_attempt_count(slug, number, row["round"])
1262
+ if row["attempt"] < newest:
1263
+ return (
1264
+ f"superseded by attempt {newest} of round {row['round']}"
1265
+ )
1266
+ owner, _, repo = slug.partition("/")
1267
+ if self.github.pull_request(owner, repo, number).state != "open":
1268
+ return "PR closed/merged"
1269
+ return None
1270
+
1271
+ log.debug(
1272
+ "reaper: %s matches a worker name but is not on this ledger — "
1273
+ "spared (another workspace's session, or the "
1274
+ "enqueue-before-ledger crash window)", name,
1275
+ )
1276
+ return None
1277
+
548
1278
  # -- polling -----------------------------------------------------------
549
1279
 
550
1280
  def poll_once(self) -> list[tuple[str, Decision]]:
1281
+ """One pass: the reaper sweep FIRST (unconditional and
1282
+ search-independent — a broken or empty search must never starve
1283
+ it), then both edges: labeled issues (the primary trigger), then —
1284
+ behind the fix_rounds_enabled gate — the review-response edge over
1285
+ open PRs authored by the token. One Decision list covers both
1286
+ edges; the slug shape tells them apart in the logs (`owner/repo#7`
1287
+ vs `owner/repo#pr16`). The pass ends with one INFO summary line so
1288
+ unattended logs stay legible without -v."""
1289
+ reaped = self.reap_finished()
1290
+ results = self._poll_issues()
1291
+ if self.config.fix_rounds_enabled:
1292
+ results.extend(self._poll_prs())
1293
+
1294
+ counts = Counter(decision.action for _, decision in results)
1295
+ log.info(
1296
+ "poll summary: %d candidate(s) — %d spawned, %d in-flight, "
1297
+ "%d skipped, %d escalated, %d capped; %d session(s) reaped",
1298
+ len(results),
1299
+ counts[Action.SPAWNED],
1300
+ counts[Action.IN_FLIGHT],
1301
+ counts[Action.SKIPPED],
1302
+ counts[Action.ESCALATED],
1303
+ counts[Action.CAPPED],
1304
+ reaped,
1305
+ )
1306
+ return results
1307
+
1308
+ def _poll_issues(self) -> list[tuple[str, Decision]]:
551
1309
  found = self.github.search_issues(self.config.label, self.config.repos)
1310
+ # "candidates", not "awaiting a developer": the search deliberately
1311
+ # includes assigned issues (the daemon must re-see its own in-flight
1312
+ # work), so the count covers work in every state, not a backlog.
552
1313
  log.info(
553
- "%d open issue(s) labeled %r awaiting a developer",
1314
+ "%d candidate issue(s) labeled %r (assigned issues included by "
1315
+ "design)",
554
1316
  len(found),
555
1317
  self.config.label,
556
1318
  )
@@ -595,6 +1357,37 @@ class DevWatcher:
595
1357
  results.append((slug, decision))
596
1358
  return results
597
1359
 
1360
+ def _poll_prs(self) -> list[tuple[str, Decision]]:
1361
+ found = self.github.search_prs(self.config.repos)
1362
+ # "candidates" for the same reason as the issue edge: every open PR
1363
+ # authored by the token comes back, whatever review state it is in.
1364
+ log.info(
1365
+ "%d candidate PR(s) authored by %s (PRs in every review state "
1366
+ "included by design)",
1367
+ len(found),
1368
+ self.github.login,
1369
+ )
1370
+
1371
+ results: list[tuple[str, Decision]] = []
1372
+ for owner, repo, number in found:
1373
+ slug = f"{owner}/{repo}#pr{number}"
1374
+ # Defense in depth, same as the issue edge: the search is already
1375
+ # repo-scoped, but nothing outside the allowlist is evaluated.
1376
+ if not self.config.watches(f"{owner}/{repo}"):
1377
+ continue
1378
+ try:
1379
+ decision = self.evaluate_pr(owner, repo, number)
1380
+ except RateLimited:
1381
+ raise
1382
+ except CommandError as exc:
1383
+ log.error("%s: %s", slug, exc)
1384
+ decision = Decision(Action.SKIPPED, str(exc))
1385
+
1386
+ level = logging.INFO if decision.action != Action.SKIPPED else logging.DEBUG
1387
+ log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
1388
+ results.append((slug, decision))
1389
+ return results
1390
+
598
1391
  def run_forever(self) -> None:
599
1392
  # preflight() is the caller's responsibility -- the CLI runs it once
600
1393
  # for every mode, so calling it here too would double every check.