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.
- alissa/tools/github/devloop/__main__.py +22 -2
- alissa/tools/github/devloop/alissa.py +33 -4
- alissa/tools/github/devloop/config.py +37 -3
- alissa/tools/github/devloop/ghclient.py +198 -1
- alissa/tools/github/devloop/loop.py +825 -32
- alissa/tools/github/devloop/state.py +93 -0
- alissa/tools/github/devloop/version +1 -1
- {alissa_tools_github_devloop-0.1.0.dist-info → alissa_tools_github_devloop-0.3.0.dist-info}/METADATA +1 -1
- alissa_tools_github_devloop-0.3.0.dist-info/RECORD +15 -0
- alissa_tools_github_devloop-0.1.0.dist-info/RECORD +0 -15
- {alissa_tools_github_devloop-0.1.0.dist-info → alissa_tools_github_devloop-0.3.0.dist-info}/WHEEL +0 -0
- {alissa_tools_github_devloop-0.1.0.dist-info → alissa_tools_github_devloop-0.3.0.dist-info}/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.1.0.dist-info → alissa_tools_github_devloop-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
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
|
|
68
|
-
"
|
|
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
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
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).
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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 {
|
|
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(
|
|
481
|
-
return hub, f"{
|
|
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
|
-
|
|
492
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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.
|