alissa-tools-github-devloop 0.8.18__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.18/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.18 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/loop.py +269 -102
  3. alissa_tools_github_devloop-0.8.19/src/main/alissa/tools/github/devloop/version +1 -0
  4. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  5. alissa_tools_github_devloop-0.8.18/src/main/alissa/tools/github/devloop/version +0 -1
  6. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/LICENSE +0 -0
  7. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/MANIFEST.in +0 -0
  8. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/NOTICE +0 -0
  9. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/README.md +0 -0
  10. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/requirements.txt +0 -0
  11. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/setup.cfg +0 -0
  12. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/setup.py +0 -0
  13. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  14. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  15. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  16. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  17. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  18. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/config.py +0 -0
  19. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  20. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  21. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/loop_events.py +0 -0
  22. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  23. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/state.py +0 -0
  24. {alissa_tools_github_devloop-0.8.18 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/version.py +0 -0
  25. {alissa_tools_github_devloop-0.8.18 → 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.18 → 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.18 → 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.18 → 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.18 → 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.18 → 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.18 → 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.18 → 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.18 → 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.18 → 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.18 → 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.18
3
+ Version: 0.8.19
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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
@@ -91,8 +95,8 @@ above takes over as attempt k+1, counting toward attempt_cap like any
91
95
  resume. Nothing about the kill is new machinery on the resume side: it
92
96
  manufactures the one signal the matrix already acts on. The invariants are
93
97
  the matrix's own -- never on INDETERMINATE (absence of evidence keeps the
94
- defer), never on a PR that left draft or has a reviewer engaged (the wedge
95
- sits strictly behind `_resumable_pr`), never with `resume_enabled` false (a
98
+ defer), never on a PR that left draft (the wedge sits strictly behind
99
+ `_resumable_pr`), never with `resume_enabled` false (a
96
100
  kill whose resume cannot fire would only orphan the lane), and never a
97
101
  roster sweep.
98
102
 
@@ -173,7 +177,9 @@ self-heal needs an unanswered review, and the issue-edge resume keys off an
173
177
  OPEN labeled issue, which a draft replayed after its issue merged does not
174
178
  have (fahera-mx/studio.alissa.app#279, 2026-07-28: an operator found it by
175
179
  accident). So the PR search is widened to drafts and every draft is asked the
176
- 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),
177
183
  a daemon-shaped head ref naming an implementation task (a `TASK-<n>-<DESC>`
178
184
  branch or a task ref in the body -- the provenance test, `closes_issue`'s
179
185
  role here), no live worker session for the PR (the SAME liveness rule --
@@ -741,8 +747,9 @@ RESUME_DIRECTIVE = (
741
747
  "previous developer session on GitHub issue {issue_url} died AFTER "
742
748
  "opening its pull request but BEFORE handing it off for review — the "
743
749
  "daemon confirmed its session `{dead_session}` is gone from the machine, "
744
- "and its DRAFT pull request {pr_url} is still open with no reviewer on "
745
- "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 "
746
753
  "PULL REQUEST, not to start the work over. "
747
754
  "{task_instruction} "
748
755
  "FIRST hydrate the context you did not live through: read the issue (its "
@@ -2629,11 +2636,16 @@ class DevWatcher:
2629
2636
  # the session behind the open PR is not running -- but NOT that it was
2630
2637
  # abandoned, because a healthy handoff ends in `alissa tmux kill`. So
2631
2638
  # DEAD only opens the question, and `_resumable_pr` answers it: a
2632
- # 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
2633
2641
  # issue's own sessions that was never handed off, and it
2634
2642
  # is the one case the deferral was always wrong about -- resumed as
2635
2643
  # attempt k+1 with RESUME_DIRECTIVE instead of parking the lane behind
2636
- # 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
2637
2649
  # behind a PR already in the review lifecycle -- keeps the floored
2638
2650
  # defer: past STALLED_DEFER_MULTIPLE stale windows it posts a
2639
2651
  # "stalled" operator comment (this deferral sits above the cap check,
@@ -2708,9 +2720,9 @@ class DevWatcher:
2708
2720
  # an operator reading the log must not have to guess.
2709
2721
  why = (
2710
2722
  "no linked PR is resumable (a handed-off PR — out of "
2711
- "draft, or with a reviewer engaged — belongs to the "
2712
- "review lifecycle, not to a resume; and a PR that "
2713
- "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)"
2714
2726
  if liveness is Liveness.DEAD
2715
2727
  and self.config.resume_enabled
2716
2728
  else "work may be alive (the review loop can outlast "
@@ -3080,12 +3092,23 @@ class DevWatcher:
3080
3092
 
3081
3093
  What separates the two is the PR the session left behind, because the
3082
3094
  handoff PUBLISHES itself: `gh pr ready` flips the draft off, then the
3083
- re-request puts a reviewer on it. So a PR that is STILL A DRAFT with
3084
- NO reviewer requested is one its session never handed off -- the only
3085
- state a resume is owed. (A session that died in the narrow window
3086
- between the ready flip and the re-request reads handed-off and keeps
3087
- the legacy floored defer: conservative on purpose, since that is also
3088
- 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.
3089
3112
 
3090
3113
  That same test settles the cross-edge race: the moment a PR leaves
3091
3114
  draft it belongs to the review lifecycle, and the fix edge -- which
@@ -3162,16 +3185,24 @@ class DevWatcher:
3162
3185
  # still a PR whose base can strand it, and the guard's own author
3163
3186
  # test is what keeps it off PRs that are not ours.
3164
3187
  self._check_stacked_base(pr)
3165
- if not pr.draft or pr.requested_reviewers:
3188
+ if not pr.draft:
3166
3189
  log.info(
3167
- "%s: %s has been handed off (draft=%s, reviewers=%s) — "
3168
- "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",
3169
3192
  issue.issue_slug,
3170
3193
  url,
3171
- pr.draft,
3172
3194
  ", ".join(pr.requested_reviewers) or "none",
3173
3195
  )
3174
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
+ )
3175
3206
  if pr.author != self.github.login:
3176
3207
  log.warning(
3177
3208
  "%s: %s is authored by %r, not the developer identity — "
@@ -3258,12 +3289,13 @@ class DevWatcher:
3258
3289
 
3259
3290
  Two gates, in order. First `_resumable_pr`, verbatim -- the wedge
3260
3291
  path inherits every admission test the resume has (still a draft,
3261
- no reviewer, self-authored, daemon-shaped head, a body that CLOSES
3262
- this issue, exactly one candidate), because the kill it licenses is
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
3263
3295
  only safe where the resume that follows it is: a PR that has left
3264
- draft or has a reviewer engaged belongs to the review lifecycle,
3265
- and a live review loop behind an ALIVE session is the healthy shape
3266
- this daemon was built around. Then the footprint (`_footprint_age`):
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`):
3267
3299
  None means unreadable, and unreadable keeps the defer -- the same
3268
3300
  fail-safe every other presumed-dead decision here makes.
3269
3301
 
@@ -4459,17 +4491,18 @@ class DevWatcher:
4459
4491
  f"identity — not this daemon's to hand off",
4460
4492
  )
4461
4493
 
4462
- # A draft already carrying a review request is not unhanded-off: a
4463
- # session (or a human) put a reviewer on it deliberately and simply has
4464
- # not flipped it ready. Nothing here is owed, and readying it would
4465
- # take that decision away from whoever made it.
4466
- if pr.requested_reviewers:
4467
- return Decision(
4468
- Action.SKIPPED,
4469
- f"draft already carries a review request "
4470
- f"({', '.join(pr.requested_reviewers)}) — its handoff is "
4471
- f"someone's deliberate work in progress, not an orphan",
4472
- )
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.
4473
4506
 
4474
4507
  # head_ref reaches nothing but log lines here (the sweep spawns no
4475
4508
  # session, so it never becomes a worktree path or a shell token), but
@@ -4804,36 +4837,64 @@ class DevWatcher:
4804
4837
  not retried on this head (escalate-don't-retry, `_escalate_rejection`
4805
4838
  verbatim).
4806
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
+
4807
4850
  The activity line comes LAST and stays best-effort: it is telemetry
4808
4851
  about acts that have already happened, and it is written as a NON-SPAWN
4809
4852
  context because no session exists for it to claim a lane on behalf of.
4810
4853
  """
4811
- reviewers = self.config.reviewers
4812
- log.warning(
4813
- "ORPHAN DRAFT %s (%s): head %s has been public %.0f min, no "
4814
- "reviewer is requested, and no worker session for this PR is "
4815
- "listed — the session that opened it died before handing it off; "
4816
- "marking it ready and requesting %s mechanically (once per "
4817
- "PR/head)",
4818
- pr.pr_slug,
4819
- task_ref,
4820
- pr.head_sha[:8],
4821
- waited / 60,
4822
- ", ".join(reviewers),
4823
- )
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
+ )
4824
4883
 
4825
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
+ )
4826
4890
  log.info(
4827
- "[dry-run] would mark %s ready and request review from %s",
4828
- pr.pr_slug,
4829
- ", ".join(reviewers),
4891
+ "[dry-run] would mark %s ready and %s", pr.pr_slug, request
4830
4892
  )
4831
4893
  return Decision(
4832
4894
  Action.IN_FLIGHT,
4833
4895
  f"[dry-run] would mechanically hand off draft head "
4834
4896
  f"{pr.head_sha[:8]} (public {int(waited / 60)} min, its "
4835
- f"session gone): mark ready + request "
4836
- f"{', '.join(reviewers)}",
4897
+ f"session gone): mark ready + {request}",
4837
4898
  task_ref=task_ref,
4838
4899
  )
4839
4900
 
@@ -4863,6 +4924,28 @@ class DevWatcher:
4863
4924
 
4864
4925
  return self._request_orphan_reviewers(pr, task_ref, waited)
4865
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
+
4866
4949
  def _owes_orphan_handoff(self, pr: PullRequest) -> bool:
4867
4950
  """Did THIS daemon ready this exact head and still owe it a reviewer?
4868
4951
 
@@ -4904,6 +4987,14 @@ class DevWatcher:
4904
4987
  entitled to finish its own handoff). Not re-run is the grace window --
4905
4988
  it was satisfied when the flip fired, and the head has not moved since
4906
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).
4907
4998
  """
4908
4999
  if not self._owes_orphan_handoff(pr) or not self.config.reviewers:
4909
5000
  return None
@@ -4952,43 +5043,73 @@ class DevWatcher:
4952
5043
  this is the completion path (where that number is a poll or more old
4953
5044
  and would be reported as fact).
4954
5045
 
4955
- Failure handling mirrors `_rerequest_review`'s exactly: a
4956
- ReviewRequestRejected is permanent and operator-fixable (escalate,
4957
- don't retry, and never record the DONE marker); a CommandError is
4958
- transient (record nothing, retry next poll). The READY row stays in
4959
- both cases -- the flip really did happen, and re-flipping an
4960
- already-ready PR is not what the retry needs to do."""
4961
- reviewers = self.config.reviewers
4962
- try:
4963
- landed = self.github.request_reviews(
4964
- pr.owner, pr.repo, pr.number, reviewers
4965
- )
4966
- except ReviewRequestRejected as exc:
4967
- return self._escalate_orphan_rejected(pr, task_ref, exc)
4968
- except CommandError as exc:
4969
- log.error(
4970
- "could not request review from %s on %s: %s — NOT recording "
4971
- "the handoff; the sweep retries next poll",
4972
- ", ".join(reviewers),
4973
- pr.pr_slug,
4974
- exc,
4975
- )
4976
- return Decision(
4977
- Action.SKIPPED,
4978
- f"the PR was marked ready but the review request did not land "
4979
- f"({exc}) — transient; not recorded, retrying next poll",
4980
- task_ref=task_ref,
4981
- )
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)
4982
5103
 
4983
- dropped = tuple(r for r in reviewers if r not in landed)
4984
- if dropped:
4985
- log.warning(
4986
- "%s: GitHub silently dropped reviewer(s) %s from the "
4987
- "mechanical handoff; %s landed, so the PR IS in review",
4988
- pr.pr_slug,
4989
- ", ".join(dropped),
4990
- ", ".join(landed),
4991
- )
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
+ )
4992
5113
 
4993
5114
  self.state.record_escalation(
4994
5115
  pr.full_name, pr.number, orphan_kind(pr.head_sha)
@@ -4998,6 +5119,23 @@ class DevWatcher:
4998
5119
  if waited is not None
4999
5120
  else ""
5000
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"
5001
5139
  self._append_activity(
5002
5140
  pr.owner,
5003
5141
  pr.repo,
@@ -5005,22 +5143,51 @@ class DevWatcher:
5005
5143
  self._activity_line(
5006
5144
  orphan_activity_lane(pr),
5007
5145
  "orphan sweep",
5008
- f"{ACTIVITY_HANDED_OFF} — marked ready and requested review "
5009
- f"from {', '.join(landed)}; the session that opened this draft "
5010
- f"died before handing it off{age}, so the daemon completed its "
5011
- 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)",
5012
5149
  ),
5013
5150
  pr.pr_slug,
5014
5151
  )
5015
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
+ )
5016
5160
  return Decision(
5017
5161
  Action.IN_FLIGHT,
5018
- f"orphaned draft handed off — marked ready and review requested "
5019
- f"from {', '.join(landed)} for head {pr.head_sha[:8]}; the ball is "
5020
- 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",
5021
5164
  task_ref=task_ref,
5022
5165
  )
5023
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
+
5024
5191
  def _escalate_orphan_rejected(
5025
5192
  self, pr: PullRequest, task_ref: "str | None", exc: Exception
5026
5193
  ) -> Decision:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.18
3
+ Version: 0.8.19
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera