alissa-tools-github-devloop 0.6.3__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {alissa_tools_github_devloop-0.6.3/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.7.0}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/__main__.py +31 -1
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/config.py +49 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/ghclient.py +102 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/loop.py +755 -30
- alissa_tools_github_devloop-0.7.0/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_devloop-0.6.3/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/README.md +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/setup.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -165,6 +165,32 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
165
165
|
"never auto-resume — a dead-with-PR lane waits for an operator",
|
|
166
166
|
)
|
|
167
167
|
|
|
168
|
+
rerequest = over.add_mutually_exclusive_group()
|
|
169
|
+
rerequest.add_argument(
|
|
170
|
+
"--rerequest",
|
|
171
|
+
dest="rerequest_enabled",
|
|
172
|
+
action="store_true",
|
|
173
|
+
default=None,
|
|
174
|
+
help="self-heal a PR whose fix session pushed and then died before "
|
|
175
|
+
"re-requesting review: once no worker session for it is listed and "
|
|
176
|
+
"the head has been public past the grace window, re-request review "
|
|
177
|
+
"mechanically, once per PR/round/head (the default)",
|
|
178
|
+
)
|
|
179
|
+
rerequest.add_argument(
|
|
180
|
+
"--no-rerequest",
|
|
181
|
+
dest="rerequest_enabled",
|
|
182
|
+
action="store_false",
|
|
183
|
+
help="never re-request review mechanically — a push whose session "
|
|
184
|
+
"died before its terminal act waits for an operator",
|
|
185
|
+
)
|
|
186
|
+
over.add_argument(
|
|
187
|
+
"--rerequest-grace-minutes",
|
|
188
|
+
type=int,
|
|
189
|
+
metavar="MINUTES",
|
|
190
|
+
help="how long the pushed head must have been public before the "
|
|
191
|
+
"wedge self-heal fires; 0 = use stale_minutes (the default)",
|
|
192
|
+
)
|
|
193
|
+
|
|
168
194
|
claims = over.add_mutually_exclusive_group()
|
|
169
195
|
claims.add_argument(
|
|
170
196
|
"--activity-claims",
|
|
@@ -221,6 +247,8 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
221
247
|
"maintain_label": args.maintain_label,
|
|
222
248
|
"maintain_enabled": args.maintain_enabled,
|
|
223
249
|
"resume_enabled": args.resume_enabled,
|
|
250
|
+
"rerequest_enabled": args.rerequest_enabled,
|
|
251
|
+
"rerequest_grace_minutes": args.rerequest_grace_minutes,
|
|
224
252
|
"activity_claims_enabled": args.activity_claims_enabled,
|
|
225
253
|
"dry_run": args.dry_run,
|
|
226
254
|
}
|
|
@@ -254,13 +282,15 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
254
282
|
log.info(
|
|
255
283
|
"poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
|
|
256
284
|
"max_sessions=%s; fix_rounds=%s; maintain=%s (label %r); resume=%s; "
|
|
257
|
-
"activity_claims=%s",
|
|
285
|
+
"rerequest=%s (grace %s min); activity_claims=%s",
|
|
258
286
|
config.poll_interval, config.dry_run, config.attempt_cap,
|
|
259
287
|
config.stale_minutes,
|
|
260
288
|
config.max_sessions or "unlimited",
|
|
261
289
|
config.fix_rounds_enabled,
|
|
262
290
|
config.maintain_enabled, config.maintain_label,
|
|
263
291
|
config.resume_enabled,
|
|
292
|
+
config.rerequest_enabled,
|
|
293
|
+
config.rerequest_grace_seconds // 60,
|
|
264
294
|
config.activity_claims_enabled,
|
|
265
295
|
)
|
|
266
296
|
log.debug("hub_template: %s", config.hub_template)
|
|
@@ -63,6 +63,8 @@ CONFIG_KEYS = (
|
|
|
63
63
|
"maintain_label",
|
|
64
64
|
"maintain_enabled",
|
|
65
65
|
"resume_enabled",
|
|
66
|
+
"rerequest_enabled",
|
|
67
|
+
"rerequest_grace_minutes",
|
|
66
68
|
"activity_claims_enabled",
|
|
67
69
|
"dry_run",
|
|
68
70
|
)
|
|
@@ -181,6 +183,30 @@ class Config:
|
|
|
181
183
|
# operator who wants a dead-with-PR lane to wait for a human.
|
|
182
184
|
resume_enabled: bool = True
|
|
183
185
|
|
|
186
|
+
# The wedge self-heal (issue #61): the fix edge's crash-recovery net under
|
|
187
|
+
# FIX_DIRECTIVE's terminal step. A fix session that posted its triage
|
|
188
|
+
# replies and PUSHED, then died before re-requesting review, leaves the PR
|
|
189
|
+
# in a state NEITHER daemon can exit -- revloop only sees PRs with a review
|
|
190
|
+
# requested from its identity, and the fix edge reads the push as "an
|
|
191
|
+
# implementer is already active". Rather than spawn a session, the daemon
|
|
192
|
+
# completes the dead one's protocol itself: it re-requests review from the
|
|
193
|
+
# reviewer whose verdict is being answered, exactly once per (PR, round,
|
|
194
|
+
# head). Default ON -- it closes a 3+ hour operator-only wedge (observed on
|
|
195
|
+
# fahera-mx/studio.alissa.app#242, 2026-07-27), it re-requests rather than
|
|
196
|
+
# spawns (so it can never double-drive a branch), and every side effect
|
|
197
|
+
# still honors dry_run. false restores the legacy read: the push closes the
|
|
198
|
+
# edge and a human re-requests.
|
|
199
|
+
rerequest_enabled: bool = True
|
|
200
|
+
|
|
201
|
+
# How long the pushed head must have been public before the self-heal
|
|
202
|
+
# fires. A live session re-requests SECONDS after its push, so firing on
|
|
203
|
+
# sight would race it; the window is what turns "a push exists" into "a
|
|
204
|
+
# push exists and nobody followed it up". 0 means "the fix-session stale
|
|
205
|
+
# window" (stale_minutes) -- the same clock every other presumed-dead
|
|
206
|
+
# decision on this edge is made on, and the documented default. Set a
|
|
207
|
+
# positive value to widen or narrow it independently.
|
|
208
|
+
rerequest_grace_minutes: int = 0
|
|
209
|
+
|
|
184
210
|
# Cross-instance spawn dedupe (issue #53): before spawning on any edge,
|
|
185
211
|
# read the trigger artifact's mechanical activity comment and treat a
|
|
186
212
|
# spawn line younger than stale_minutes as an in-flight claim EVEN when
|
|
@@ -219,6 +245,15 @@ class Config:
|
|
|
219
245
|
return default_state_path(self.workspace_root)
|
|
220
246
|
return Path(self.state_path).expanduser()
|
|
221
247
|
|
|
248
|
+
@property
|
|
249
|
+
def rerequest_grace_seconds(self) -> int:
|
|
250
|
+
"""How long a pushed head must have been public before the wedge
|
|
251
|
+
self-heal fires, in seconds. Read this, never the raw field: 0 is the
|
|
252
|
+
sentinel for "the fix-session stale window", so the fallback has to
|
|
253
|
+
live in ONE place or a caller reading the field directly would fire
|
|
254
|
+
the re-request instantly on a default config."""
|
|
255
|
+
return (self.rerequest_grace_minutes or self.stale_minutes) * 60
|
|
256
|
+
|
|
222
257
|
@property
|
|
223
258
|
def manifest_path(self) -> Path:
|
|
224
259
|
return self.workspace_root / "alissa-workspace.yaml"
|
|
@@ -321,6 +356,16 @@ class Config:
|
|
|
321
356
|
f"max_sessions must be >= 0 (0 = unlimited), got {max_sessions}"
|
|
322
357
|
)
|
|
323
358
|
|
|
359
|
+
# 0 is the sentinel for "fall back to stale_minutes" (the documented
|
|
360
|
+
# default), so the floor is 0 like max_sessions' -- a negative grace
|
|
361
|
+
# would make the self-heal fire on a head pushed in the future.
|
|
362
|
+
grace = int(raw.get("rerequest_grace_minutes", cls.rerequest_grace_minutes))
|
|
363
|
+
if grace < 0:
|
|
364
|
+
raise ValueError(
|
|
365
|
+
f"rerequest_grace_minutes must be >= 0 (0 = use stale_minutes), "
|
|
366
|
+
f"got {grace}"
|
|
367
|
+
)
|
|
368
|
+
|
|
324
369
|
interval = int(raw.get("poll_interval", cls.poll_interval))
|
|
325
370
|
if interval < MIN_POLL_INTERVAL:
|
|
326
371
|
raise ValueError(
|
|
@@ -353,6 +398,10 @@ class Config:
|
|
|
353
398
|
resume_enabled=bool(
|
|
354
399
|
raw.get("resume_enabled", cls.resume_enabled)
|
|
355
400
|
),
|
|
401
|
+
rerequest_enabled=bool(
|
|
402
|
+
raw.get("rerequest_enabled", cls.rerequest_enabled)
|
|
403
|
+
),
|
|
404
|
+
rerequest_grace_minutes=grace,
|
|
356
405
|
activity_claims_enabled=bool(
|
|
357
406
|
raw.get("activity_claims_enabled", cls.activity_claims_enabled)
|
|
358
407
|
),
|
|
@@ -176,6 +176,35 @@ class IdentityMismatch(RuntimeError):
|
|
|
176
176
|
"""Configured developer identity disagrees with the gh token."""
|
|
177
177
|
|
|
178
178
|
|
|
179
|
+
class ReviewRequestRejected(RuntimeError):
|
|
180
|
+
"""`request_review` returned 2xx but the reviewer is not on the PR.
|
|
181
|
+
|
|
182
|
+
The `requested_reviewers` endpoint answers with the (possibly unchanged)
|
|
183
|
+
pull request, so a request GitHub declined to honour -- a reviewer without
|
|
184
|
+
read access, a login that has since been renamed, an author who cannot
|
|
185
|
+
request themselves -- can come back 2xx with nothing added. The mechanical
|
|
186
|
+
re-request leans on that field being TRUE afterwards (it is the whole
|
|
187
|
+
remedy: the reviewer daemon's only entry point is a
|
|
188
|
+
`review-requested:@me` search), so the response is checked exactly as
|
|
189
|
+
`assign_self` checks its own.
|
|
190
|
+
|
|
191
|
+
The caller's contract is escalate-don't-retry, the same one
|
|
192
|
+
AssignmentRejected gets on the issue edge and for the same reason: every
|
|
193
|
+
cause above is PERMANENT and operator-fixable, so retrying achieves
|
|
194
|
+
nothing but write traffic against an endpoint GitHub has already refused.
|
|
195
|
+
`loop.DevWatcher._escalate_rerequest_rejected` pages the operator on the
|
|
196
|
+
PR once and records `loop.rerequest_rejected_kind`, which short-circuits
|
|
197
|
+
the next poll before any probe is paid for; a NEW head is a new key and
|
|
198
|
+
gets a fresh attempt.
|
|
199
|
+
|
|
200
|
+
What still separates this from AssignmentRejected is the OTHER ledger row
|
|
201
|
+
it must not write: a refused re-request is never recorded as DONE
|
|
202
|
+
(`loop.rerequest_kind`). The remedy did not happen, and a row saying it
|
|
203
|
+
did would re-wedge the PR permanently with nothing left that can see it.
|
|
204
|
+
Distinct from a transient CommandError on the same call, which keeps the
|
|
205
|
+
plain retry."""
|
|
206
|
+
|
|
207
|
+
|
|
179
208
|
class AssignmentRejected(RuntimeError):
|
|
180
209
|
"""`assign_self` returned 2xx but the login is not on the issue.
|
|
181
210
|
|
|
@@ -609,6 +638,79 @@ class GitHub:
|
|
|
609
638
|
if isinstance(r, dict)
|
|
610
639
|
]
|
|
611
640
|
|
|
641
|
+
def commit_time(self, owner: str, repo: str, sha: str) -> "int | None":
|
|
642
|
+
"""A commit's COMMITTER date as unix seconds, or None when it cannot
|
|
643
|
+
be read -- the grace window's clock behind the mechanical re-request.
|
|
644
|
+
|
|
645
|
+
GitHub's REST API exposes no "when was this ref pushed" field on a pull
|
|
646
|
+
request, and plain pushes raise no timeline event (only force-pushes
|
|
647
|
+
do), so the head commit's committer date is the cheapest instant
|
|
648
|
+
available. It is a LOWER BOUND on the push: a commit created locally
|
|
649
|
+
and pushed minutes later stamps the earlier moment, so an age measured
|
|
650
|
+
from it over-estimates how long the head has been public and the grace
|
|
651
|
+
window can expire marginally early. For the sessions this daemon
|
|
652
|
+
spawns, commit and push are seconds apart (the directives push
|
|
653
|
+
immediately after committing), and the caller tightens the bound
|
|
654
|
+
further by taking the later of this and the review the push answers --
|
|
655
|
+
the push necessarily postdates that review.
|
|
656
|
+
|
|
657
|
+
The GIT-OBJECT endpoint, not `repos/:o/:r/commits/:sha`: the latter
|
|
658
|
+
ships the commit's full file list and patches (up to 300 files) to
|
|
659
|
+
deliver one date, while `git/commits/:sha` returns the object alone --
|
|
660
|
+
a few hundred bytes whatever the commit's size, with the date one
|
|
661
|
+
level shallower. Immaterial on the steady-state path (the done-marker
|
|
662
|
+
short-circuits ahead of this call once the remedy has fired) and not
|
|
663
|
+
immaterial on the transient-retry path, where a poll that keeps
|
|
664
|
+
failing would otherwise re-fetch a possibly-large merge commit every
|
|
665
|
+
cycle.
|
|
666
|
+
|
|
667
|
+
None on anything unreadable (no such commit, a malformed payload, a
|
|
668
|
+
date GitHub did not spell in UTC `Z`): the caller treats an unknown
|
|
669
|
+
push instant as "cannot prove the grace has passed" and defers, which
|
|
670
|
+
is the fail-safe direction for a call that ends in a mutation."""
|
|
671
|
+
data = self._api(f"repos/{owner}/{repo}/git/commits/{sha}") or {}
|
|
672
|
+
committer = data.get("committer") or {}
|
|
673
|
+
return parse_github_timestamp(committer.get("date"))
|
|
674
|
+
|
|
675
|
+
def request_review(self, owner: str, repo: str, number: int, reviewer: str) -> None:
|
|
676
|
+
"""Request review from ONE login on a pull request.
|
|
677
|
+
|
|
678
|
+
The API call behind a fix session's terminal step (`gh pr edit
|
|
679
|
+
--add-reviewer`), performed by the daemon itself when the session that
|
|
680
|
+
owed it died first. `--input` rather than `-f reviewers[]=...` for the
|
|
681
|
+
same reason `assign_self` uses it: gh 2.4.0 predates the array syntax
|
|
682
|
+
and the endpoint wants `{"reviewers": [...]}`.
|
|
683
|
+
|
|
684
|
+
The response is the pull request, so the request is verified to have
|
|
685
|
+
LANDED (see ReviewRequestRejected) -- casefolded, because GitHub logins
|
|
686
|
+
are case-insensitive while the reviewer set is operator-typed."""
|
|
687
|
+
payload = json.dumps({"reviewers": [reviewer]})
|
|
688
|
+
fd, body_path = tempfile.mkstemp(prefix="devloop-rereview-", suffix=".json")
|
|
689
|
+
try:
|
|
690
|
+
with os.fdopen(fd, "w") as handle:
|
|
691
|
+
handle.write(payload)
|
|
692
|
+
data = self._api(
|
|
693
|
+
"-X",
|
|
694
|
+
"POST",
|
|
695
|
+
f"repos/{owner}/{repo}/pulls/{number}/requested_reviewers",
|
|
696
|
+
"--input",
|
|
697
|
+
body_path,
|
|
698
|
+
)
|
|
699
|
+
finally:
|
|
700
|
+
os.unlink(body_path)
|
|
701
|
+
|
|
702
|
+
landed = tuple(
|
|
703
|
+
(r.get("login") or "")
|
|
704
|
+
for r in ((data or {}).get("requested_reviewers") or [])
|
|
705
|
+
)
|
|
706
|
+
if reviewer.casefold() not in {name.casefold() for name in landed}:
|
|
707
|
+
raise ReviewRequestRejected(
|
|
708
|
+
f"{owner}/{repo}#{number}: GitHub accepted the request but "
|
|
709
|
+
f"{reviewer!r} is not among the requested reviewers {landed!r} "
|
|
710
|
+
f"-- the review request did not land, so the reviewer daemon "
|
|
711
|
+
f"still cannot see this PR."
|
|
712
|
+
)
|
|
713
|
+
|
|
612
714
|
def issue(self, owner: str, repo: str, number: int) -> Issue:
|
|
613
715
|
# `or {}`: run_json returns None on empty stdout, and the siblings
|
|
614
716
|
# (search_issues, assign_self) already guard the same way.
|
|
@@ -118,6 +118,28 @@ CHANGES_REQUESTED); COMMENTED-mode reviews defer to the CR6 verdict envelope
|
|
|
118
118
|
via an injectable reader, and with no readable verdict the edge stays closed
|
|
119
119
|
-- a fix session is never spawned on an ambiguous verdict.
|
|
120
120
|
|
|
121
|
+
That same edge carries the loop's one MECHANICAL remedy (rerequest_enabled,
|
|
122
|
+
issue #61). "A push since the review means an implementer is answering it" is
|
|
123
|
+
usually true and, when it is not, terminal: a fix session that posted its CR8
|
|
124
|
+
replies and PUSHED, then died before `gh pr edit --add-reviewer`, leaves a PR
|
|
125
|
+
neither daemon can reach -- reviewloop only sees a pending re-request, this
|
|
126
|
+
edge only sees the push -- and it waits for a human (observed
|
|
127
|
+
fahera-mx/studio.alissa.app#242, 2026-07-27: 3+ hours). So a push past a
|
|
128
|
+
`request_changes` head is re-examined rather than trusted: with no pending
|
|
129
|
+
re-request, no live worker session for the PR (the liveness rule verbatim --
|
|
130
|
+
DEAD needs a SUCCESSFUL listing with every candidate absent; INDETERMINATE
|
|
131
|
+
defers), and the head public longer than a grace window
|
|
132
|
+
(rerequest_grace_minutes, default stale_minutes -- a live session re-requests
|
|
133
|
+
seconds after its push and must not be raced), the daemon performs the dead
|
|
134
|
+
session's terminal act ITSELF: one re-request to the reviewer whose verdict
|
|
135
|
+
went unanswered, at most once per (PR, round, HEAD), logged on the activity
|
|
136
|
+
comment. No session is spawned -- the work is already pushed and the triage
|
|
137
|
+
already posted, so what is missing is one API call, not a worker -- and the
|
|
138
|
+
pushed head is reviewed as-is. The ledger row lands only AFTER the call
|
|
139
|
+
succeeds (a remedy recorded but never performed would re-wedge the PR with
|
|
140
|
+
nothing left that can see it), and a further `request_changes` on a new head
|
|
141
|
+
is a new key, so the remedy never loops on one commit.
|
|
142
|
+
|
|
121
143
|
The pass's THIRD edge is the MAINTENANCE edge (maintain_enabled): open PRs
|
|
122
144
|
an operator labeled `maintain_label` (default `alissa:maintain`) get a
|
|
123
145
|
maintenance session -- merge conflicts on approved-but-unmerged branches,
|
|
@@ -250,6 +272,7 @@ from .ghclient import (
|
|
|
250
272
|
PullRequest,
|
|
251
273
|
RateLimited,
|
|
252
274
|
Review,
|
|
275
|
+
ReviewRequestRejected,
|
|
253
276
|
parse_github_timestamp,
|
|
254
277
|
parse_task_ref,
|
|
255
278
|
)
|
|
@@ -299,6 +322,85 @@ _RESUME_TASK_WITHOUT_ORIGIN = (
|
|
|
299
322
|
"your implementation task DOWNSTREAM of it before writing code."
|
|
300
323
|
)
|
|
301
324
|
|
|
325
|
+
# The delivery note (issue #63). A worker's highest-value output is not the
|
|
326
|
+
# diff -- the reviewer can read that -- it is the narrative the diff cannot
|
|
327
|
+
# carry: which readings of the spec it chose, which fallbacks it deliberately
|
|
328
|
+
# did not build, and which criteria this environment simply cannot check. That
|
|
329
|
+
# narrative used to die with the tmux session, forcing the operator to
|
|
330
|
+
# re-derive every criterion by hand. So the note is MANDATED in both places a
|
|
331
|
+
# later reader looks: the worker's own task partition (private to the worker
|
|
332
|
+
# and its validator) and the PR body (what the reviewer and the origin-side
|
|
333
|
+
# validator read). The four section names are FIXED so the note is walkable
|
|
334
|
+
# rather than free prose, and an empty section is stated -- "none" under a kept
|
|
335
|
+
# heading is evidence of a considered nothing; a dropped heading is evidence of
|
|
336
|
+
# nothing at all.
|
|
337
|
+
_DELIVERY_NOTE_DESTINATIONS = (
|
|
338
|
+
"register a DELIVERY NOTE in BOTH places — as evidence on your "
|
|
339
|
+
"implementation task, titled `Delivery note`, and as a `## Delivery "
|
|
340
|
+
"notes` section in the PR body (your task partition is private to you; "
|
|
341
|
+
"the PR body is what the reviewer and the origin-side validator read). "
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Resume and fix rounds land on a PR that may already carry the section: a
|
|
345
|
+
# second one splits the record the first was created to hold.
|
|
346
|
+
_DELIVERY_NOTE_EXTEND = (
|
|
347
|
+
"If the PR body already carries a `## Delivery notes` section, EXTEND it "
|
|
348
|
+
"— never open a second one. "
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# A fix round delivers that round, not the whole issue, and its triage calls
|
|
352
|
+
# ARE judgment calls: why a finding was pursued, answered or deferred is
|
|
353
|
+
# exactly what the validator cannot reconstruct from the diff.
|
|
354
|
+
_DELIVERY_NOTE_FIX_SCOPE = (
|
|
355
|
+
"The note covers THIS round's changes, and your triage decisions are "
|
|
356
|
+
"judgment calls too — record why each finding was pursued, answered or "
|
|
357
|
+
"deferred. "
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
_DELIVERY_NOTE_SECTIONS = (
|
|
361
|
+
"Four sections, every one present even when it is empty (state `none` "
|
|
362
|
+
"under it — never omit the heading): **Judgment calls** — every place "
|
|
363
|
+
"the implementation deviated from or interpreted the issue text, one "
|
|
364
|
+
"bullet each, with the why; **Not verified — operator's gate** — what "
|
|
365
|
+
"could NOT be verified in this environment and why, enumerated as a "
|
|
366
|
+
"checklist the validator can walk; **Verification** — what you actually "
|
|
367
|
+
"ran (tests, style, types, build) and the honest results, failures "
|
|
368
|
+
"included; **Scope touched** — the files and areas you actually modified "
|
|
369
|
+
"against the issue's declared scope, calling out any excursion "
|
|
370
|
+
"explicitly. Honest bullets, not essays: do NOT restate the diff and do "
|
|
371
|
+
"NOT pad — your reader is the human validator deciding whether to trust "
|
|
372
|
+
"this merge. One grammar rule, because this daemon PARSES the body you "
|
|
373
|
+
"are writing into: never put a GitHub closing keyword (`close`/`closes`/"
|
|
374
|
+
"`closed`, `fix`/`fixes`/`fixed`, `resolve`/`resolves`/`resolved`) "
|
|
375
|
+
"directly before an issue reference anywhere in the note — write `issue "
|
|
376
|
+
"#<n>` or name it in prose instead. A not-verified bullet reading `does "
|
|
377
|
+
"not fix #<n>` reads to the daemon as a claim that this PR closes that "
|
|
378
|
+
"issue, and that claim is the ONLY thing standing between another "
|
|
379
|
+
"issue's work and your branch. The single closing claim this body makes "
|
|
380
|
+
"is the mandated `Closes #<n>` for the issue you are implementing."
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
_DELIVERY_NOTE = (
|
|
384
|
+
"BEFORE the review handoff, "
|
|
385
|
+
+ _DELIVERY_NOTE_DESTINATIONS
|
|
386
|
+
+ _DELIVERY_NOTE_SECTIONS
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
_RESUME_DELIVERY_NOTE = (
|
|
390
|
+
"BEFORE the review handoff, "
|
|
391
|
+
+ _DELIVERY_NOTE_DESTINATIONS
|
|
392
|
+
+ _DELIVERY_NOTE_EXTEND
|
|
393
|
+
+ _DELIVERY_NOTE_SECTIONS
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
_FIX_DELIVERY_NOTE = (
|
|
397
|
+
"BEFORE you re-request review, "
|
|
398
|
+
+ _DELIVERY_NOTE_DESTINATIONS
|
|
399
|
+
+ _DELIVERY_NOTE_EXTEND
|
|
400
|
+
+ _DELIVERY_NOTE_FIX_SCOPE
|
|
401
|
+
+ _DELIVERY_NOTE_SECTIONS
|
|
402
|
+
)
|
|
403
|
+
|
|
302
404
|
DEV_DIRECTIVE = (
|
|
303
405
|
"You are an IMPLEMENTER, not a reviewer. Implement GitHub issue "
|
|
304
406
|
"{issue_url} (attempt {attempt} of {cap}) — the issue is your "
|
|
@@ -316,7 +418,9 @@ DEV_DIRECTIVE = (
|
|
|
316
418
|
"body contains `Closes #{number}` and BOTH task refs — the origin task "
|
|
317
419
|
"AND your implementation task: the PR body is the canonical cross-actor "
|
|
318
420
|
"record, and each actor's tasks live in its own partition — and attach "
|
|
319
|
-
"the PR URL to the task as evidence.
|
|
421
|
+
"the PR URL to the task as evidence. "
|
|
422
|
+
+ _DELIVERY_NOTE
|
|
423
|
+
+ " Then drive the review handoff with "
|
|
320
424
|
"the alissa-pr-review skill, requesting reviewer(s): {reviewers}. In "
|
|
321
425
|
"every review round the ORDER is load-bearing: post your CR8 triage "
|
|
322
426
|
"replies FIRST, then push the fixes and re-request review — the review "
|
|
@@ -365,6 +469,8 @@ RESUME_DIRECTIVE = (
|
|
|
365
469
|
"anchors. Keep the PR body carrying `Closes #{number}` and BOTH task refs "
|
|
366
470
|
"(the origin task AND your implementation task) — restoring them if the "
|
|
367
471
|
"dead session did not. "
|
|
472
|
+
+ _RESUME_DELIVERY_NOTE
|
|
473
|
+
+ " "
|
|
368
474
|
"If the PR already carries review findings, the ORDER is load-bearing: "
|
|
369
475
|
"post your CR8 triage replies FIRST (only the ones missing — never a "
|
|
370
476
|
"duplicate reply on a thread the dead session already answered), THEN "
|
|
@@ -395,7 +501,9 @@ FIX_DIRECTIVE = (
|
|
|
395
501
|
"FIRST verify the triage of every prior review finding per CR8: exactly "
|
|
396
502
|
"one reasoned `[triage:*]` reply on each finding's thread — post any that "
|
|
397
503
|
"are missing. Then fix the pursued findings on the same branch and VERIFY "
|
|
398
|
-
"locally (run the repo's test/style/type scripts).
|
|
504
|
+
"locally (run the repo's test/style/type scripts). "
|
|
505
|
+
+ _FIX_DELIVERY_NOTE
|
|
506
|
+
+ " The ORDER is "
|
|
399
507
|
"load-bearing: triage replies FIRST, then push the fixes, then re-request "
|
|
400
508
|
"review from {reviewer} (`gh pr edit {number} --add-reviewer {reviewer}`) "
|
|
401
509
|
"— the review daemon re-enters on head movement, so pushing before the "
|
|
@@ -442,6 +550,8 @@ FIX_RESUME_DIRECTIVE = (
|
|
|
442
550
|
"the ORIGINAL acceptance criteria the issue set, and VERIFY locally (run "
|
|
443
551
|
"the repo's test/style/type scripts). NEVER force-push: history stays "
|
|
444
552
|
"append-only, or the review threads lose their anchors. "
|
|
553
|
+
+ _FIX_DELIVERY_NOTE
|
|
554
|
+
+ " "
|
|
445
555
|
"The ORDER is load-bearing: triage replies FIRST, then push the fixes, "
|
|
446
556
|
"then re-request review from {reviewer} (`gh pr edit {number} "
|
|
447
557
|
"--add-reviewer {reviewer}`) — the review daemon re-enters on head "
|
|
@@ -547,6 +657,21 @@ MAINTAIN_CAP_COMMENT = (
|
|
|
547
657
|
"`attempt_cap` to re-enter the loop, or remove the label to park the PR."
|
|
548
658
|
)
|
|
549
659
|
|
|
660
|
+
REREQUEST_REJECTED_COMMENT = (
|
|
661
|
+
"**Dev loop cannot re-request review** — this PR's round {round} fix "
|
|
662
|
+
"session pushed `{head}` and then died before re-requesting review, so "
|
|
663
|
+
"the daemon tried to complete that step for it. GitHub accepted the "
|
|
664
|
+
"request but did not honour it: `{reviewer}` is not on the PR's requested "
|
|
665
|
+
"reviewers afterwards — most likely that account lacks read access here, "
|
|
666
|
+
"has been renamed, or is the PR author (GitHub refuses an author's "
|
|
667
|
+
"request of themselves). That is a permanent condition, so the daemon "
|
|
668
|
+
"will not retry it on this head. Nothing else can reach the PR in this "
|
|
669
|
+
"state either: with no pending re-request, the reviewer daemon's "
|
|
670
|
+
"`review-requested:@me` search cannot see it. This needs an operator: "
|
|
671
|
+
"request a review by hand (or fix the reviewer's access), or close the "
|
|
672
|
+
"PR to park the work."
|
|
673
|
+
)
|
|
674
|
+
|
|
550
675
|
ASSIGNMENT_REJECTED_COMMENT = (
|
|
551
676
|
"**Dev loop cannot start** — the daemon tried to self-assign this issue "
|
|
552
677
|
"as its in-flight marker, but GitHub silently dropped the assignment: "
|
|
@@ -579,6 +704,17 @@ ACTIVITY_HEADER = (
|
|
|
579
704
|
# line is deduped per episode -- see deferral_activity_kind.
|
|
580
705
|
ACTIVITY_DEFERRED = "deferred"
|
|
581
706
|
|
|
707
|
+
# The activity-log context prefix for a mechanical re-request (the wedge
|
|
708
|
+
# self-heal). Non-spawn like ACTIVITY_DEFERRED, and for the same reason: the
|
|
709
|
+
# self-heal enqueues NOTHING, so a reader that took its line for a spawn claim
|
|
710
|
+
# would park the lane against every instance including the one that wrote it.
|
|
711
|
+
ACTIVITY_REREQUESTED = "re-requested"
|
|
712
|
+
|
|
713
|
+
# Line contexts that record something OTHER than a spawn. `activity_claims`
|
|
714
|
+
# skips them: a claim is a promise that a session is running on the lane, and
|
|
715
|
+
# neither a capacity deferral nor a mechanical re-request makes one.
|
|
716
|
+
ACTIVITY_NON_SPAWN = (ACTIVITY_DEFERRED, ACTIVITY_REREQUESTED)
|
|
717
|
+
|
|
582
718
|
# The reader's grammar for a line `_activity_line` wrote: bullet, UTC
|
|
583
719
|
# timestamp, backticked session name, edge, context. The edge field is
|
|
584
720
|
# non-greedy and em-dash-free so the CONTEXT keeps every em-dash it contains
|
|
@@ -638,6 +774,21 @@ ESCALATION_LIVE_STALLED = "live-stalled"
|
|
|
638
774
|
# PR share a number; the epoch suffix is maintain_cap_kind's job below.
|
|
639
775
|
ESCALATION_MAINTAIN_CAP = "maintain-cap"
|
|
640
776
|
|
|
777
|
+
# The wedge self-heal's done-marker prefix (see rerequest_kind). Not an
|
|
778
|
+
# operator page -- nothing is escalated and no human is asked for anything --
|
|
779
|
+
# but it is exactly what the `escalations` table is: the ledger of once-per-key
|
|
780
|
+
# acts this daemon has already performed, keyed by a free-form TEXT kind. The
|
|
781
|
+
# `rerequest-` prefix keeps its rows from colliding with any issue-edge kind
|
|
782
|
+
# when an issue and a PR share a number, like every other PR-side kind here.
|
|
783
|
+
ESCALATION_REREQUEST = "rerequest"
|
|
784
|
+
|
|
785
|
+
# The self-heal's OTHER once-per-key row: the remedy was attempted and GitHub
|
|
786
|
+
# permanently refused it (see ReviewRequestRejected). Deliberately a separate
|
|
787
|
+
# kind from ESCALATION_REREQUEST, because the two say opposite things -- one
|
|
788
|
+
# marks the remedy DONE, the other marks it IMPOSSIBLE on this head -- and a
|
|
789
|
+
# rejected attempt must never read as a completed one.
|
|
790
|
+
ESCALATION_REREQUEST_REJECTED = "rerequest-rejected"
|
|
791
|
+
|
|
641
792
|
# CR6 verdict words, spelled exactly as reviewloop's alissa.py spells them --
|
|
642
793
|
# the envelope tie-breaker (see DevWatcher.envelope_verdict) must return
|
|
643
794
|
# these strings, so the two daemons read one vocabulary.
|
|
@@ -660,6 +811,55 @@ def maintain_cap_kind(labeled_ts: "int | None") -> str:
|
|
|
660
811
|
return f"{ESCALATION_MAINTAIN_CAP}:e{labeled_ts}"
|
|
661
812
|
|
|
662
813
|
|
|
814
|
+
def rerequest_kind(round_: int, head_sha: str) -> str:
|
|
815
|
+
"""The ledger kind that marks ONE mechanical re-request as done.
|
|
816
|
+
|
|
817
|
+
Keyed per (PR, round, HEAD) -- the row_slug/number supply the PR, and both
|
|
818
|
+
of the other two fold into the kind. All three are load-bearing:
|
|
819
|
+
|
|
820
|
+
* the ROUND, because a later round is a different unanswered verdict, and
|
|
821
|
+
round k's self-heal must not silence round k+1's;
|
|
822
|
+
* the HEAD, because the whole point of the remedy is to get THIS pushed
|
|
823
|
+
head reviewed. If the reviewer answers with another `request_changes`
|
|
824
|
+
and a session pushes a new head that again goes unanswered, that is a
|
|
825
|
+
fresh wedge on a fresh commit and deserves its own re-request. Keyed on
|
|
826
|
+
(PR, round) alone the second wedge would be silently swallowed;
|
|
827
|
+
* and together they are what stops a LOOP: within one (round, head) the
|
|
828
|
+
remedy fires exactly once, whatever the reviewer does next. If the
|
|
829
|
+
re-request is honoured the edge closes server-side (a pending
|
|
830
|
+
re-request); if it is dismissed or withdrawn without a verdict, the row
|
|
831
|
+
is what keeps the daemon from re-requesting into that dismissal every
|
|
832
|
+
poll forever.
|
|
833
|
+
|
|
834
|
+
Delivery contract, unlike every escalation kind beside it: the row lands
|
|
835
|
+
only AFTER the API call SUCCEEDS. A page that failed to post costs an
|
|
836
|
+
operator one notification; a re-request that failed to land and was
|
|
837
|
+
recorded anyway would re-wedge the PR permanently, with the remedy marked
|
|
838
|
+
done and no automation left that can see it.
|
|
839
|
+
"""
|
|
840
|
+
return f"{ESCALATION_REREQUEST}:r{round_}@{head_sha}"
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def rerequest_rejected_kind(round_: int, head_sha: str) -> str:
|
|
844
|
+
"""The ledger kind that bounds ONE permanently-refused re-request.
|
|
845
|
+
|
|
846
|
+
Keyed exactly like rerequest_kind (per PR, round and HEAD) so the two
|
|
847
|
+
rows describe the same act, and so a NEW head -- a fresh push, possibly
|
|
848
|
+
by a session that can request reviewers -- gets a fresh attempt rather
|
|
849
|
+
than inheriting the last head's refusal.
|
|
850
|
+
|
|
851
|
+
Delivery contract: the OPPOSITE of rerequest_kind's, and matching
|
|
852
|
+
_escalate_rejection's on the issue edge. This row is recorded even if its
|
|
853
|
+
operator comment fails to post, because the row's job is to STOP a POST
|
|
854
|
+
that GitHub has permanently refused -- recording only on a successful
|
|
855
|
+
comment would leave the daemon writing to that endpoint every poll
|
|
856
|
+
forever, which is the failure the row exists to end. At most one lost
|
|
857
|
+
page per (PR, round, head): the same trade every terminal-state
|
|
858
|
+
escalation here makes.
|
|
859
|
+
"""
|
|
860
|
+
return f"{ESCALATION_REREQUEST_REJECTED}:r{round_}@{head_sha}"
|
|
861
|
+
|
|
862
|
+
|
|
663
863
|
def fix_cap_kind(round_: int) -> str:
|
|
664
864
|
"""The ledger kind that dedupes ONE round's fix cap-out operator page.
|
|
665
865
|
|
|
@@ -796,8 +996,13 @@ DAEMON_HEAD_REF = re.compile(r"[A-Za-z0-9._-]+")
|
|
|
796
996
|
CLOSING_KEYWORD = r"clos(?:e|es|ed)|fix(?:es|ed)?|resolv(?:e|es|ed)"
|
|
797
997
|
|
|
798
998
|
|
|
799
|
-
def
|
|
800
|
-
"""
|
|
999
|
+
def _closing_pattern(owner: str, repo: str, number: str) -> str:
|
|
1000
|
+
"""The closing-keyword grammar, with `number` the digits to match.
|
|
1001
|
+
|
|
1002
|
+
Factored out so the two readers -- "does this body close issue 7?" and
|
|
1003
|
+
"which issues does this body close?" -- can never drift apart: one accepts
|
|
1004
|
+
a literal number, the other a capturing `(\\d+)`, and everything ELSE
|
|
1005
|
+
about what counts as a closing claim is stated once.
|
|
801
1006
|
|
|
802
1007
|
Accepts every keyword GitHub accepts, case-insensitively, against every
|
|
803
1008
|
reference form that resolves to this repo's issue: `#7`, `GH-7`,
|
|
@@ -807,22 +1012,46 @@ def closes_issue(body: str, owner: str, repo: str, number: int) -> bool:
|
|
|
807
1012
|
(`Closes: #7` yes, a keyword ending a paragraph followed by a `#7` on the
|
|
808
1013
|
next line no): GitHub does not link across the break either, and prose
|
|
809
1014
|
like "this fixes:" above a list of issue references must not read as a
|
|
810
|
-
claim to close them.
|
|
1015
|
+
claim to close them.
|
|
1016
|
+
"""
|
|
811
1017
|
ref = (
|
|
812
1018
|
r"(?:#|GH-|"
|
|
813
1019
|
+ re.escape(f"{owner}/{repo}") + r"#|"
|
|
814
1020
|
+ re.escape(f"https://github.com/{owner}/{repo}/issues/")
|
|
815
1021
|
+ r")"
|
|
816
1022
|
)
|
|
817
|
-
|
|
1023
|
+
return (
|
|
818
1024
|
r"\b(?:" + CLOSING_KEYWORD + r")\b[ \t]*:?[ \t]*"
|
|
819
1025
|
+ ref
|
|
820
|
-
+
|
|
1026
|
+
+ number
|
|
821
1027
|
+ r"(?!\d)"
|
|
822
1028
|
)
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def closes_issue(body: str, owner: str, repo: str, number: int) -> bool:
|
|
1032
|
+
"""Does `body` claim to close THIS issue with a GitHub closing keyword?
|
|
1033
|
+
See _closing_pattern for the grammar this asks against."""
|
|
1034
|
+
pattern = _closing_pattern(owner, repo, re.escape(str(number)))
|
|
823
1035
|
return re.search(pattern, body, re.IGNORECASE) is not None
|
|
824
1036
|
|
|
825
1037
|
|
|
1038
|
+
def closed_issue_numbers(body: str, owner: str, repo: str) -> "tuple[int, ...]":
|
|
1039
|
+
"""Every issue number `body` claims to close in THIS repo, in order.
|
|
1040
|
+
|
|
1041
|
+
closes_issue's question asked the other way round, for the caller that
|
|
1042
|
+
holds a PR and needs the issue behind it rather than the reverse (the
|
|
1043
|
+
wedge self-heal, resolving which `develop-*` lane opened this PR so a live
|
|
1044
|
+
one is never re-requested over). Same grammar, same repo scoping -- a
|
|
1045
|
+
`Closes acme/other#7` names another repo's issue and is not returned,
|
|
1046
|
+
because the ledger key it would produce would be a lie.
|
|
1047
|
+
"""
|
|
1048
|
+
pattern = _closing_pattern(owner, repo, r"(\d+)")
|
|
1049
|
+
seen: "dict[int, None]" = {}
|
|
1050
|
+
for match in re.finditer(pattern, body or "", re.IGNORECASE):
|
|
1051
|
+
seen.setdefault(int(match.group(1)), None)
|
|
1052
|
+
return tuple(seen)
|
|
1053
|
+
|
|
1054
|
+
|
|
826
1055
|
class Action(str, Enum):
|
|
827
1056
|
SPAWNED = "spawned"
|
|
828
1057
|
IN_FLIGHT = "in-flight"
|
|
@@ -923,6 +1152,17 @@ class SessionLiveness:
|
|
|
923
1152
|
return Liveness.INDETERMINATE
|
|
924
1153
|
return Liveness.ALIVE if session in self._live else Liveness.DEAD
|
|
925
1154
|
|
|
1155
|
+
def live_with_prefix(self, prefix: str) -> "tuple[str, ...]":
|
|
1156
|
+
"""Live session names starting with `prefix`, sorted for a stable log.
|
|
1157
|
+
|
|
1158
|
+
`of()` asks about a name the LEDGER supplied; this asks the roster
|
|
1159
|
+
directly, for the caller that can derive a name shape from GitHub
|
|
1160
|
+
alone (see `pr_session_prefix`). Empty is not evidence of anything on
|
|
1161
|
+
its own -- a failed listing has nothing live in it either -- so every
|
|
1162
|
+
caller checks `listed` first, exactly as `of()` does internally.
|
|
1163
|
+
"""
|
|
1164
|
+
return tuple(sorted(n for n in self._live if n.startswith(prefix)))
|
|
1165
|
+
|
|
926
1166
|
|
|
927
1167
|
class SessionBudget:
|
|
928
1168
|
"""The per-pass concurrency budget behind `max_sessions`.
|
|
@@ -1021,20 +1261,58 @@ def _oldest_first(found: list) -> list:
|
|
|
1021
1261
|
)
|
|
1022
1262
|
|
|
1023
1263
|
|
|
1264
|
+
def _name_slug(owner: str, repo: str) -> str:
|
|
1265
|
+
"""`<owner>-<repo>`, squashed to the tmux-safe alphabet.
|
|
1266
|
+
|
|
1267
|
+
The shared root of every worker session name. The owner is folded in
|
|
1268
|
+
because attempt counts are keyed per `owner/repo` (the P3 session-name
|
|
1269
|
+
lesson): `acme/widgets#7` and `other/widgets#7` can both legitimately sit
|
|
1270
|
+
at attempt 1, and a repo-only name would collide their sessions.
|
|
1271
|
+
"""
|
|
1272
|
+
return (
|
|
1273
|
+
f"{re.sub(r'[^A-Za-z0-9-]', '-', owner).strip('-').lower()}-"
|
|
1274
|
+
f"{re.sub(r'[^A-Za-z0-9-]', '-', repo).strip('-').lower()}"
|
|
1275
|
+
)
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def issue_session_prefix(owner: str, repo: str, number: int) -> str:
|
|
1279
|
+
"""`develop-<owner>-<repo>-i<n>-` — the name prefix EVERY developer
|
|
1280
|
+
session on this issue shares, whatever attempt it sits at.
|
|
1281
|
+
|
|
1282
|
+
`pr_session_prefix`'s counterpart for the issue edge, and readable for the
|
|
1283
|
+
same second reason: the wedge self-heal matches it against the pass's tmux
|
|
1284
|
+
roster to find a live `develop-*` worker on an issue WITHOUT consulting
|
|
1285
|
+
the ledger. Every argument is derivable from the PR alone (its owner/repo
|
|
1286
|
+
plus a closing-keyword issue number), which is what makes the check
|
|
1287
|
+
survive a ledger this instance never wrote.
|
|
1288
|
+
"""
|
|
1289
|
+
return f"develop-{_name_slug(owner, repo)}-i{number}-"
|
|
1290
|
+
|
|
1291
|
+
|
|
1024
1292
|
def session_name(issue: Issue, attempt: int) -> str:
|
|
1025
1293
|
"""A tmux-safe developer session name:
|
|
1026
1294
|
`develop-<owner>-<repo>-i<n>-a<attempt>`.
|
|
1027
1295
|
|
|
1028
1296
|
Deterministic on purpose (no nonce, unlike reviewloop): the attempt number
|
|
1029
1297
|
comes from the ledger's row count, so every spawn gets a fresh number and
|
|
1030
|
-
the name cannot collide with a still-live session.
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1298
|
+
the name cannot collide with a still-live session.
|
|
1299
|
+
"""
|
|
1300
|
+
return f"{issue_session_prefix(issue.owner, issue.repo, issue.number)}a{attempt}"
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def pr_session_prefix(pr: PullRequest, edge: str) -> str:
|
|
1304
|
+
"""`<edge>-<owner>-<repo>-pr<n>-` — the name prefix EVERY worker session
|
|
1305
|
+
on this pull request shares, whatever round or attempt it sits at.
|
|
1306
|
+
|
|
1307
|
+
The two PR-side session names below are this plus their tail, so the
|
|
1308
|
+
naming rules are stated once (see `_name_slug`). It is also readable on
|
|
1309
|
+
its own: the wedge self-heal matches it against the pass's tmux roster to
|
|
1310
|
+
find live workers on a PR WITHOUT consulting the ledger -- the one
|
|
1311
|
+
liveness question a lost, foreign, or freshly-migrated ledger cannot
|
|
1312
|
+
answer, and the self-heal ends in a mutation, so it must not depend on
|
|
1313
|
+
local memory being intact.
|
|
1034
1314
|
"""
|
|
1035
|
-
|
|
1036
|
-
repo = re.sub(r"[^A-Za-z0-9-]", "-", issue.repo).strip("-").lower()
|
|
1037
|
-
return f"develop-{owner}-{repo}-i{issue.number}-a{attempt}"
|
|
1315
|
+
return f"{edge}-{_name_slug(pr.owner, pr.repo)}-pr{pr.number}-"
|
|
1038
1316
|
|
|
1039
1317
|
|
|
1040
1318
|
def fix_session_name(pr: PullRequest, round_: int, attempt: int) -> str:
|
|
@@ -1048,9 +1326,7 @@ def fix_session_name(pr: PullRequest, round_: int, attempt: int) -> str:
|
|
|
1048
1326
|
owners' PR #16 can both sit at round 1 attempt 1, and a repo-only name
|
|
1049
1327
|
would collide.
|
|
1050
1328
|
"""
|
|
1051
|
-
|
|
1052
|
-
repo = re.sub(r"[^A-Za-z0-9-]", "-", pr.repo).strip("-").lower()
|
|
1053
|
-
return f"fix-{owner}-{repo}-pr{pr.number}-r{round_}-a{attempt}"
|
|
1329
|
+
return f"{pr_session_prefix(pr, 'fix')}r{round_}-a{attempt}"
|
|
1054
1330
|
|
|
1055
1331
|
|
|
1056
1332
|
def maintain_session_name(pr: PullRequest, attempt: int) -> str:
|
|
@@ -1064,9 +1340,7 @@ def maintain_session_name(pr: PullRequest, attempt: int) -> str:
|
|
|
1064
1340
|
maintain counts are keyed per `owner/repo`, so two owners' PR #16 can
|
|
1065
1341
|
both sit at attempt 1, and a repo-only name would collide.
|
|
1066
1342
|
"""
|
|
1067
|
-
|
|
1068
|
-
repo = re.sub(r"[^A-Za-z0-9-]", "-", pr.repo).strip("-").lower()
|
|
1069
|
-
return f"maintain-{owner}-{repo}-pr{pr.number}-a{attempt}"
|
|
1343
|
+
return f"{pr_session_prefix(pr, 'maintain')}a{attempt}"
|
|
1070
1344
|
|
|
1071
1345
|
|
|
1072
1346
|
def activity_lane(session: str) -> str:
|
|
@@ -1118,10 +1392,11 @@ def activity_claims(body: str, now: "float | None" = None) -> "list[ActivityClai
|
|
|
1118
1392
|
"""Every readable SPAWN record in an activity comment's body.
|
|
1119
1393
|
|
|
1120
1394
|
Spawn records only: a capacity-deferral line explicitly claims nothing (no
|
|
1121
|
-
self-assign, no ledger row, no session -- see _spawn's gate),
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1395
|
+
self-assign, no ledger row, no session -- see _spawn's gate), and neither
|
|
1396
|
+
does a mechanical re-request (it completes a DEAD session's protocol; no
|
|
1397
|
+
session is running afterwards), so reading either as a claim would let an
|
|
1398
|
+
instance park the lane against everybody including itself. The context
|
|
1399
|
+
field is what tells them apart -- see ACTIVITY_NON_SPAWN.
|
|
1125
1400
|
|
|
1126
1401
|
Everything else fails open, one line at a time: a line that does not match
|
|
1127
1402
|
the grammar, or whose stamp is not an instant, or whose stamp is
|
|
@@ -1135,7 +1410,7 @@ def activity_claims(body: str, now: "float | None" = None) -> "list[ActivityClai
|
|
|
1135
1410
|
match = ACTIVITY_LINE_RE.match(raw.strip())
|
|
1136
1411
|
if match is None:
|
|
1137
1412
|
continue
|
|
1138
|
-
if match.group("context").strip().lower().startswith(
|
|
1413
|
+
if match.group("context").strip().lower().startswith(ACTIVITY_NON_SPAWN):
|
|
1139
1414
|
continue
|
|
1140
1415
|
at = parse_activity_timestamp(match.group("ts"))
|
|
1141
1416
|
if at is None:
|
|
@@ -2117,13 +2392,13 @@ class DevWatcher:
|
|
|
2117
2392
|
# is the head it reviewed, so commit_id != head_sha means the head
|
|
2118
2393
|
# moved after the review. A falsy commit_id (older records lack one)
|
|
2119
2394
|
# cannot be checked and falls through, same as reviewloop.
|
|
2395
|
+
# ...unless nobody is: a push whose session died before re-requesting
|
|
2396
|
+
# review wedges the PR between the two daemons' triggers, and the
|
|
2397
|
+
# self-heal below is the only thing that can complete the dead
|
|
2398
|
+
# session's terminal act. See _push_after_review.
|
|
2120
2399
|
newest = reviews[-1]
|
|
2121
2400
|
if newest.commit_id and newest.commit_id != pr.head_sha:
|
|
2122
|
-
return
|
|
2123
|
-
Action.SKIPPED,
|
|
2124
|
-
f"head {pr.head_sha[:8]} is newer than the reviewed commit "
|
|
2125
|
-
f"{newest.commit_id[:8]} — an implementer is already active",
|
|
2126
|
-
)
|
|
2401
|
+
return self._push_after_review(pr, reviews, newest, roster)
|
|
2127
2402
|
|
|
2128
2403
|
verdict = self._verdict(pr, newest)
|
|
2129
2404
|
if verdict == VERDICT_APPROVE:
|
|
@@ -2331,6 +2606,456 @@ class DevWatcher:
|
|
|
2331
2606
|
]
|
|
2332
2607
|
return sorted(mine, key=lambda r: r.submitted_at)
|
|
2333
2608
|
|
|
2609
|
+
# -- the wedge self-heal (issue #61) -----------------------------------
|
|
2610
|
+
|
|
2611
|
+
def _push_after_review(
|
|
2612
|
+
self,
|
|
2613
|
+
pr: PullRequest,
|
|
2614
|
+
reviews: list[Review],
|
|
2615
|
+
newest: Review,
|
|
2616
|
+
roster: "SessionLiveness | None",
|
|
2617
|
+
) -> Decision:
|
|
2618
|
+
"""The head moved past the review. Is an implementer really active --
|
|
2619
|
+
or did the one who pushed die before re-requesting review?
|
|
2620
|
+
|
|
2621
|
+
Both readings produce the same GitHub state, and until now the edge
|
|
2622
|
+
took the optimistic one unconditionally. The pessimistic one is a real,
|
|
2623
|
+
observed, terminal wedge (fahera-mx/studio.alissa.app#242, 2026-07-27):
|
|
2624
|
+
a fix session posted its CR8 replies, pushed its fix commit, and died
|
|
2625
|
+
before `gh pr edit --add-reviewer`. Neither daemon can exit that state
|
|
2626
|
+
-- reviewloop's ONLY entry point is a `review-requested:@me` search and
|
|
2627
|
+
there is no pending request, while this edge reads the push and stands
|
|
2628
|
+
down -- so the PR waits for a human. It waited three hours.
|
|
2629
|
+
|
|
2630
|
+
The remedy is deliberately NOT a session: the round's work is already
|
|
2631
|
+
pushed and its triage already posted, so what is missing is one API
|
|
2632
|
+
call, and spawning a worker to make it would re-enter a branch that
|
|
2633
|
+
needs nothing done to it. The daemon performs the dead session's
|
|
2634
|
+
terminal act itself and lets the pushed head be reviewed as-is.
|
|
2635
|
+
|
|
2636
|
+
Every gate below is a reason to keep the optimistic reading, in
|
|
2637
|
+
increasing order of cost -- config, then the verdict already in hand,
|
|
2638
|
+
then the local ledger, then the tmux roster the pass already holds,
|
|
2639
|
+
and only last the one extra GitHub call (the head's age). The order is
|
|
2640
|
+
not cosmetic: the common case on this branch is a HEALTHY fix session
|
|
2641
|
+
that pushed thirty seconds ago, and it must fall out on a config read
|
|
2642
|
+
and a string compare, never on a network round-trip.
|
|
2643
|
+
"""
|
|
2644
|
+
# The legacy reading, returned unchanged by every gate that declines:
|
|
2645
|
+
# a wedge that is not yet provable is exactly the state this edge has
|
|
2646
|
+
# always described, and nothing downstream may see a new shape for it.
|
|
2647
|
+
active = Decision(
|
|
2648
|
+
Action.SKIPPED,
|
|
2649
|
+
f"head {pr.head_sha[:8]} is newer than the reviewed commit "
|
|
2650
|
+
f"{newest.commit_id[:8]} — an implementer is already active",
|
|
2651
|
+
)
|
|
2652
|
+
if not self.config.rerequest_enabled:
|
|
2653
|
+
return active
|
|
2654
|
+
|
|
2655
|
+
# A push answering an APPROVE (or an unreadable verdict) is ordinary
|
|
2656
|
+
# follow-up work, not an unanswered round: there is nothing for the
|
|
2657
|
+
# reviewer to be re-requested FOR. Only request_changes leaves a
|
|
2658
|
+
# protocol half-finished.
|
|
2659
|
+
if self._verdict(pr, newest) != VERDICT_REQUEST_CHANGES:
|
|
2660
|
+
return active
|
|
2661
|
+
|
|
2662
|
+
# An empty head_sha cannot be re-requested against coherently (it
|
|
2663
|
+
# names no commit to review and would make a nonsense ledger key), and
|
|
2664
|
+
# it is only reachable from a malformed PR payload.
|
|
2665
|
+
if not pr.head_sha:
|
|
2666
|
+
return active
|
|
2667
|
+
|
|
2668
|
+
round_ = len(reviews)
|
|
2669
|
+
attempts = self.state.fix_attempt_count(pr.full_name, pr.number, round_)
|
|
2670
|
+
kind = rerequest_kind(round_, pr.head_sha)
|
|
2671
|
+
|
|
2672
|
+
# Idempotence, checked before anything is spent: the remedy already
|
|
2673
|
+
# fired for this (PR, round, head). Whatever the reviewer did with it
|
|
2674
|
+
# -- honoured it, dismissed it, withdrew from the PR -- this daemon
|
|
2675
|
+
# does not re-request into the same head twice. A NEW head (another
|
|
2676
|
+
# session pushed again) is a new key and gets its own single shot.
|
|
2677
|
+
if self.state.escalated(pr.full_name, pr.number, kind):
|
|
2678
|
+
return Decision(
|
|
2679
|
+
Action.SKIPPED,
|
|
2680
|
+
f"head {pr.head_sha[:8]} is newer than the reviewed commit "
|
|
2681
|
+
f"{newest.commit_id[:8]} and review was already mechanically "
|
|
2682
|
+
f"re-requested once for round {round_} on this head — not "
|
|
2683
|
+
f"re-requesting again (the round is the reviewer's or the "
|
|
2684
|
+
f"operator's now)",
|
|
2685
|
+
attempts,
|
|
2686
|
+
round=round_,
|
|
2687
|
+
)
|
|
2688
|
+
|
|
2689
|
+
# The refusal's mirror of the row above, and the retry bound behind
|
|
2690
|
+
# it: GitHub permanently declined this exact re-request, an operator
|
|
2691
|
+
# was paged on the PR, and re-POSTing every poll would achieve nothing
|
|
2692
|
+
# but write traffic. A fresh push is a fresh key and gets a fresh
|
|
2693
|
+
# attempt -- the refusal is pinned to the head it was refused on, not
|
|
2694
|
+
# to the PR.
|
|
2695
|
+
rejected = rerequest_rejected_kind(round_, pr.head_sha)
|
|
2696
|
+
if self.state.escalated(pr.full_name, pr.number, rejected):
|
|
2697
|
+
return Decision(
|
|
2698
|
+
Action.SKIPPED,
|
|
2699
|
+
f"head {pr.head_sha[:8]} is newer than the reviewed commit "
|
|
2700
|
+
f"{newest.commit_id[:8]} and GitHub permanently refused the "
|
|
2701
|
+
f"mechanical re-request for round {round_} on this head — "
|
|
2702
|
+
f"operator paged on the PR; not retrying",
|
|
2703
|
+
attempts,
|
|
2704
|
+
round=round_,
|
|
2705
|
+
)
|
|
2706
|
+
|
|
2707
|
+
# ALIVE and INDETERMINATE both land here, and both keep the legacy
|
|
2708
|
+
# sentence verbatim: with a live session -- or no evidence either way
|
|
2709
|
+
# -- "an implementer is already active" is exactly what the daemon
|
|
2710
|
+
# believes, and the parenthetical only says on what grounds.
|
|
2711
|
+
liveness, who = self._wedge_liveness(pr, round_, roster)
|
|
2712
|
+
if liveness is not Liveness.DEAD:
|
|
2713
|
+
return Decision(
|
|
2714
|
+
Action.SKIPPED,
|
|
2715
|
+
f"head {pr.head_sha[:8]} is newer than the reviewed commit "
|
|
2716
|
+
f"{newest.commit_id[:8]} — an implementer is already active "
|
|
2717
|
+
f"(session picture: {liveness.value} — {who})",
|
|
2718
|
+
attempts,
|
|
2719
|
+
round=round_,
|
|
2720
|
+
)
|
|
2721
|
+
|
|
2722
|
+
waited = self._head_public_for(pr, newest)
|
|
2723
|
+
grace = self.config.rerequest_grace_seconds
|
|
2724
|
+
if waited is None:
|
|
2725
|
+
return Decision(
|
|
2726
|
+
Action.SKIPPED,
|
|
2727
|
+
f"head {pr.head_sha[:8]} is newer than the reviewed commit "
|
|
2728
|
+
f"{newest.commit_id[:8]} but its push instant could not be "
|
|
2729
|
+
f"read — cannot prove the {int(grace / 60)} min grace has "
|
|
2730
|
+
f"passed; deferring the mechanical re-request",
|
|
2731
|
+
attempts,
|
|
2732
|
+
round=round_,
|
|
2733
|
+
)
|
|
2734
|
+
if waited < grace:
|
|
2735
|
+
return Decision(
|
|
2736
|
+
Action.SKIPPED,
|
|
2737
|
+
f"head {pr.head_sha[:8]} is newer than the reviewed commit "
|
|
2738
|
+
f"{newest.commit_id[:8]} and has only been public "
|
|
2739
|
+
f"{int(waited / 60)} min of the {int(grace / 60)} min grace — "
|
|
2740
|
+
f"a session that just pushed re-requests within seconds; not "
|
|
2741
|
+
f"racing it",
|
|
2742
|
+
attempts,
|
|
2743
|
+
round=round_,
|
|
2744
|
+
)
|
|
2745
|
+
|
|
2746
|
+
return self._rerequest_review(pr, round_, attempts, newest, kind, waited)
|
|
2747
|
+
|
|
2748
|
+
def _wedge_liveness(
|
|
2749
|
+
self, pr: PullRequest, round_: int, roster: "SessionLiveness | None"
|
|
2750
|
+
) -> "tuple[Liveness, str]":
|
|
2751
|
+
"""Is ANY worker session that could be answering this round still
|
|
2752
|
+
running? Returns the reading and the phrase that explains it.
|
|
2753
|
+
|
|
2754
|
+
The three-signal matrix's liveness rule, applied to a whole PR rather
|
|
2755
|
+
than one ledger row -- DEAD requires a listing that SUCCEEDED with
|
|
2756
|
+
every candidate absent from it, and no listing at all is
|
|
2757
|
+
INDETERMINATE, which defers. That direction is the entire safety
|
|
2758
|
+
argument: a tmux hiccup must never manufacture a re-request onto a
|
|
2759
|
+
branch a live session is still pushing to.
|
|
2760
|
+
|
|
2761
|
+
Three candidate sources, because the session that pushed and died is
|
|
2762
|
+
not always a fix session:
|
|
2763
|
+
|
|
2764
|
+
* the fix ledger's newest session for THIS round -- issue #61's
|
|
2765
|
+
literal subject, and the shape #242 took;
|
|
2766
|
+
* every live session on the roster under this PR's own name prefixes
|
|
2767
|
+
(`fix-…-pr<n>-`, `maintain-…-pr<n>-`) -- ledger-independent, so a
|
|
2768
|
+
lost or migrated ledger cannot turn a live worker into a dead one;
|
|
2769
|
+
* the issue-edge lane for every issue this PR's body CLOSES, read BOTH
|
|
2770
|
+
ways -- the ledger's newest session for that issue AND the roster's
|
|
2771
|
+
own `develop-<owner>-<repo>-i<n>-` prefix. Round one is normally
|
|
2772
|
+
answered inline by the `develop-*` session that opened the PR (it
|
|
2773
|
+
blocks in alissa-pr-review, holding its own re-request pending), and
|
|
2774
|
+
when THAT session dies after its push the wedge is identical while
|
|
2775
|
+
the fix ledger is empty. The roster half is what makes this lane as
|
|
2776
|
+
ledger-independent as the two above it: reading a lost ledger as "no
|
|
2777
|
+
session is alive" would re-request over a live implementer
|
|
2778
|
+
mid-round, which is the one thing this gate exists to prevent.
|
|
2779
|
+
|
|
2780
|
+
A candidate the ledger names but the roster does not list is DEAD, and
|
|
2781
|
+
that is the point -- it is the dead session whose act is being
|
|
2782
|
+
completed.
|
|
2783
|
+
|
|
2784
|
+
Known bound, shared with the issue edge's resume and for the same
|
|
2785
|
+
reason: a PR whose body never carried a closing keyword links to no
|
|
2786
|
+
issue, so its `develop-*` lane is not derivable and only the two
|
|
2787
|
+
PR-keyed sources speak for it. DEV_DIRECTIVE mandates `Closes #<n>`,
|
|
2788
|
+
so this is the body of a PR the daemon did not drive; widening it
|
|
2789
|
+
would mean treating any live `develop-*` session anywhere as a reason
|
|
2790
|
+
to defer every PR, which is not evidence about THIS one.
|
|
2791
|
+
"""
|
|
2792
|
+
if roster is None or not roster.listed:
|
|
2793
|
+
return Liveness.INDETERMINATE, (
|
|
2794
|
+
"the session listing is unavailable, so the daemon has no "
|
|
2795
|
+
"evidence either way"
|
|
2796
|
+
)
|
|
2797
|
+
|
|
2798
|
+
candidates: list[str] = []
|
|
2799
|
+
row_session = self.state.newest_fix_spawn_session(
|
|
2800
|
+
pr.full_name, pr.number, round_
|
|
2801
|
+
)
|
|
2802
|
+
if row_session:
|
|
2803
|
+
candidates.append(row_session)
|
|
2804
|
+
for issue_number in closed_issue_numbers(pr.body, pr.owner, pr.repo):
|
|
2805
|
+
dev_session = self.state.newest_spawn_session(
|
|
2806
|
+
pr.full_name, issue_number
|
|
2807
|
+
)
|
|
2808
|
+
if dev_session:
|
|
2809
|
+
candidates.append(dev_session)
|
|
2810
|
+
candidates.extend(
|
|
2811
|
+
roster.live_with_prefix(
|
|
2812
|
+
issue_session_prefix(pr.owner, pr.repo, issue_number)
|
|
2813
|
+
)
|
|
2814
|
+
)
|
|
2815
|
+
for edge in ("fix", "maintain"):
|
|
2816
|
+
candidates.extend(
|
|
2817
|
+
roster.live_with_prefix(pr_session_prefix(pr, edge))
|
|
2818
|
+
)
|
|
2819
|
+
|
|
2820
|
+
alive = sorted(
|
|
2821
|
+
{c for c in candidates if roster.of(c) is Liveness.ALIVE}
|
|
2822
|
+
)
|
|
2823
|
+
if alive:
|
|
2824
|
+
return Liveness.ALIVE, f"{', '.join(alive)} still listed"
|
|
2825
|
+
return Liveness.DEAD, "no worker session for this PR is listed"
|
|
2826
|
+
|
|
2827
|
+
def _head_public_for(
|
|
2828
|
+
self, pr: PullRequest, newest: Review
|
|
2829
|
+
) -> "float | None":
|
|
2830
|
+
"""How long the pushed head has been public, in seconds, or None when
|
|
2831
|
+
that cannot be established.
|
|
2832
|
+
|
|
2833
|
+
The grace window's clock. GitHub publishes no push instant for a pull
|
|
2834
|
+
request head (see `GitHub.commit_time`), so this takes the LATER of
|
|
2835
|
+
two lower bounds: the head commit's own committer date, and the
|
|
2836
|
+
submission of the review the push answers -- the push demonstrably
|
|
2837
|
+
postdates that review, so where a commit was authored long before it
|
|
2838
|
+
was pushed (a rebase, a branch parked for a day) the review's stamp is
|
|
2839
|
+
the tighter and more honest of the two.
|
|
2840
|
+
|
|
2841
|
+
The result is still a lower bound on the true push instant, so the
|
|
2842
|
+
measured age can run slightly LONG and the grace expire slightly
|
|
2843
|
+
early. That is the direction worth documenting rather than hiding:
|
|
2844
|
+
the alternative -- a per-poll walk of the PR's commits or its
|
|
2845
|
+
timeline -- costs more calls to sharpen a number the liveness gate
|
|
2846
|
+
above has already made nearly redundant.
|
|
2847
|
+
|
|
2848
|
+
None whenever the commit cannot be read (CommandError, an absent or
|
|
2849
|
+
malformed date): the caller cannot prove the grace has passed and
|
|
2850
|
+
defers. RateLimited is NOT caught -- like every other GitHub call in
|
|
2851
|
+
the pass it belongs to run_forever's backoff."""
|
|
2852
|
+
try:
|
|
2853
|
+
committed = self.github.commit_time(pr.owner, pr.repo, pr.head_sha)
|
|
2854
|
+
except CommandError as exc:
|
|
2855
|
+
log.warning(
|
|
2856
|
+
"could not read the push instant of %s head %s (%s) — "
|
|
2857
|
+
"deferring the mechanical re-request this poll",
|
|
2858
|
+
pr.pr_slug,
|
|
2859
|
+
pr.head_sha[:8],
|
|
2860
|
+
exc,
|
|
2861
|
+
)
|
|
2862
|
+
return None
|
|
2863
|
+
if committed is None:
|
|
2864
|
+
return None
|
|
2865
|
+
reviewed = parse_github_timestamp(newest.submitted_at)
|
|
2866
|
+
pushed = committed if reviewed is None else max(committed, reviewed)
|
|
2867
|
+
# Clamped at 0.0 for spawn_age's reason: these are wall clocks on two
|
|
2868
|
+
# machines, and a forward-skewed one must read as "just pushed" (the
|
|
2869
|
+
# deferring direction), never as a negative age that compares below
|
|
2870
|
+
# every grace window.
|
|
2871
|
+
return max(0.0, time.time() - pushed)
|
|
2872
|
+
|
|
2873
|
+
def _rerequest_review(
|
|
2874
|
+
self,
|
|
2875
|
+
pr: PullRequest,
|
|
2876
|
+
round_: int,
|
|
2877
|
+
attempts: int,
|
|
2878
|
+
review: Review,
|
|
2879
|
+
kind: str,
|
|
2880
|
+
waited: float,
|
|
2881
|
+
) -> Decision:
|
|
2882
|
+
"""Complete the dead session's terminal act: re-request review from
|
|
2883
|
+
the reviewer whose `request_changes` this round answers, record it,
|
|
2884
|
+
and log one line on the mechanical activity comment.
|
|
2885
|
+
|
|
2886
|
+
The reviewer is `review.author`, exactly as FIX_DIRECTIVE names it --
|
|
2887
|
+
precise, and always resolvable because the review exists.
|
|
2888
|
+
|
|
2889
|
+
Ordering is a delivery contract, not style. The API call goes FIRST
|
|
2890
|
+
and the ledger row lands only on success: a failure must retry next
|
|
2891
|
+
poll, because a remedy marked done that never happened re-wedges the
|
|
2892
|
+
PR permanently with no automation left that can see it. The activity
|
|
2893
|
+
line comes LAST and stays best-effort -- it is telemetry about an act
|
|
2894
|
+
that has already happened, the inverse of a spawn's line (which is
|
|
2895
|
+
published first, because there it is a CLAIM on a lane and a claim
|
|
2896
|
+
after the fact closes nothing). The line is written as a NON-SPAWN
|
|
2897
|
+
context for the same reason: no session exists afterwards for it to
|
|
2898
|
+
claim a lane on behalf of.
|
|
2899
|
+
|
|
2900
|
+
Two instances polling the same PR in the same second can both reach
|
|
2901
|
+
this and both POST; GitHub takes the second as a no-op and the loser's
|
|
2902
|
+
next fetch sees the pending re-request and returns at the guard far
|
|
2903
|
+
above. The cost is a duplicate activity line, which is why this is not
|
|
2904
|
+
gated on the cross-instance claim check -- that check exists to stop
|
|
2905
|
+
duplicate SESSIONS, and there is no session here to duplicate.
|
|
2906
|
+
"""
|
|
2907
|
+
reviewer = review.author
|
|
2908
|
+
log.warning(
|
|
2909
|
+
"WEDGE %s round %d: head %s was pushed %.0f min ago answering "
|
|
2910
|
+
"%s's request_changes, no re-request is pending, and no worker "
|
|
2911
|
+
"session for this PR is listed — the fix session died before its "
|
|
2912
|
+
"terminal act; re-requesting review from %s mechanically (once "
|
|
2913
|
+
"per PR/round/head)",
|
|
2914
|
+
pr.pr_slug,
|
|
2915
|
+
round_,
|
|
2916
|
+
pr.head_sha[:8],
|
|
2917
|
+
waited / 60,
|
|
2918
|
+
reviewer,
|
|
2919
|
+
reviewer,
|
|
2920
|
+
)
|
|
2921
|
+
|
|
2922
|
+
if self.config.dry_run:
|
|
2923
|
+
log.info(
|
|
2924
|
+
"[dry-run] would re-request review from %s on %s",
|
|
2925
|
+
reviewer,
|
|
2926
|
+
pr.pr_slug,
|
|
2927
|
+
)
|
|
2928
|
+
return Decision(
|
|
2929
|
+
Action.IN_FLIGHT,
|
|
2930
|
+
f"[dry-run] would mechanically re-request review from "
|
|
2931
|
+
f"{reviewer} for round {round_} (head {pr.head_sha[:8]} "
|
|
2932
|
+
f"pushed {int(waited / 60)} min ago, its session gone)",
|
|
2933
|
+
attempts,
|
|
2934
|
+
round=round_,
|
|
2935
|
+
)
|
|
2936
|
+
|
|
2937
|
+
# The two failure modes are NOT the same failure, and the issue edge
|
|
2938
|
+
# already settled which response each gets. A CommandError is
|
|
2939
|
+
# transient (a 502, a flaky network): don't record, retry next poll.
|
|
2940
|
+
# A ReviewRequestRejected is PERMANENT and operator-fixable -- GitHub
|
|
2941
|
+
# accepted the request and declined to honour it, for a reason no
|
|
2942
|
+
# amount of retrying changes (no read access, a renamed login, an
|
|
2943
|
+
# author requesting themselves) -- so it takes _escalate_rejection's
|
|
2944
|
+
# doctrine verbatim: escalate-don't-retry. Neither path records the
|
|
2945
|
+
# DONE marker: the remedy did not happen either way.
|
|
2946
|
+
try:
|
|
2947
|
+
self.github.request_review(pr.owner, pr.repo, pr.number, reviewer)
|
|
2948
|
+
except ReviewRequestRejected as exc:
|
|
2949
|
+
return self._escalate_rerequest_rejected(
|
|
2950
|
+
pr, round_, attempts, reviewer, exc
|
|
2951
|
+
)
|
|
2952
|
+
except CommandError as exc:
|
|
2953
|
+
log.error(
|
|
2954
|
+
"could not mechanically re-request review from %s on %s: %s "
|
|
2955
|
+
"— NOT recording it; the self-heal retries next poll",
|
|
2956
|
+
reviewer,
|
|
2957
|
+
pr.pr_slug,
|
|
2958
|
+
exc,
|
|
2959
|
+
)
|
|
2960
|
+
return Decision(
|
|
2961
|
+
Action.SKIPPED,
|
|
2962
|
+
f"the mechanical re-request to {reviewer} did not land "
|
|
2963
|
+
f"({exc}) — transient; not recorded, retrying next poll",
|
|
2964
|
+
attempts,
|
|
2965
|
+
round=round_,
|
|
2966
|
+
)
|
|
2967
|
+
|
|
2968
|
+
self.state.record_escalation(pr.full_name, pr.number, kind)
|
|
2969
|
+
self._append_activity(
|
|
2970
|
+
pr.owner,
|
|
2971
|
+
pr.repo,
|
|
2972
|
+
pr.number,
|
|
2973
|
+
self._activity_line(
|
|
2974
|
+
activity_lane(fix_session_name(pr, round_, 0)),
|
|
2975
|
+
"fix edge",
|
|
2976
|
+
f"{ACTIVITY_REREQUESTED} review from {reviewer} — round "
|
|
2977
|
+
f"{round_}'s session pushed {pr.head_sha[:8]} "
|
|
2978
|
+
f"{int(waited / 60)} min ago and died before re-requesting; "
|
|
2979
|
+
f"completing its terminal act mechanically (no session "
|
|
2980
|
+
f"spawned, once per PR/round/head)",
|
|
2981
|
+
),
|
|
2982
|
+
pr.pr_slug,
|
|
2983
|
+
)
|
|
2984
|
+
|
|
2985
|
+
return Decision(
|
|
2986
|
+
Action.IN_FLIGHT,
|
|
2987
|
+
f"wedge self-healed — review mechanically re-requested from "
|
|
2988
|
+
f"{reviewer} for round {round_} (head {pr.head_sha[:8]} was "
|
|
2989
|
+
f"pushed {int(waited / 60)} min ago and its session is gone); the "
|
|
2990
|
+
f"ball is back in the reviewer's court",
|
|
2991
|
+
attempts,
|
|
2992
|
+
round=round_,
|
|
2993
|
+
)
|
|
2994
|
+
|
|
2995
|
+
def _escalate_rerequest_rejected(
|
|
2996
|
+
self,
|
|
2997
|
+
pr: PullRequest,
|
|
2998
|
+
round_: int,
|
|
2999
|
+
attempts: int,
|
|
3000
|
+
reviewer: str,
|
|
3001
|
+
exc: ReviewRequestRejected,
|
|
3002
|
+
) -> Decision:
|
|
3003
|
+
"""Operator page for a re-request GitHub permanently refused, posted
|
|
3004
|
+
ON the PR -- `_escalate_rejection`'s doctrine on this edge.
|
|
3005
|
+
|
|
3006
|
+
The remedy cannot land and no retry will change that, so the daemon
|
|
3007
|
+
stops trying and tells a human instead. Paging is not optional here
|
|
3008
|
+
for the same reason FIX_CAP_COMMENT is not: with no pending
|
|
3009
|
+
re-request the reviewer daemon's `review-requested:@me` search cannot
|
|
3010
|
+
see this PR at all, so the self-heal was the last automation standing
|
|
3011
|
+
and its silent failure is the wedge the whole feature exists to end.
|
|
3012
|
+
The container log is not where operators find wedges -- the PR is.
|
|
3013
|
+
|
|
3014
|
+
The rejected row is recorded even when the comment fails to post (the
|
|
3015
|
+
cap/assignment trade, spelled out in rerequest_rejected_kind): the
|
|
3016
|
+
row's job is to stop a refused POST, and gating it on a successful
|
|
3017
|
+
comment would leave the daemon writing to that endpoint every poll
|
|
3018
|
+
forever. The DONE marker is NOT recorded -- the remedy never
|
|
3019
|
+
happened, and a later push re-opens the question on its own key.
|
|
3020
|
+
|
|
3021
|
+
NOT dry-run gated, and it does not need to be: the only path here is
|
|
3022
|
+
the live `request_review` call's `except`, and a dry pass returns
|
|
3023
|
+
above it without ever POSTing -- so a dry run cannot be refused,
|
|
3024
|
+
which is exactly right (it performed nothing to be refused)."""
|
|
3025
|
+
kind = rerequest_rejected_kind(round_, pr.head_sha)
|
|
3026
|
+
log.error(
|
|
3027
|
+
"REREQUEST REFUSED %s round %d: GitHub did not honour the "
|
|
3028
|
+
"mechanical re-request of %s on head %s (%s) — permanent and "
|
|
3029
|
+
"operator-fixable; paging the operator on the PR and not "
|
|
3030
|
+
"retrying on this head",
|
|
3031
|
+
pr.pr_slug,
|
|
3032
|
+
round_,
|
|
3033
|
+
reviewer,
|
|
3034
|
+
pr.head_sha[:8],
|
|
3035
|
+
exc,
|
|
3036
|
+
)
|
|
3037
|
+
|
|
3038
|
+
body = REREQUEST_REJECTED_COMMENT.format(
|
|
3039
|
+
round=round_, head=pr.head_sha[:8], reviewer=reviewer
|
|
3040
|
+
)
|
|
3041
|
+
try:
|
|
3042
|
+
self.github.comment(pr.owner, pr.repo, pr.number, body)
|
|
3043
|
+
except CommandError as comment_exc:
|
|
3044
|
+
log.error(
|
|
3045
|
+
"could not post the re-request-refused comment on %s: %s",
|
|
3046
|
+
pr.pr_slug,
|
|
3047
|
+
comment_exc,
|
|
3048
|
+
)
|
|
3049
|
+
self.state.record_escalation(pr.full_name, pr.number, kind)
|
|
3050
|
+
return Decision(
|
|
3051
|
+
Action.ESCALATED,
|
|
3052
|
+
f"GitHub permanently refused the mechanical re-request to "
|
|
3053
|
+
f"{reviewer} for round {round_} on head {pr.head_sha[:8]} "
|
|
3054
|
+
f"({exc}) — operator paged on the PR; not retrying on this head",
|
|
3055
|
+
attempts,
|
|
3056
|
+
round=round_,
|
|
3057
|
+
)
|
|
3058
|
+
|
|
2334
3059
|
def _escalate_fix_cap(self, pr: PullRequest, round_: int, attempts: int) -> None:
|
|
2335
3060
|
"""Operator page for a fix cap-out, posted ON the PR (the issue
|
|
2336
3061
|
edge's _escalate shape): log, comment (dry-run gated), and record
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.7.0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.6.3
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|