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.
Files changed (29) hide show
  1. {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
  2. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/__main__.py +31 -1
  3. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/config.py +49 -0
  4. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/ghclient.py +102 -0
  5. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/loop.py +755 -30
  6. alissa_tools_github_devloop-0.7.0/src/main/alissa/tools/github/devloop/version +1 -0
  7. {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
  8. alissa_tools_github_devloop-0.6.3/src/main/alissa/tools/github/devloop/version +0 -1
  9. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/MANIFEST.in +0 -0
  10. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/README.md +0 -0
  11. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/requirements.txt +0 -0
  12. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/setup.cfg +0 -0
  13. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/setup.py +0 -0
  14. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  15. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  16. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  17. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/state.py +0 -0
  18. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/version.py +0 -0
  19. {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
  20. {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
  21. {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
  22. {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
  23. {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
  24. {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
  25. {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
  26. {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
  27. {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
  28. {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
  29. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.6.3
3
+ Version: 0.7.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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. Then drive the review handoff with "
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). The ORDER is "
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 closes_issue(body: str, owner: str, repo: str, number: int) -> bool:
800
- """Does `body` claim to close THIS issue with a GitHub closing keyword?
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
- pattern = (
1023
+ return (
818
1024
  r"\b(?:" + CLOSING_KEYWORD + r")\b[ \t]*:?[ \t]*"
819
1025
  + ref
820
- + str(number)
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. The owner is folded in
1031
- because the attempt count is keyed per `owner/repo` -- two owners' ledgers
1032
- count independently, so `acme/widgets#7` and `other/widgets#7` can both
1033
- legitimately sit at attempt 1 and a repo-only name would collide.
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
- owner = re.sub(r"[^A-Za-z0-9-]", "-", issue.owner).strip("-").lower()
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
- owner = re.sub(r"[^A-Za-z0-9-]", "-", pr.owner).strip("-").lower()
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
- owner = re.sub(r"[^A-Za-z0-9-]", "-", pr.owner).strip("-").lower()
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), so reading
1122
- one as a claim would let a deferring instance park the lane against
1123
- everybody including itself. The context field is what tells them apart,
1124
- and `deferred` is the only context that is not a spawn.
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(ACTIVITY_DEFERRED):
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 Decision(
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.6.3
3
+ Version: 0.7.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera