alissa-tools-github-devloop 0.8.17__tar.gz → 0.8.19__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 (35) hide show
  1. {alissa_tools_github_devloop-0.8.17/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.19}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/__main__.py +11 -0
  3. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/config.py +72 -0
  4. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/ghclient.py +11 -0
  5. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/loop.py +548 -100
  6. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/loop_events.py +6 -2
  7. alissa_tools_github_devloop-0.8.19/src/main/alissa/tools/github/devloop/version +1 -0
  8. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  9. alissa_tools_github_devloop-0.8.17/src/main/alissa/tools/github/devloop/version +0 -1
  10. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/LICENSE +0 -0
  11. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/MANIFEST.in +0 -0
  12. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/NOTICE +0 -0
  13. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/README.md +0 -0
  14. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/requirements.txt +0 -0
  15. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/setup.cfg +0 -0
  16. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/setup.py +0 -0
  17. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  18. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  19. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  20. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  21. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  22. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  23. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/state.py +0 -0
  24. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/version.py +0 -0
  25. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  26. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  27. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  28. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  29. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  30. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  31. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  32. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  33. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  34. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  35. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/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.8.17
3
+ Version: 0.8.19
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -256,6 +256,16 @@ def build_parser() -> argparse.ArgumentParser:
256
256
  help="never complete a draft's handoff mechanically — an unhanded-off "
257
257
  "draft waits for an operator (also narrows the PR search to non-drafts)",
258
258
  )
259
+ over.add_argument(
260
+ "--wedge-minutes",
261
+ type=int,
262
+ metavar="MINUTES",
263
+ help="wedge detection: kill an ALIVE session behind its own still-"
264
+ "draft, never-handed-off PR once its activity footprint (spawn, last "
265
+ "head commit, last PR update) is older than this, so the next pass "
266
+ "resumes it as attempt k+1; 0 = off (the default); floored at "
267
+ "stale_minutes",
268
+ )
259
269
  over.add_argument(
260
270
  "--orphan-grace-minutes",
261
271
  type=int,
@@ -401,6 +411,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
401
411
  "maintain_label": args.maintain_label,
402
412
  "maintain_enabled": args.maintain_enabled,
403
413
  "resume_enabled": args.resume_enabled,
414
+ "wedge_minutes": args.wedge_minutes,
404
415
  "rerequest_enabled": args.rerequest_enabled,
405
416
  "rerequest_grace_minutes": args.rerequest_grace_minutes,
406
417
  "orphan_sweep_enabled": args.orphan_sweep_enabled,
@@ -28,12 +28,15 @@ through to the config rather than fail the daemon's boot on a mode of `''`.
28
28
  from __future__ import annotations
29
29
 
30
30
  import json
31
+ import logging
31
32
  import os
32
33
  import re
33
34
  from dataclasses import dataclass, field
34
35
  from pathlib import Path
35
36
  from typing import Any, Mapping
36
37
 
38
+ log = logging.getLogger(__name__)
39
+
37
40
  # What to do when a labeled issue has no matching Alissa origin task (the
38
41
  # label may have been applied by hand, outside the Alissa task flow).
39
42
  ON_MISSING_WARN = "warn_and_spawn" # spawn, but log loudly
@@ -147,6 +150,7 @@ CONFIG_KEYS = (
147
150
  "maintain_label",
148
151
  "maintain_enabled",
149
152
  "resume_enabled",
153
+ "wedge_minutes",
150
154
  "rerequest_enabled",
151
155
  "rerequest_grace_minutes",
152
156
  "orphan_sweep_enabled",
@@ -452,6 +456,33 @@ class Config:
452
456
  # operator who wants a dead-with-PR lane to wait for a human.
453
457
  resume_enabled: bool = True
454
458
 
459
+ # Wedge detection on the issue edge (issue #110): the activity-footprint
460
+ # window behind the resume above. Liveness measures session EXISTENCE,
461
+ # not work: a worker wedged at an interactive prompt reads ALIVE forever,
462
+ # and behind a still-DRAFT PR (never handed off -- nothing else will ever
463
+ # move it) the ALIVE defer above is an infinite one (observed on
464
+ # fahera-mx/studio.alissa.app#1121: 31+ hours behind draft PR #1123,
465
+ # one stalled page, then silence). With a positive value, an ALIVE
466
+ # session whose lane is otherwise RESUMABLE (exactly the `_resumable_pr`
467
+ # admission -- still a draft, no reviewer, self-authored, daemon-shaped
468
+ # head, a body that CLOSES the issue) and whose activity footprint -- the
469
+ # NEWEST of the ledger row's spawn, the PR's last update, and the head
470
+ # commit's date -- is older than this many minutes is killed (the ledger
471
+ # row's own session name, the same `alissa tmux kill` a healthy handoff
472
+ # ends with), so the next pass reads it DEAD and the ordinary resume path
473
+ # above takes over as attempt k+1, counting toward attempt_cap like any
474
+ # resume. 0 (the default) DISABLES it: absent or 0 preserves the ALIVE
475
+ # defer bit for bit, so a deployment opts in explicitly. Never fires on
476
+ # INDETERMINATE (absence of evidence keeps the defer), never on a PR that
477
+ # left draft or has a reviewer engaged, and never with resume_enabled
478
+ # false (a kill whose resume cannot fire would only orphan the lane).
479
+ # Floored at stale_minutes: a wedge window shorter than the staleness
480
+ # window is a misconfiguration (the branch this runs on is only reached
481
+ # past stale_minutes), so `build()` clamps it up and warns rather than
482
+ # failing the boot. Read `wedge_seconds`, which applies the same floor,
483
+ # never this field.
484
+ wedge_minutes: int = 0
485
+
455
486
  # The wedge self-heal (issue #61): the fix edge's crash-recovery net under
456
487
  # FIX_DIRECTIVE's terminal step. A fix session that posted its triage
457
488
  # replies and PUSHED, then died before re-requesting review, leaves the PR
@@ -642,6 +673,22 @@ class Config:
642
673
  ready-and-request a draft opened seconds ago on a default config."""
643
674
  return (self.orphan_grace_minutes or self.stale_minutes) * 60
644
675
 
676
+ @property
677
+ def wedge_seconds(self) -> int:
678
+ """The activity-footprint window behind which an ALIVE session on a
679
+ resumable draft is killed for resume, in seconds -- 0 means the wedge
680
+ path is OFF. Read this, never the raw field: unlike the grace knobs
681
+ above, 0 here is not a sentinel for the stale window but the OFF
682
+ switch, and a positive value is floored at stale_minutes (the branch
683
+ this gates is only reached past the stale window, so a shorter
684
+ window would fire on its first evaluation). `build()` already clamps
685
+ the field and warns; the floor is applied again here so a Config
686
+ assembled without `build()` (tests, `dataclasses.replace`) can never
687
+ read a window below the staleness one."""
688
+ if self.wedge_minutes <= 0:
689
+ return 0
690
+ return max(self.wedge_minutes, self.stale_minutes) * 60
691
+
645
692
  @property
646
693
  def janitor_grace_seconds(self) -> int:
647
694
  """How long an anchor must have been terminal before its workspace may
@@ -904,6 +951,30 @@ class Config:
904
951
  f"got {orphan_grace}"
905
952
  )
906
953
 
954
+ # The wedge window (issue #110). 0 is the OFF switch, not a fallback
955
+ # sentinel, so the floor is 0 -- a negative value is the only invalid
956
+ # one. A positive value below stale_minutes is a misconfiguration
957
+ # (the wedge branch is only reached past the stale window), but not
958
+ # one worth failing the boot over: clamp it up to stale_minutes and
959
+ # say so, exactly as the issue's contract asks.
960
+ wedge = int(raw.get("wedge_minutes", cls.wedge_minutes))
961
+ if wedge < 0:
962
+ raise ValueError(
963
+ f"wedge_minutes must be >= 0 (0 = wedge detection off), "
964
+ f"got {wedge}"
965
+ )
966
+ if 0 < wedge < stale:
967
+ log.warning(
968
+ "wedge_minutes=%d is below stale_minutes=%d — a wedge window "
969
+ "shorter than the staleness window cannot be honoured (the "
970
+ "wedge branch is only reached past the stale window); "
971
+ "clamping wedge_minutes up to %d",
972
+ wedge,
973
+ stale,
974
+ stale,
975
+ )
976
+ wedge = stale
977
+
907
978
  # The janitor's grace, the reaper's sentinel one rung longer (0 = use
908
979
  # reap_grace_minutes). Negative is the only invalid value, and it is
909
980
  # the dangerous one: it would license reclaiming a workspace the
@@ -999,6 +1070,7 @@ class Config:
999
1070
  resume_enabled=bool(
1000
1071
  raw.get("resume_enabled", cls.resume_enabled)
1001
1072
  ),
1073
+ wedge_minutes=wedge,
1002
1074
  rerequest_enabled=bool(
1003
1075
  raw.get("rerequest_enabled", cls.rerequest_enabled)
1004
1076
  ),
@@ -146,6 +146,16 @@ class PullRequest:
146
146
  # base nobody observed. Defaulted like node_id and closed_at -- the edges
147
147
  # that only spawn never ask.
148
148
  base_ref: str = ""
149
+ # When GitHub last touched this PR (`updated_at`), unix seconds; None when
150
+ # the payload carried no readable stamp. GitHub bumps it on every event
151
+ # that lands on the PR -- a push to the head branch (synchronize), an
152
+ # edit, a comment, a label, a review -- so it is the cheapest upper bound
153
+ # on "when did anything last happen here". The issue edge's wedge
154
+ # detection (issue #110) reads it as one leg of the activity footprint
155
+ # behind an ALIVE session; it rides the `pull_request()` fetch the resume
156
+ # probe already pays. Defaulted like the fields above it: the other edges
157
+ # and their fakes never ask.
158
+ updated_at: "int | None" = None
149
159
 
150
160
  @property
151
161
  def full_name(self) -> str:
@@ -201,6 +211,7 @@ def _pull_request_from(
201
211
  node_id=data.get("node_id") or "",
202
212
  closed_at=parse_github_timestamp(data.get("closed_at")),
203
213
  base_ref=base.get("ref") or "",
214
+ updated_at=parse_github_timestamp(data.get("updated_at")),
204
215
  )
205
216
 
206
217
 
@@ -27,11 +27,15 @@ and the merge, a finished session's PR sits open for the whole review loop.
27
27
  DEAD alone would therefore fire on the modal happy path. So the resume takes
28
28
  a FOURTH signal from the PR the session left behind: the handoff publishes
29
29
  itself (`gh pr ready` flips the draft off, then the re-request puts a
30
- reviewer on it), which makes a PR that is STILL A DRAFT with NO reviewer
31
- requested the one state that says "this session never handed off". That test
32
- also keeps the edges disjoint -- the moment a PR leaves draft it belongs to
33
- the review lifecycle and the fix edge owns it exclusively, so no `develop-*`
34
- resume can land on a branch a `fix-*` session is already working.
30
+ reviewer on it), which makes a PR that is STILL A DRAFT the one state that
31
+ says "this session never handed off" -- whatever reviewer is on it, because
32
+ a review request on a draft publishes nothing (the reviewer daemon only
33
+ reviews non-draft PRs and the fix edge refuses drafts, so nothing downstream
34
+ can ever move a draft; studio.alissa.app#1204 sat 80 minutes behind exactly
35
+ that shape). That test also keeps the edges disjoint -- the moment a PR
36
+ leaves draft it belongs to the review lifecycle and the fix edge owns it
37
+ exclusively, so no `develop-*` resume can land on a branch a `fix-*` session
38
+ is already working.
35
39
 
36
40
  The signals so far all describe the PR's STATE, and a sibling task's young
37
41
  draft satisfies every one of them; a cross-reference is raised by ANY mention
@@ -72,6 +76,30 @@ behind `resume_enabled`: that switch gates an ACTION the daemon gained, and
72
76
  turning it off restores a conservative legacy; this is a guard against a
73
77
  DOUBLE SPAWN, and no version of "off" for it is safe.
74
78
 
79
+ ALIVE has a blind spot of its own: liveness measures session EXISTENCE, not
80
+ work. A worker wedged at an interactive prompt is a live process and a dead
81
+ worker, and it reads ALIVE forever. Behind a handed-off PR that is harmless
82
+ (the review lifecycle moves the PR, and the reaper collects the session once
83
+ the PR is terminal), but behind a STILL-DRAFT PR nothing else will ever move
84
+ it: pre-handoff the dev session owns the pace, the "review loop can outlast
85
+ stale_minutes" rationale does not apply, and the deferral above is infinite
86
+ (studio.alissa.app#1121 sat 31+ hours behind draft PR #1123, paged once,
87
+ then silence). So the issue edge has an OPT-IN fifth signal on exactly that
88
+ branch -- ALIVE, and `_resumable_pr` admits the PR -- an ACTIVITY FOOTPRINT:
89
+ the newest of the ledger row's spawn, the PR's last update, and the head
90
+ commit's date. A footprint older than `wedge_minutes` (0 = off, the default)
91
+ is a session that exists and does nothing, and the daemon KILLS it -- the
92
+ ledger row's own session name only, the same `alissa tmux kill` a healthy
93
+ handoff ends with -- so the next pass reads it DEAD and the ordinary resume
94
+ above takes over as attempt k+1, counting toward attempt_cap like any
95
+ resume. Nothing about the kill is new machinery on the resume side: it
96
+ manufactures the one signal the matrix already acts on. The invariants are
97
+ the matrix's own -- never on INDETERMINATE (absence of evidence keeps the
98
+ defer), never on a PR that left draft (the wedge sits strictly behind
99
+ `_resumable_pr`), never with `resume_enabled` false (a
100
+ kill whose resume cannot fire would only orphan the lane), and never a
101
+ roster sweep.
102
+
75
103
  The same pass then runs the REVIEW-RESPONSE edge (fix_rounds_enabled): open
76
104
  PRs authored by the token identity whose newest substantive review verdict is
77
105
  request_changes, with no pending re-request and no push since that review --
@@ -149,7 +177,9 @@ self-heal needs an unanswered review, and the issue-edge resume keys off an
149
177
  OPEN labeled issue, which a draft replayed after its issue merged does not
150
178
  have (fahera-mx/studio.alissa.app#279, 2026-07-28: an operator found it by
151
179
  accident). So the PR search is widened to drafts and every draft is asked the
152
- handoff question instead of being skipped: self-authored, no reviewer on it,
180
+ handoff question instead of being skipped: self-authored (a reviewer already
181
+ requested on it is NOT a reason to skip -- a request on a draft arms nothing,
182
+ so the sweep readies it and requests only the reviewers still missing),
153
183
  a daemon-shaped head ref naming an implementation task (a `TASK-<n>-<DESC>`
154
184
  branch or a task ref in the body -- the provenance test, `closes_issue`'s
155
185
  role here), no live worker session for the PR (the SAME liveness rule --
@@ -717,8 +747,9 @@ RESUME_DIRECTIVE = (
717
747
  "previous developer session on GitHub issue {issue_url} died AFTER "
718
748
  "opening its pull request but BEFORE handing it off for review — the "
719
749
  "daemon confirmed its session `{dead_session}` is gone from the machine, "
720
- "and its DRAFT pull request {pr_url} is still open with no reviewer on "
721
- "it. This is attempt {attempt} of {cap}, and your job is to FINISH THAT "
750
+ "and its DRAFT pull request {pr_url} is still open (a reviewer may "
751
+ "already be requested on it — that is not a handoff; the ready flip is). "
752
+ "This is attempt {attempt} of {cap}, and your job is to FINISH THAT "
722
753
  "PULL REQUEST, not to start the work over. "
723
754
  "{task_instruction} "
724
755
  "FIRST hydrate the context you did not live through: read the issue (its "
@@ -1046,14 +1077,24 @@ ACTIVITY_REREQUESTED = "re-requested"
1046
1077
  # including the one that wrote it.
1047
1078
  ACTIVITY_HANDED_OFF = "handed off"
1048
1079
 
1080
+ # The activity-log context prefix for a wedge kill (see ESCALATION_WEDGED).
1081
+ # Non-spawn like the three above, and more so: the line records that the
1082
+ # lane's session was KILLED, the exact opposite of a running session. A reader
1083
+ # that took it for a spawn claim would park the lane behind a phantom for up
1084
+ # to a stale_minutes window -- against a peer instance during an overlapping
1085
+ # deploy, or this daemon after ledger loss, the cases external_claim exists
1086
+ # for -- delaying the very resume the kill was issued to unblock.
1087
+ ACTIVITY_WEDGE_KILLED = "wedge-killed"
1088
+
1049
1089
  # Line contexts that record something OTHER than a spawn. `activity_claims`
1050
1090
  # skips them: a claim is a promise that a session is running on the lane, and
1051
- # neither a capacity deferral, a mechanical re-request, nor a mechanical
1052
- # handoff makes one.
1091
+ # neither a capacity deferral, a mechanical re-request, a mechanical handoff,
1092
+ # nor a wedge kill makes one.
1053
1093
  ACTIVITY_NON_SPAWN = (
1054
1094
  ACTIVITY_DEFERRED,
1055
1095
  ACTIVITY_REREQUESTED,
1056
1096
  ACTIVITY_HANDED_OFF,
1097
+ ACTIVITY_WEDGE_KILLED,
1057
1098
  )
1058
1099
 
1059
1100
  # The reader's grammar for a line `_activity_line` wrote: bullet, UTC
@@ -1110,6 +1151,16 @@ ESCALATION_FIX_STALLED = "fix-stalled"
1110
1151
  # neither ping may silence the other.
1111
1152
  ESCALATION_LIVE_STALLED = "live-stalled"
1112
1153
 
1154
+ # The wedge kill's once-per-episode marker (see wedge_kind). Not an operator
1155
+ # page -- the daemon acted instead of asking -- but, like ESCALATION_REREQUEST,
1156
+ # a once-per-key act the `escalations` table is the ledger of: it dedupes the
1157
+ # activity-comment line (a kill that has to be repeated must not grow the
1158
+ # comment by one line per poll) and it is what the loop-events emitter turns
1159
+ # into an `escalation.wedged` event. The KILL itself is never deduped on it:
1160
+ # a session that survives its kill is still wedged, and re-killing it every
1161
+ # poll is the remedy, not a bug.
1162
+ ESCALATION_WEDGED = "wedged"
1163
+
1113
1164
  # The maintenance edge's cap-out kind prefix. The `maintain-` prefix keeps
1114
1165
  # rows from ever colliding with the issue edge's `cap` when an issue and a
1115
1166
  # PR share a number; the epoch suffix is maintain_cap_kind's job below.
@@ -1425,6 +1476,17 @@ def live_stalled_kind(attempt: int) -> str:
1425
1476
  return f"{ESCALATION_LIVE_STALLED}:a{attempt}"
1426
1477
 
1427
1478
 
1479
+ def wedge_kind(attempt: int) -> str:
1480
+ """The ledger kind for ONE wedge-kill episode's activity line.
1481
+
1482
+ stalled_kind's episode reasoning again: every attempt can wedge behind
1483
+ its own draft, and attempt k's row must not silence attempt k+1's line.
1484
+ Only the activity line is keyed on it -- the kill is repeated whenever
1485
+ the footprint still warrants it (see ESCALATION_WEDGED).
1486
+ """
1487
+ return f"{ESCALATION_WEDGED}:a{attempt}"
1488
+
1489
+
1428
1490
  def deferral_activity_kind(session: str) -> str:
1429
1491
  """The activity-log kind that dedupes ONE capacity-deferral episode's line.
1430
1492
 
@@ -2356,9 +2418,10 @@ def activity_claims(body: str, now: "float | None" = None) -> "list[ActivityClai
2356
2418
  Spawn records only: a capacity-deferral line explicitly claims nothing (no
2357
2419
  self-assign, no ledger row, no session -- see _spawn's gate), and neither
2358
2420
  does a mechanical re-request (it completes a DEAD session's protocol; no
2359
- session is running afterwards), so reading either as a claim would let an
2360
- instance park the lane against everybody including itself. The context
2361
- field is what tells them apart -- see ACTIVITY_NON_SPAWN.
2421
+ session is running afterwards) nor a wedge kill (it records that the
2422
+ lane's session was just KILLED), so reading any of them as a claim would
2423
+ let an instance park the lane against everybody including itself. The
2424
+ context field is what tells them apart -- see ACTIVITY_NON_SPAWN.
2362
2425
 
2363
2426
  Everything else fails open, one line at a time: a line that does not match
2364
2427
  the grammar, or whose stamp is not an instant, or whose stamp is
@@ -2573,11 +2636,16 @@ class DevWatcher:
2573
2636
  # the session behind the open PR is not running -- but NOT that it was
2574
2637
  # abandoned, because a healthy handoff ends in `alissa tmux kill`. So
2575
2638
  # DEAD only opens the question, and `_resumable_pr` answers it: a
2576
- # still-draft, no-reviewer PR that CLOSES this issue is one of this
2639
+ # still-draft PR that CLOSES this issue (a reviewer on the draft
2640
+ # changes nothing -- a draft is never handed off) is one of this
2577
2641
  # issue's own sessions that was never handed off, and it
2578
2642
  # is the one case the deferral was always wrong about -- resumed as
2579
2643
  # attempt k+1 with RESUME_DIRECTIVE instead of parking the lane behind
2580
- # an operator ping. Everything else -- ALIVE, INDETERMINATE, and DEAD
2644
+ # an operator ping. (When the orphan sweep is on it runs EARLIER in
2645
+ # the same pass and readies such a draft mechanically, so the fetch
2646
+ # here sees a non-draft PR and defers -- the sweep's handoff wins
2647
+ # over a resume without any coordination between the edges.)
2648
+ # Everything else -- ALIVE, INDETERMINATE, and DEAD
2581
2649
  # behind a PR already in the review lifecycle -- keeps the floored
2582
2650
  # defer: past STALLED_DEFER_MULTIPLE stale windows it posts a
2583
2651
  # "stalled" operator comment (this deferral sits above the cap check,
@@ -2607,6 +2675,32 @@ class DevWatcher:
2607
2675
  liveness = self.session_liveness(row_session, roster)
2608
2676
  if self.config.resume_enabled and liveness is Liveness.DEAD:
2609
2677
  resume_pr = self._resumable_pr(issue, linked)
2678
+ elif (
2679
+ self.config.resume_enabled
2680
+ and self.config.wedge_seconds
2681
+ and liveness is Liveness.ALIVE
2682
+ and row_session
2683
+ ):
2684
+ # Wedge detection (issue #110), the opt-in escape from
2685
+ # the infinite ALIVE defer. Reached ONLY on ALIVE -- a
2686
+ # SUCCESSFUL listing that carries the row's own name --
2687
+ # never on INDETERMINATE, whose absence of evidence keeps
2688
+ # the defer exactly as the Liveness docstring argues.
2689
+ # `_wedge_candidate` sits strictly behind `_resumable_pr`
2690
+ # (a handed-off PR belongs to the review lifecycle) and
2691
+ # then asks the one question liveness cannot: has this
2692
+ # lane shown ANY activity inside the wedge window? If
2693
+ # not, the session is killed here and nothing else
2694
+ # happens this pass: the next pass reads it DEAD and the
2695
+ # ordinary resume branch above does the rest. A fresh
2696
+ # footprint, or a footprint that cannot be read, falls
2697
+ # through to the floored defer below, unchanged.
2698
+ wedged = self._wedge_candidate(issue, linked, age)
2699
+ if wedged is not None:
2700
+ wedged_pr, footprint = wedged
2701
+ return self._kill_wedged(
2702
+ issue, attempts, row_session, wedged_pr, footprint
2703
+ )
2610
2704
  if resume_pr is not None:
2611
2705
  # Fall through to the cap check and the spawn below: a
2612
2706
  # resume is an ORDINARY attempt (it counts toward
@@ -2626,9 +2720,9 @@ class DevWatcher:
2626
2720
  # an operator reading the log must not have to guess.
2627
2721
  why = (
2628
2722
  "no linked PR is resumable (a handed-off PR — out of "
2629
- "draft, or with a reviewer engaged — belongs to the "
2630
- "review lifecycle, not to a resume; and a PR that "
2631
- "only MENTIONS this issue is another task's work)"
2723
+ "draft — belongs to the review lifecycle, not to a "
2724
+ "resume; a PR that only MENTIONS this issue is "
2725
+ "another task's work)"
2632
2726
  if liveness is Liveness.DEAD
2633
2727
  and self.config.resume_enabled
2634
2728
  else "work may be alive (the review loop can outlast "
@@ -2998,12 +3092,23 @@ class DevWatcher:
2998
3092
 
2999
3093
  What separates the two is the PR the session left behind, because the
3000
3094
  handoff PUBLISHES itself: `gh pr ready` flips the draft off, then the
3001
- re-request puts a reviewer on it. So a PR that is STILL A DRAFT with
3002
- NO reviewer requested is one its session never handed off -- the only
3003
- state a resume is owed. (A session that died in the narrow window
3004
- between the ready flip and the re-request reads handed-off and keeps
3005
- the legacy floored defer: conservative on purpose, since that is also
3006
- exactly what a live review loop looks like from here.)
3095
+ re-request puts a reviewer on it. So a PR that is STILL A DRAFT is one
3096
+ its session never handed off -- the only state a resume is owed --
3097
+ and a reviewer requested on that draft does not change the reading: a
3098
+ request on a draft arms nothing (the reviewer daemon reviews only
3099
+ non-draft PRs, the fix edge refuses drafts), so no automation
3100
+ downstream can ever move it. The two half-states a session can die in
3101
+ are therefore read differently: (a) READY + NO reviewer -- the ready
3102
+ flip landed, the request did not -- reads handed-off here and keeps
3103
+ the legacy floored defer, conservative on purpose since that is also
3104
+ exactly what a live review loop looks like from this edge; where this
3105
+ daemon's own sweep made that flip, the `orphan-ready@<head>` row lets
3106
+ the PR edge's `_finish_orphan_handoff` complete it. (b) DRAFT +
3107
+ reviewer -- the request landed, the flip did not (studio#1204: 80
3108
+ minutes behind a draft with `alissa-app` on it) -- is resumable when
3109
+ the session is DEAD, exactly like a review-less draft, and deferred
3110
+ while ALIVE; with the orphan sweep on, that sweep readies it earlier
3111
+ in the same pass, so the resume yields to the mechanical handoff.
3007
3112
 
3008
3113
  That same test settles the cross-edge race: the moment a PR leaves
3009
3114
  draft it belongs to the review lifecycle, and the fix edge -- which
@@ -3080,16 +3185,24 @@ class DevWatcher:
3080
3185
  # still a PR whose base can strand it, and the guard's own author
3081
3186
  # test is what keeps it off PRs that are not ours.
3082
3187
  self._check_stacked_base(pr)
3083
- if not pr.draft or pr.requested_reviewers:
3188
+ if not pr.draft:
3084
3189
  log.info(
3085
- "%s: %s has been handed off (draft=%s, reviewers=%s) — "
3086
- "not a resume candidate; the review lifecycle owns it",
3190
+ "%s: %s has been handed off (out of draft, reviewers=%s) "
3191
+ "— not a resume candidate; the review lifecycle owns it",
3087
3192
  issue.issue_slug,
3088
3193
  url,
3089
- pr.draft,
3090
3194
  ", ".join(pr.requested_reviewers) or "none",
3091
3195
  )
3092
3196
  continue
3197
+ if pr.requested_reviewers:
3198
+ log.info(
3199
+ "%s: %s is still a DRAFT with %s requested — a review "
3200
+ "request on a draft is not a handoff (the ready flip is), "
3201
+ "so this PR is a resume candidate",
3202
+ issue.issue_slug,
3203
+ url,
3204
+ ", ".join(pr.requested_reviewers),
3205
+ )
3093
3206
  if pr.author != self.github.login:
3094
3207
  log.warning(
3095
3208
  "%s: %s is authored by %r, not the developer identity — "
@@ -3167,6 +3280,206 @@ class DevWatcher:
3167
3280
  )
3168
3281
  return False
3169
3282
 
3283
+ def _wedge_candidate(
3284
+ self, issue: Issue, linked: "list[str]", spawn_age: float
3285
+ ) -> "tuple[PullRequest, float] | None":
3286
+ """The wedge decision's evidence: the one resumable PR behind this
3287
+ ALIVE session and the age of the lane's activity footprint, or None
3288
+ when the lane must keep its defer.
3289
+
3290
+ Two gates, in order. First `_resumable_pr`, verbatim -- the wedge
3291
+ path inherits every admission test the resume has (still a draft,
3292
+ self-authored, daemon-shaped head, a body that CLOSES this issue,
3293
+ exactly one candidate; a reviewer requested on the draft is not a
3294
+ handoff and does not exclude it), because the kill it licenses is
3295
+ only safe where the resume that follows it is: a PR that has left
3296
+ draft belongs to the review lifecycle, and a live review loop behind
3297
+ an ALIVE session is the healthy shape this daemon was built around.
3298
+ Then the footprint (`_footprint_age`):
3299
+ None means unreadable, and unreadable keeps the defer -- the same
3300
+ fail-safe every other presumed-dead decision here makes.
3301
+
3302
+ Cost: nothing beyond what the DEAD branch already pays for the same
3303
+ lane -- `_resumable_pr`'s one GET per linked PR -- except the head
3304
+ commit fetch, which `_footprint_age` only makes once the cheap legs
3305
+ have already run past the window."""
3306
+ pr = self._resumable_pr(issue, linked)
3307
+ if pr is None:
3308
+ return None
3309
+ footprint = self._footprint_age(pr, spawn_age)
3310
+ if footprint is None:
3311
+ return None
3312
+ window = self.config.wedge_seconds
3313
+ if footprint < window:
3314
+ log.debug(
3315
+ "%s: session behind draft PR %s reads alive with a footprint "
3316
+ "%.0f min old (wedge window %d min) — not wedged",
3317
+ issue.issue_slug,
3318
+ pr.url,
3319
+ footprint / 60,
3320
+ window // 60,
3321
+ )
3322
+ return None
3323
+ return pr, footprint
3324
+
3325
+ def _footprint_age(
3326
+ self, pr: PullRequest, spawn_age: float
3327
+ ) -> "float | None":
3328
+ """How long ago this lane last showed ANY activity, in seconds: the
3329
+ YOUNGEST of the ledger row's spawn, the PR's last update, and the
3330
+ head commit's committer date. None when that cannot be established.
3331
+
3332
+ The three legs are the three places a working session leaves a
3333
+ mark. The spawn is the floor (a session younger than the window
3334
+ cannot be wedged yet -- it is still starting). `updated_at` is
3335
+ GitHub's own "anything happened here" stamp: it moves on every push
3336
+ to the head (synchronize), every edit, comment, label and review,
3337
+ and it rides the `pull_request()` fetch the resume probe already
3338
+ paid, so on the common path the footprint costs no call at all. The
3339
+ head commit's date is the third leg, read only once the first two
3340
+ have ALREADY run past the window: it is redundant with `updated_at`
3341
+ on every push GitHub saw, so paying for it while the cheap legs
3342
+ still prove activity would be a per-poll GET for a question already
3343
+ answered. Where it IS read it is the last word before a kill, and
3344
+ an unreadable commit (CommandError, or a payload with no date)
3345
+ answers None rather than "old" -- the daemon does not kill a
3346
+ session over evidence it could not read. RateLimited is NOT caught:
3347
+ like every other GitHub call in the pass it belongs to run_forever's
3348
+ backoff."""
3349
+ now = time.time()
3350
+ youngest = spawn_age
3351
+ if pr.updated_at is not None:
3352
+ youngest = min(youngest, max(0.0, now - pr.updated_at))
3353
+ if youngest < self.config.wedge_seconds:
3354
+ return youngest
3355
+ try:
3356
+ committed = self.github.commit_time(pr.owner, pr.repo, pr.head_sha)
3357
+ except CommandError as exc:
3358
+ log.warning(
3359
+ "wedge: could not read the head commit date of %s (%s) — "
3360
+ "the footprint cannot be established, so the session keeps "
3361
+ "its defer this poll",
3362
+ pr.pr_slug,
3363
+ exc,
3364
+ )
3365
+ return None
3366
+ if committed is None:
3367
+ log.warning(
3368
+ "wedge: head commit %s of %s carries no readable date — the "
3369
+ "footprint cannot be established, so the session keeps its "
3370
+ "defer this poll",
3371
+ pr.head_sha[:8],
3372
+ pr.pr_slug,
3373
+ )
3374
+ return None
3375
+ return min(youngest, max(0.0, now - committed))
3376
+
3377
+ def _kill_wedged(
3378
+ self,
3379
+ issue: Issue,
3380
+ attempts: int,
3381
+ session: str,
3382
+ pr: PullRequest,
3383
+ footprint: float,
3384
+ ) -> Decision:
3385
+ """Kill the ledger row's own session so the next pass can resume
3386
+ the lane, and say so everywhere an operator would look.
3387
+
3388
+ The kill is the daemon's own session-kill machinery -- one `alissa
3389
+ tmux kill <name>` on exactly the row's recorded name, the same act a
3390
+ healthy handoff ends with, never a roster sweep. It is the ONLY side
3391
+ effect this pass has on the lane: no assignment changes, no ledger
3392
+ row is written for an attempt, nothing is enqueued. The decision
3393
+ stays IN_FLIGHT -- the lane is still deferred behind its draft --
3394
+ and what changes is the NEXT evaluation: a successful listing
3395
+ without this name reads DEAD, `_resumable_pr` admits the same PR
3396
+ it admitted here, and the ordinary resume spawns attempt k+1 (or,
3397
+ at the cap, pages the operator exactly as any resume would). A
3398
+ kill that fails is logged and the defer holds; the next poll makes
3399
+ the same decision and tries again, which is the remedy for a
3400
+ session that will not die, not a loop to guard against.
3401
+
3402
+ The activity comment gets ONE line per episode (keyed
3403
+ wedge_kind(attempts)), recorded only after the line lands so a
3404
+ transient failure retries -- the comment is the cross-actor record
3405
+ of why this session vanished, and the recorded row is what the
3406
+ loop-events emitter turns into `escalation.wedged`. Every side
3407
+ effect honours dry_run."""
3408
+ minutes = int(footprint / 60)
3409
+ window = self.config.wedge_seconds // 60
3410
+ log.warning(
3411
+ "wedged: alive session %s with no footprint for %d min behind "
3412
+ "draft PR #%d (%s) — killed for resume (%s attempt %d; wedge "
3413
+ "window %d min; the next pass reads it dead and resumes as "
3414
+ "attempt %d, counting toward attempt_cap %d)",
3415
+ session,
3416
+ minutes,
3417
+ pr.number,
3418
+ pr.url,
3419
+ issue.issue_slug,
3420
+ attempts,
3421
+ window,
3422
+ attempts + 1,
3423
+ self.config.attempt_cap,
3424
+ )
3425
+ reason = (
3426
+ f"attempt {attempts} is wedged: its session {session} reads alive "
3427
+ f"but the lane's activity footprint is {minutes} min old (wedge "
3428
+ f"window {window} min) behind still-draft PR {pr.url} — "
3429
+ )
3430
+ if self.config.dry_run:
3431
+ log.info("[dry-run] would kill wedged session %s", session)
3432
+ return Decision(
3433
+ Action.IN_FLIGHT,
3434
+ reason + "[dry-run] would kill it for resume",
3435
+ attempts,
3436
+ session=session,
3437
+ defer_token=loop_events_mod.DEFER_IN_FLIGHT,
3438
+ )
3439
+ try:
3440
+ self.alissa.kill_session(session)
3441
+ except CommandError as exc:
3442
+ log.warning(
3443
+ "wedge: could not kill %s (%s) — kills are best-effort; the "
3444
+ "lane keeps its defer and the kill is retried next poll",
3445
+ session,
3446
+ exc,
3447
+ )
3448
+ return Decision(
3449
+ Action.IN_FLIGHT,
3450
+ reason + f"the kill failed ({exc}); deferring",
3451
+ attempts,
3452
+ session=session,
3453
+ defer_token=loop_events_mod.DEFER_IN_FLIGHT,
3454
+ )
3455
+ kind = wedge_kind(attempts)
3456
+ if not self.state.escalated(issue.full_name, issue.number, kind):
3457
+ landed = self._append_activity(
3458
+ issue.owner,
3459
+ issue.repo,
3460
+ issue.number,
3461
+ self._activity_line(
3462
+ session,
3463
+ "issue edge",
3464
+ f"{ACTIVITY_WEDGE_KILLED} — the session read alive but the lane's "
3465
+ f"activity footprint (spawn / last head commit / last PR "
3466
+ f"update) was {minutes} min old behind still-draft PR "
3467
+ f"{pr.url}; killed so the next pass resumes it as attempt "
3468
+ f"{attempts + 1}",
3469
+ ),
3470
+ issue.issue_slug,
3471
+ )
3472
+ if landed:
3473
+ self.state.record_escalation(issue.full_name, issue.number, kind)
3474
+ return Decision(
3475
+ Action.IN_FLIGHT,
3476
+ reason + f"killed for resume; the next pass reads it dead and "
3477
+ f"resumes as attempt {attempts + 1}",
3478
+ attempts,
3479
+ session=session,
3480
+ defer_token=loop_events_mod.DEFER_IN_FLIGHT,
3481
+ )
3482
+
3170
3483
  def _escalate_rejection(self, issue: Issue, exc: AssignmentRejected) -> Decision:
3171
3484
  """Escalate-don't-retry: a silently dropped assignment is a permanent,
3172
3485
  operator-fixable condition (push access), and without the in-flight
@@ -4178,17 +4491,18 @@ class DevWatcher:
4178
4491
  f"identity — not this daemon's to hand off",
4179
4492
  )
4180
4493
 
4181
- # A draft already carrying a review request is not unhanded-off: a
4182
- # session (or a human) put a reviewer on it deliberately and simply has
4183
- # not flipped it ready. Nothing here is owed, and readying it would
4184
- # take that decision away from whoever made it.
4185
- if pr.requested_reviewers:
4186
- return Decision(
4187
- Action.SKIPPED,
4188
- f"draft already carries a review request "
4189
- f"({', '.join(pr.requested_reviewers)}) — its handoff is "
4190
- f"someone's deliberate work in progress, not an orphan",
4191
- )
4494
+ # A draft already carrying a review request is NOT excluded (issue
4495
+ # #113). The sweep used to read a reviewer on a draft as someone's
4496
+ # deliberate work in progress, but a request on a draft arms nothing
4497
+ # -- the reviewer daemon reviews only non-draft PRs and the fix edge
4498
+ # refuses drafts -- and the issue edge read the same shape as "handed
4499
+ # off", so a session that died between its `--add-reviewer` and its
4500
+ # `gh pr ready` (studio.alissa.app#1204: 80 minutes of silence after
4501
+ # a 10-hour attempt) left a draft NO edge would ever move. The
4502
+ # liveness gate below is what protects a deliberately-parked draft:
4503
+ # a live session behind it is never swept, and for a DEAD one the
4504
+ # sweep does the FIRST half only -- the ready flip -- and requests
4505
+ # just the configured reviewers that are not already on the PR.
4192
4506
 
4193
4507
  # head_ref reaches nothing but log lines here (the sweep spawns no
4194
4508
  # session, so it never becomes a worktree path or a shell token), but
@@ -4523,36 +4837,64 @@ class DevWatcher:
4523
4837
  not retried on this head (escalate-don't-retry, `_escalate_rejection`
4524
4838
  verbatim).
4525
4839
 
4840
+ A draft that ALREADY carries a review request (issue #113) takes the
4841
+ same walk minus the request: the session died between its
4842
+ `--add-reviewer` and its `gh pr ready`, so the flip is the only act
4843
+ still owed. `request_reviews` is skipped entirely when every
4844
+ configured reviewer is already on the PR -- re-requesting an
4845
+ already-requested reviewer is a no-op on GitHub, but it must not be
4846
+ recorded as a fresh request -- and requests only the missing ones
4847
+ otherwise. Both ledger rows still land, so the once-per-(PR, head)
4848
+ guarantee and `_owes_orphan_handoff` keep holding on this shape.
4849
+
4526
4850
  The activity line comes LAST and stays best-effort: it is telemetry
4527
4851
  about acts that have already happened, and it is written as a NON-SPAWN
4528
4852
  context because no session exists for it to claim a lane on behalf of.
4529
4853
  """
4530
- reviewers = self.config.reviewers
4531
- log.warning(
4532
- "ORPHAN DRAFT %s (%s): head %s has been public %.0f min, no "
4533
- "reviewer is requested, and no worker session for this PR is "
4534
- "listed — the session that opened it died before handing it off; "
4535
- "marking it ready and requesting %s mechanically (once per "
4536
- "PR/head)",
4537
- pr.pr_slug,
4538
- task_ref,
4539
- pr.head_sha[:8],
4540
- waited / 60,
4541
- ", ".join(reviewers),
4542
- )
4854
+ already, missing = self._orphan_reviewers_split(pr)
4855
+ if already:
4856
+ log.warning(
4857
+ "ORPHAN DRAFT %s (%s): head %s has been public %.0f min, %s "
4858
+ "already requested on it, and no worker session for this PR "
4859
+ "is listed — the session that opened it died after its "
4860
+ "review request but before flipping it ready (a request on a "
4861
+ "draft arms nothing); marking it ready mechanically%s (once "
4862
+ "per PR/head)",
4863
+ pr.pr_slug,
4864
+ task_ref,
4865
+ pr.head_sha[:8],
4866
+ waited / 60,
4867
+ ", ".join(already),
4868
+ f" and requesting {', '.join(missing)}" if missing else "",
4869
+ )
4870
+ else:
4871
+ log.warning(
4872
+ "ORPHAN DRAFT %s (%s): head %s has been public %.0f min, no "
4873
+ "reviewer is requested, and no worker session for this PR is "
4874
+ "listed — the session that opened it died before handing it "
4875
+ "off; marking it ready and requesting %s mechanically (once "
4876
+ "per PR/head)",
4877
+ pr.pr_slug,
4878
+ task_ref,
4879
+ pr.head_sha[:8],
4880
+ waited / 60,
4881
+ ", ".join(missing),
4882
+ )
4543
4883
 
4544
4884
  if self.config.dry_run:
4885
+ request = (
4886
+ f"request {', '.join(missing)}"
4887
+ if missing
4888
+ else f"request nobody ({', '.join(already)} already requested)"
4889
+ )
4545
4890
  log.info(
4546
- "[dry-run] would mark %s ready and request review from %s",
4547
- pr.pr_slug,
4548
- ", ".join(reviewers),
4891
+ "[dry-run] would mark %s ready and %s", pr.pr_slug, request
4549
4892
  )
4550
4893
  return Decision(
4551
4894
  Action.IN_FLIGHT,
4552
4895
  f"[dry-run] would mechanically hand off draft head "
4553
4896
  f"{pr.head_sha[:8]} (public {int(waited / 60)} min, its "
4554
- f"session gone): mark ready + request "
4555
- f"{', '.join(reviewers)}",
4897
+ f"session gone): mark ready + {request}",
4556
4898
  task_ref=task_ref,
4557
4899
  )
4558
4900
 
@@ -4582,6 +4924,28 @@ class DevWatcher:
4582
4924
 
4583
4925
  return self._request_orphan_reviewers(pr, task_ref, waited)
4584
4926
 
4927
+ def _orphan_reviewers_split(
4928
+ self, pr: PullRequest
4929
+ ) -> "tuple[tuple[str, ...], tuple[str, ...]]":
4930
+ """The configured reviewers, split by what the PR already carries:
4931
+ (already requested, still missing), each in config order.
4932
+
4933
+ Casefolded like `_reviewer_reviews`: the configured set is
4934
+ operator-typed and GitHub logins are case-insensitive, so `Alissa-App`
4935
+ in the config and `alissa-app` on the PR are one reviewer, not a
4936
+ missing one. Stated once because the sweep and the completion path
4937
+ both need exactly this answer -- the request must never name a
4938
+ reviewer who is already on the PR (a no-op on GitHub that would still
4939
+ be recorded, and logged, as a fresh request)."""
4940
+ on_pr = {r.casefold() for r in pr.requested_reviewers}
4941
+ already = tuple(
4942
+ r for r in self.config.reviewers if r.casefold() in on_pr
4943
+ )
4944
+ missing = tuple(
4945
+ r for r in self.config.reviewers if r.casefold() not in on_pr
4946
+ )
4947
+ return already, missing
4948
+
4585
4949
  def _owes_orphan_handoff(self, pr: PullRequest) -> bool:
4586
4950
  """Did THIS daemon ready this exact head and still owe it a reviewer?
4587
4951
 
@@ -4623,6 +4987,14 @@ class DevWatcher:
4623
4987
  entitled to finish its own handoff). Not re-run is the grace window --
4624
4988
  it was satisfied when the flip fired, and the head has not moved since
4625
4989
  (a moved head would be a different key with no ready row at all).
4990
+
4991
+ Reached only for a PR with NO pending request (evaluate_pr's own
4992
+ guard sits above this). So a draft that already carried a reviewer
4993
+ can never arrive here: it left draft carrying that reviewer, and the
4994
+ pending-request guard reads it as in review. That is why
4995
+ `_request_orphan_reviewers` never leaves that shape half-recorded --
4996
+ a failed request for the MISSING reviewers there is given up on and
4997
+ the handoff completed on the spot (round-1 review fix, PR #114).
4626
4998
  """
4627
4999
  if not self._owes_orphan_handoff(pr) or not self.config.reviewers:
4628
5000
  return None
@@ -4671,43 +5043,73 @@ class DevWatcher:
4671
5043
  this is the completion path (where that number is a poll or more old
4672
5044
  and would be reported as fact).
4673
5045
 
4674
- Failure handling mirrors `_rerequest_review`'s exactly: a
4675
- ReviewRequestRejected is permanent and operator-fixable (escalate,
4676
- don't retry, and never record the DONE marker); a CommandError is
4677
- transient (record nothing, retry next poll). The READY row stays in
4678
- both cases -- the flip really did happen, and re-flipping an
4679
- already-ready PR is not what the retry needs to do."""
4680
- reviewers = self.config.reviewers
4681
- try:
4682
- landed = self.github.request_reviews(
4683
- pr.owner, pr.repo, pr.number, reviewers
4684
- )
4685
- except ReviewRequestRejected as exc:
4686
- return self._escalate_orphan_rejected(pr, task_ref, exc)
4687
- except CommandError as exc:
4688
- log.error(
4689
- "could not request review from %s on %s: %s — NOT recording "
4690
- "the handoff; the sweep retries next poll",
4691
- ", ".join(reviewers),
4692
- pr.pr_slug,
4693
- exc,
4694
- )
4695
- return Decision(
4696
- Action.SKIPPED,
4697
- f"the PR was marked ready but the review request did not land "
4698
- f"({exc}) — transient; not recorded, retrying next poll",
4699
- task_ref=task_ref,
4700
- )
5046
+ Only the configured reviewers NOT already on the PR are requested
5047
+ (`_orphan_reviewers_split`, issue #113). When every one of them is
5048
+ already requested -- the draft the session put a reviewer on before
5049
+ dying, or a completion-path PR someone requested by hand between
5050
+ polls -- no call is made at all: the DONE marker lands, the activity
5051
+ line says who was already there, and the reviewer daemon already sees
5052
+ the PR.
5053
+
5054
+ Failure handling on the review-less shape mirrors
5055
+ `_rerequest_review`'s exactly: a ReviewRequestRejected is permanent
5056
+ and operator-fixable (escalate, don't retry, and never record the
5057
+ DONE marker); a CommandError is transient (record nothing, retry next
5058
+ poll). The READY row stays in both cases -- the flip really did
5059
+ happen, and re-flipping an already-ready PR is not what the retry
5060
+ needs to do.
5061
+
5062
+ On the PARTIAL shape -- some configured reviewers already on the PR,
5063
+ the call carrying only the missing rest -- neither doctrine applies
5064
+ (round-1 review fix, PR #114). `request_reviews` raises only when
5065
+ nothing in THAT call landed, which here does not mean nobody is on
5066
+ the PR: the flip landed and `already` is requested, so the PR is in
5067
+ review and the reviewer daemon sees it. The refusal page would be
5068
+ false ("never marked ready or sent to a reviewer"), and a retry is
5069
+ structurally impossible: next poll the pending-request guard in
5070
+ evaluate_pr reads the PR as in review before `_finish_orphan_handoff`
5071
+ is reached, so a ready row left down would stick on that head
5072
+ forever. So a failed request for the missing subset, permanent or
5073
+ transient, is logged as given up on -- naming who IS on the PR --
5074
+ and the handoff is completed: DONE row, activity line."""
5075
+ already, missing = self._orphan_reviewers_split(pr)
5076
+ landed: tuple[str, ...] = ()
5077
+ if missing:
5078
+ try:
5079
+ landed = self.github.request_reviews(
5080
+ pr.owner, pr.repo, pr.number, missing
5081
+ )
5082
+ except ReviewRequestRejected as exc:
5083
+ if not already:
5084
+ return self._escalate_orphan_rejected(pr, task_ref, exc)
5085
+ self._log_orphan_reviewers_given_up(pr, missing, already, exc)
5086
+ except CommandError as exc:
5087
+ if not already:
5088
+ log.error(
5089
+ "could not request review from %s on %s: %s — NOT "
5090
+ "recording the handoff; the sweep retries next poll",
5091
+ ", ".join(missing),
5092
+ pr.pr_slug,
5093
+ exc,
5094
+ )
5095
+ return Decision(
5096
+ Action.SKIPPED,
5097
+ f"the PR was marked ready but the review request "
5098
+ f"did not land ({exc}) — transient; not recorded, "
5099
+ f"retrying next poll",
5100
+ task_ref=task_ref,
5101
+ )
5102
+ self._log_orphan_reviewers_given_up(pr, missing, already, exc)
4701
5103
 
4702
- dropped = tuple(r for r in reviewers if r not in landed)
4703
- if dropped:
4704
- log.warning(
4705
- "%s: GitHub silently dropped reviewer(s) %s from the "
4706
- "mechanical handoff; %s landed, so the PR IS in review",
4707
- pr.pr_slug,
4708
- ", ".join(dropped),
4709
- ", ".join(landed),
4710
- )
5104
+ dropped = tuple(r for r in missing if r not in landed)
5105
+ if dropped and landed:
5106
+ log.warning(
5107
+ "%s: GitHub silently dropped reviewer(s) %s from the "
5108
+ "mechanical handoff; %s landed, so the PR IS in review",
5109
+ pr.pr_slug,
5110
+ ", ".join(dropped),
5111
+ ", ".join(landed + already),
5112
+ )
4711
5113
 
4712
5114
  self.state.record_escalation(
4713
5115
  pr.full_name, pr.number, orphan_kind(pr.head_sha)
@@ -4717,6 +5119,23 @@ class DevWatcher:
4717
5119
  if waited is not None
4718
5120
  else ""
4719
5121
  )
5122
+ if landed and already:
5123
+ acted = (
5124
+ f"marked ready and requested review from {', '.join(landed)} "
5125
+ f"({', '.join(already)} already requested)"
5126
+ )
5127
+ died = "before handing it off"
5128
+ elif landed:
5129
+ acted = f"marked ready and requested review from {', '.join(landed)}"
5130
+ died = "before handing it off"
5131
+ else:
5132
+ noun = "reviewer" if len(already) == 1 else "reviewers"
5133
+ verb = "was" if len(already) == 1 else "were"
5134
+ acted = (
5135
+ f"marked ready ({noun} {', '.join(already)} {verb} already "
5136
+ f"requested)"
5137
+ )
5138
+ died = "before flipping it"
4720
5139
  self._append_activity(
4721
5140
  pr.owner,
4722
5141
  pr.repo,
@@ -4724,22 +5143,51 @@ class DevWatcher:
4724
5143
  self._activity_line(
4725
5144
  orphan_activity_lane(pr),
4726
5145
  "orphan sweep",
4727
- f"{ACTIVITY_HANDED_OFF} — marked ready and requested review "
4728
- f"from {', '.join(landed)}; the session that opened this draft "
4729
- f"died before handing it off{age}, so the daemon completed its "
4730
- f"protocol (no session spawned, once per PR/head)",
5146
+ f"{ACTIVITY_HANDED_OFF} — {acted}; the session that opened "
5147
+ f"this draft died {died}, so the daemon completed its "
5148
+ f"protocol{age} (no session spawned, once per PR/head)",
4731
5149
  ),
4732
5150
  pr.pr_slug,
4733
5151
  )
4734
5152
 
5153
+ if landed:
5154
+ requested = f"and review requested from {', '.join(landed)}"
5155
+ else:
5156
+ requested = (
5157
+ f"(review already requested from {', '.join(already)}, no "
5158
+ f"new request made)"
5159
+ )
4735
5160
  return Decision(
4736
5161
  Action.IN_FLIGHT,
4737
- f"orphaned draft handed off — marked ready and review requested "
4738
- f"from {', '.join(landed)} for head {pr.head_sha[:8]}; the ball is "
4739
- f"in the reviewer's court",
5162
+ f"orphaned draft handed off — marked ready {requested} for head "
5163
+ f"{pr.head_sha[:8]}; the ball is in the reviewer's court",
4740
5164
  task_ref=task_ref,
4741
5165
  )
4742
5166
 
5167
+ @staticmethod
5168
+ def _log_orphan_reviewers_given_up(
5169
+ pr: PullRequest,
5170
+ missing: "tuple[str, ...]",
5171
+ already: "tuple[str, ...]",
5172
+ exc: Exception,
5173
+ ) -> None:
5174
+ """The partial shape's one failure path (see
5175
+ `_request_orphan_reviewers`): the request for the configured
5176
+ reviewers NOT yet on the PR failed, `already` IS on it, and no retry
5177
+ can ever reach this PR again -- so the missing reviewers are given up
5178
+ on, out loud, and the handoff completes with who is there."""
5179
+ log.warning(
5180
+ "%s: could not request review from %s on the mechanical handoff "
5181
+ "(%s) — giving up on them: %s already requested and the PR is out "
5182
+ "of draft, so it IS in review; the daemon will not retry (the "
5183
+ "pending request reads as in review from here on), request them "
5184
+ "by hand if wanted",
5185
+ pr.pr_slug,
5186
+ ", ".join(missing),
5187
+ exc,
5188
+ ", ".join(already),
5189
+ )
5190
+
4743
5191
  def _escalate_orphan_rejected(
4744
5192
  self, pr: PullRequest, task_ref: "str | None", exc: Exception
4745
5193
  ) -> Decision:
@@ -122,8 +122,12 @@ _FOREIGN_RE = re.compile(r"^foreign-session:(.+)$")
122
122
 
123
123
  # Escalation kinds anchored on a repo ISSUE; everything else that names a
124
124
  # number names a PULL REQUEST (the fix/maintain/orphan/rerequest families all
125
- # key the PR number into the ledger's `issue` column).
126
- _ISSUE_ANCHORED = ("cap", "assignment", "stalled", "live-stalled")
125
+ # key the PR number into the ledger's `issue` column). `wedged` is the issue
126
+ # edge's wedge kill (loop.ESCALATION_WEDGED, recorded against the issue the
127
+ # lane is working, not the draft PR behind it) -- PR and issue numbers share
128
+ # GitHub's namespace, so a kind missing here does not merely mislabel, it
129
+ # points the operator at an unrelated PR or at nothing.
130
+ _ISSUE_ANCHORED = ("cap", "assignment", "stalled", "live-stalled", "wedged")
127
131
 
128
132
 
129
133
  def _ms(ts: "int | float | str") -> int:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.17
3
+ Version: 0.8.19
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera