alissa-tools-github-devloop 0.8.22__tar.gz → 0.8.23__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 (36) hide show
  1. {alissa_tools_github_devloop-0.8.22/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.23}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/loop.py +298 -2
  3. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/state.py +71 -6
  4. alissa_tools_github_devloop-0.8.23/src/main/alissa/tools/github/devloop/version +1 -0
  5. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  6. alissa_tools_github_devloop-0.8.22/src/main/alissa/tools/github/devloop/version +0 -1
  7. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/LICENSE +0 -0
  8. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/MANIFEST.in +0 -0
  9. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/NOTICE +0 -0
  10. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/README.md +0 -0
  11. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/requirements.txt +0 -0
  12. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/setup.cfg +0 -0
  13. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/setup.py +0 -0
  14. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  15. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  16. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  17. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  18. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  19. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/config.py +0 -0
  20. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/fleet_vitals.py +0 -0
  21. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  22. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  23. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/loop_events.py +0 -0
  24. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  25. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/version.py +0 -0
  26. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  27. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  28. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  29. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  30. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  31. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  32. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  33. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  34. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  35. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  36. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.23}/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.22
3
+ Version: 0.8.23
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -810,6 +810,24 @@ RESUME_DIRECTIVE = (
810
810
  "(best-effort — the daemon's reaper sweep collects stragglers)."
811
811
  )
812
812
 
813
+ # The handover clause (issue #121), shared by FIX_DIRECTIVE and
814
+ # FIX_RESUME_DIRECTIVE. When a later review round lands on the head a fix
815
+ # session is still working (the 2026-09-15 studio#1243 shape: a phantom round
816
+ # 2 on round 1's head, minutes after round 1), the daemon no longer spawns a
817
+ # second session onto the same branch -- it posts FIX_HANDOVER_COMMENT on the
818
+ # PR and moves the live session's ledger row to the new round. The triage
819
+ # loop reads review THREADS, not the PR's issue comments, so the contract has
820
+ # to say it: look for a newer round before the terminal re-request.
821
+ _NEWER_ROUNDS = (
822
+ "Before you re-request review, check the PR for a NEWER review round: "
823
+ "if another `request_changes` round (or a daemon comment beginning "
824
+ "`Round N+1 arrived while the round-N fix session … is still working "
825
+ "on this head`) landed while you worked, the daemon has handed that "
826
+ "round to THIS session instead of spawning a second one on your branch "
827
+ "— triage and address its findings here as well, and re-request review "
828
+ "ONCE, after the newest round is answered. "
829
+ )
830
+
813
831
  FIX_DIRECTIVE = (
814
832
  "You are an IMPLEMENTER in a fix round, not a reviewer. PR {pr_url} "
815
833
  "received a `request_changes` review (round {round}; this is fix attempt "
@@ -831,6 +849,7 @@ FIX_DIRECTIVE = (
831
849
  "one reasoned `[triage:*]` reply on each finding's thread — post any that "
832
850
  "are missing. Then fix the pursued findings on the same branch and VERIFY "
833
851
  "locally (run the repo's test/style/type scripts). "
852
+ + _NEWER_ROUNDS
834
853
  + _FIX_DELIVERY_NOTE
835
854
  + " "
836
855
  + _APPROVE_FOLLOWUP
@@ -887,6 +906,7 @@ FIX_RESUME_DIRECTIVE = (
887
906
  "the ORIGINAL acceptance criteria the issue set, and VERIFY locally (run "
888
907
  "the repo's test/style/type scripts). NEVER force-push: history stays "
889
908
  "append-only, or the review threads lose their anchors. "
909
+ + _NEWER_ROUNDS
890
910
  + _FIX_DELIVERY_NOTE
891
911
  + " "
892
912
  + _APPROVE_FOLLOWUP
@@ -1000,6 +1020,29 @@ FIX_STALLED_COMMENT = (
1000
1020
  "close the PR to park the work."
1001
1021
  )
1002
1022
 
1023
+ # The hidden marker that identifies a fix-round handover comment (issue #121)
1024
+ # -- a machine-findable anchor for the live session and for anyone auditing
1025
+ # the PR, distinct from ACTIVITY_MARKER so the two kinds of comment never
1026
+ # collide on one PR. Dedupe is NOT by marker but by the fix-handover:r<k>
1027
+ # ledger row (see fix_handover_kind): once per (PR, round).
1028
+ FIX_HANDOVER_MARKER = "<!-- alissa-devloop:fix-handover -->"
1029
+
1030
+ # The steering comment the fix edge posts INSTEAD of spawning when a later
1031
+ # review round lands on the head a fix session is still working. The first
1032
+ # sentence is the issue's mandated text verbatim; the trailer says what the
1033
+ # daemon did and what happens if the session dies anyway.
1034
+ FIX_HANDOVER_COMMENT = (
1035
+ FIX_HANDOVER_MARKER + "\n"
1036
+ "Round {new_round} arrived while the round-{round} fix session "
1037
+ "`{session}` is still working on this head — handing it over: address "
1038
+ "the round-{new_round} findings in the same session and re-request "
1039
+ "review once.\n\n"
1040
+ "_devloop: no second fix session was spawned for round {new_round} (head "
1041
+ "`{head}` is the one `{session}` is working). If that session dies "
1042
+ "without re-requesting review, the stale path re-enqueues round "
1043
+ "{new_round} as its next attempt._"
1044
+ )
1045
+
1003
1046
  MAINTAIN_CAP_COMMENT = (
1004
1047
  "**Maintain loop cap-out** — {attempts} maintenance attempt(s) were "
1005
1048
  "spawned on this PR without any of them completing (attempt cap: {cap}; "
@@ -1190,6 +1233,11 @@ ESCALATION_ASSIGNMENT = "assignment"
1190
1233
  ESCALATION_STALLED = "stalled"
1191
1234
  ESCALATION_FIX_CAP = "fix-cap"
1192
1235
  ESCALATION_FIX_STALLED = "fix-stalled"
1236
+ # The round handover's once-per-(PR, round) row (issue #121, see
1237
+ # fix_handover_kind). A steering comment, not an operator page -- it lives in
1238
+ # the escalations table because that is the daemon's dedupe gate for
1239
+ # comments that must land exactly once and survive a degraded ledger.
1240
+ ESCALATION_FIX_HANDOVER = "fix-handover"
1193
1241
 
1194
1242
  # The pre-PR liveness deferral's own kind (see live_stalled_kind). Distinct
1195
1243
  # from ESCALATION_STALLED because the two stories are different and so are
@@ -1503,6 +1551,21 @@ def fix_stalled_kind(round_: int, attempt: int) -> str:
1503
1551
  return f"{ESCALATION_FIX_STALLED}:r{round_}a{attempt}"
1504
1552
 
1505
1553
 
1554
+ def fix_handover_kind(round_: int) -> str:
1555
+ """The ledger kind that dedupes ONE round's handover comment (issue #121).
1556
+
1557
+ Keyed per round (`fix-handover:r<k>`): round k+1 handed to a live session
1558
+ must not silence a round k+2 handed to the same session later, and the
1559
+ `fix-` prefix keeps the row from colliding with issue-edge kinds when an
1560
+ issue and a PR share a number. Delivery contract as fix_stalled_kind's:
1561
+ the row lands only AFTER the comment posts, so a transient failure
1562
+ retries next poll and the steering lands exactly once per round. The
1563
+ ledger round bump rides BEHIND the comment for the same reason -- a
1564
+ bumped row would close this edge's fresh-in-flight gate before the
1565
+ comment ever landed."""
1566
+ return f"{ESCALATION_FIX_HANDOVER}:r{round_}"
1567
+
1568
+
1506
1569
  def stalled_kind(attempt: int) -> str:
1507
1570
  """The ledger kind for the stalled ping of ONE deferral episode.
1508
1571
 
@@ -3947,15 +4010,35 @@ class DevWatcher:
3947
4010
  age = self.state.fix_spawn_age(pr.full_name, number, round_)
3948
4011
 
3949
4012
  if age is not None and age < self.config.stale_minutes * 60:
4013
+ # The row's own session name, not one re-derived from the
4014
+ # attempt: after a handover (below) the row answering round k+1
4015
+ # still carries its `-r<k>-` name, and the console reads this.
3950
4016
  return Decision(
3951
4017
  Action.IN_FLIGHT,
3952
4018
  f"fix attempt {attempts} for round {round_} enqueued "
3953
4019
  f"{int(age)}s ago",
3954
4020
  attempts,
3955
- session=fix_session_name(pr, round_, attempts),
4021
+ session=self.state.newest_fix_spawn_session(
4022
+ pr.full_name, number, round_
4023
+ ) or fix_session_name(pr, round_, attempts),
3956
4024
  round=round_,
3957
4025
  )
3958
4026
 
4027
+ # A round nothing was spawned for yet -- but the PREVIOUS round's fix
4028
+ # session may still be alive on this very head (issue #121, the
4029
+ # studio#1243 shape: the reviewer daemon posted a phantom round 2 on
4030
+ # round 1's head four minutes after round 1, and this edge spawned a
4031
+ # second session onto the branch the first was mid-work on). A live
4032
+ # session on an unchanged head gets the new round handed to it with
4033
+ # one steering comment; the spawn below is for a head that moved or a
4034
+ # session that is gone. Above the external-claim check because it
4035
+ # spends nothing (ledger + the pass's roster) and a live local session
4036
+ # is the strongest evidence this edge can hold.
4037
+ if age is None:
4038
+ handed = self._hand_over_round(pr, round_, reviews, roster)
4039
+ if handed is not None:
4040
+ return handed
4041
+
3959
4042
  # The cross-instance claim check, the issue edge's placement
3960
4043
  # transposed: past the fresh-in-flight return, the local ledger no
3961
4044
  # longer vouches for this round, and a peer instance's fresh spawn
@@ -4038,7 +4121,8 @@ class DevWatcher:
4038
4121
  f"session may be there, already triaging (its CR8 "
4039
4122
  f"replies are the footprint); not respawning",
4040
4123
  attempts,
4041
- session=fix_session_name(pr, round_, attempts),
4124
+ session=row_session
4125
+ or fix_session_name(pr, round_, attempts),
4042
4126
  round=round_,
4043
4127
  defer_token=loop_events_mod.DEFER_IN_FLIGHT,
4044
4128
  )
@@ -5554,6 +5638,215 @@ class DevWatcher:
5554
5638
  pr.full_name, pr.number, fix_stalled_kind(round_, attempts)
5555
5639
  )
5556
5640
 
5641
+ def _hand_over_round(
5642
+ self,
5643
+ pr: PullRequest,
5644
+ round_: int,
5645
+ reviews: list[Review],
5646
+ roster: "SessionLiveness | None",
5647
+ ) -> "Decision | None":
5648
+ """Round `round_` just landed and nothing was spawned for it. Is the
5649
+ previous round's fix session still ALIVE on this head? Then the round
5650
+ is ITS to answer (issue #121): post one steering comment, move the
5651
+ session's ledger row to this round, and spawn nothing. None means
5652
+ "no handover -- spawn as always".
5653
+
5654
+ The gates, cheapest first, and every one of them is a reason to keep
5655
+ the legacy spawn:
5656
+
5657
+ * no roster, or a listing that failed -- INDETERMINATE, and the issue
5658
+ keeps today's behaviour for it (a spawn); the fail-safe direction
5659
+ is inverted here on purpose: the wedge and resume gates defer
5660
+ because their remedy is a MUTATION on a possibly-live branch, while
5661
+ this gate's remedy is a comment, so ambiguity must not turn a real
5662
+ new round into silence;
5663
+ * the ledger holds no earlier round for this PR, or its newest round
5664
+ is not below this one (a review deleted outright, or a ledger
5665
+ written by a newer daemon -- NOT a dismissal: SUBMITTED_STATES
5666
+ keeps DISMISSED records, so dismissing never shrinks the count);
5667
+ * the newest session of that earlier round is not ALIVE -- DEAD is
5668
+ the ordinary "session finished, next round" flow, and it spawns;
5669
+ * the head is not that session's (see _head_is_sessions) -- the
5670
+ round is on work someone else pushed, and it spawns.
5671
+
5672
+ The comment lands BEFORE the ledger moves, and the move is skipped
5673
+ when the comment fails: a moved row closes the fresh-in-flight gate
5674
+ above, so a comment that failed once would never be retried. With the
5675
+ fix-handover:r<k> row down, the retry re-enters here with the comment
5676
+ already deduped and just moves the row -- the same retry covers a
5677
+ move that found no row to move (logged as an error: the ledger is
5678
+ then not what the daemon just said it was). Dry-run comments nothing
5679
+ and moves nothing. Both decisions ride Action.IN_FLIGHT with
5680
+ DEFER_IN_FLIGHT: a handover is a hold behind own work already on the
5681
+ lane, and the console must be able to tell it from a spawn.
5682
+
5683
+ What the live session does with it: FIX_DIRECTIVE's _NEWER_ROUNDS
5684
+ clause tells it to look for a newer round before its terminal
5685
+ re-request. If it dies without one, the moved row is what the stale
5686
+ path ages: `stale_minutes` after the handover, with the session gone,
5687
+ the edge re-enqueues attempt k+1 of THIS round (never of the old
5688
+ one), because the row now says this is the round in flight."""
5689
+ if roster is None or not roster.listed:
5690
+ return None
5691
+ previous = self.state.max_fix_round(pr.full_name, pr.number)
5692
+ if not previous or previous >= round_:
5693
+ return None
5694
+ row = self.state.newest_fix_spawn(pr.full_name, pr.number, previous)
5695
+ if row is None:
5696
+ return None
5697
+ session = str(row["session"])
5698
+ if roster.of(session) is not Liveness.ALIVE:
5699
+ return None
5700
+ if not self._head_is_sessions(pr, row, reviews):
5701
+ log.info(
5702
+ "%s round %d landed while the round-%d fix session %s is "
5703
+ "still listed, but head %s is not that session's — spawning "
5704
+ "as usual",
5705
+ pr.pr_slug,
5706
+ round_,
5707
+ previous,
5708
+ session,
5709
+ pr.head_sha[:8],
5710
+ )
5711
+ return None
5712
+
5713
+ kind = fix_handover_kind(round_)
5714
+ body = FIX_HANDOVER_COMMENT.format(
5715
+ new_round=round_,
5716
+ round=previous,
5717
+ session=session,
5718
+ head=pr.head_sha[:8],
5719
+ )
5720
+ if self.config.dry_run:
5721
+ log.info(
5722
+ "[dry-run] would hand round %d of %s to the live fix session "
5723
+ "%s and comment:\n%s",
5724
+ round_,
5725
+ pr.pr_slug,
5726
+ session,
5727
+ body,
5728
+ )
5729
+ elif not self.state.escalated(pr.full_name, pr.number, kind):
5730
+ log.warning(
5731
+ "%s round %d arrived while the round-%d fix session %s is "
5732
+ "ALIVE on the same head %s — handing the round to it (one "
5733
+ "steering comment), NOT spawning a second session",
5734
+ pr.pr_slug,
5735
+ round_,
5736
+ previous,
5737
+ session,
5738
+ pr.head_sha[:8],
5739
+ )
5740
+ try:
5741
+ self.github.comment(pr.owner, pr.repo, pr.number, body)
5742
+ except CommandError as exc:
5743
+ log.error(
5744
+ "could not post the round-%d handover comment on %s: %s "
5745
+ "— not moving the ledger row; the handover retries next "
5746
+ "poll",
5747
+ round_,
5748
+ pr.pr_slug,
5749
+ exc,
5750
+ )
5751
+ return Decision(
5752
+ Action.IN_FLIGHT,
5753
+ f"round {round_} is the live round-{previous} fix "
5754
+ f"session {session}'s to answer, but the steering comment "
5755
+ f"could not be posted — retrying next poll",
5756
+ int(row["attempt"]),
5757
+ session=session,
5758
+ round=round_,
5759
+ defer_token=loop_events_mod.DEFER_IN_FLIGHT,
5760
+ )
5761
+ self.state.record_escalation(pr.full_name, pr.number, kind)
5762
+
5763
+ if not self.config.dry_run:
5764
+ moved = self.state.hand_over_fix_spawn(
5765
+ repo_slug=pr.full_name,
5766
+ number=pr.number,
5767
+ session=session,
5768
+ round_=round_,
5769
+ head_sha=pr.head_sha,
5770
+ )
5771
+ if not moved:
5772
+ log.error(
5773
+ "%s round %d handover: no fix_spawns row carries session "
5774
+ "%s any more — the ledger was not moved (the row is "
5775
+ "still on round %d); retrying the move next poll",
5776
+ pr.pr_slug,
5777
+ round_,
5778
+ session,
5779
+ previous,
5780
+ )
5781
+ return Decision(
5782
+ Action.IN_FLIGHT,
5783
+ f"round {round_} is the live round-{previous} fix "
5784
+ f"session {session}'s to answer (steering comment "
5785
+ f"posted), but its ledger row could not be moved — "
5786
+ f"retrying next poll",
5787
+ int(row["attempt"]),
5788
+ session=session,
5789
+ round=round_,
5790
+ defer_token=loop_events_mod.DEFER_IN_FLIGHT,
5791
+ )
5792
+ # The moved row is attempt 1 of the new round (see
5793
+ # State.hand_over_fix_spawn); dry-run moved nothing and reports the
5794
+ # row as it stands.
5795
+ return Decision(
5796
+ Action.IN_FLIGHT,
5797
+ f"round {round_} handed to the live round-{previous} fix session "
5798
+ f"{session} on the same head {pr.head_sha[:8]} (steering comment "
5799
+ f"posted once; no second session)",
5800
+ int(row["attempt"]) if self.config.dry_run else 1,
5801
+ session=session,
5802
+ round=round_,
5803
+ defer_token=loop_events_mod.DEFER_IN_FLIGHT,
5804
+ )
5805
+
5806
+ def _head_is_sessions(
5807
+ self, pr: PullRequest, row: "sqlite3.Row", reviews: list[Review]
5808
+ ) -> bool:
5809
+ """Is the PR's current head the one the row's session is working --
5810
+ the head it was spawned against, or one it has pushed since?
5811
+
5812
+ Three tiers, cheapest first, and the first two cost nothing:
5813
+
5814
+ 1. the ledger's spawn head (`head_sha`, recorded since issue #121;
5815
+ '' on older rows, which skip this tier);
5816
+ 2. the PREVIOUS verdict's head: the round before this one was
5817
+ reviewed on `commit_id`, and a head still equal to it has not
5818
+ moved between the rounds -- the issue's "simply" test, and the
5819
+ #1243 shape exactly (round 2 on round 1's head);
5820
+ 3. a head that POSTDATES the session's spawn: one branch, one live
5821
+ session, so a commit made after the session started is that
5822
+ session's own push (the directives commit and push within
5823
+ seconds). One GET, on this rare path only, and any failure to
5824
+ read the instant answers False -- the legacy spawn.
5825
+
5826
+ False means "the head moved" for the handover's purposes, which keeps
5827
+ today's behaviour (issue #121 §4)."""
5828
+ spawn_head = str(row["head_sha"] or "")
5829
+ if spawn_head and pr.head_sha == spawn_head:
5830
+ return True
5831
+ if len(reviews) >= 2:
5832
+ before = reviews[-2]
5833
+ if before.commit_id and before.commit_id == pr.head_sha:
5834
+ return True
5835
+ try:
5836
+ committed = self.github.commit_time(pr.owner, pr.repo, pr.head_sha)
5837
+ except CommandError as exc:
5838
+ log.warning(
5839
+ "could not read the commit instant of %s head %s (%s) — "
5840
+ "treating the head as moved",
5841
+ pr.pr_slug,
5842
+ pr.head_sha[:8],
5843
+ exc,
5844
+ )
5845
+ return False
5846
+ if committed is None:
5847
+ return False
5848
+ return committed >= int(row["spawned_at"])
5849
+
5557
5850
  def _verdict(self, pr: PullRequest, newest: Review) -> "str | None":
5558
5851
  """The newest review's verdict, mirroring reviewloop's precedence:
5559
5852
  the GitHub review state is authoritative where it can speak, and
@@ -5787,6 +6080,9 @@ class DevWatcher:
5787
6080
  round_=round_,
5788
6081
  attempt=attempt,
5789
6082
  session=name,
6083
+ # The head this session takes the round against -- the
6084
+ # handover check's first tier (issue #121).
6085
+ head_sha=pr.head_sha,
5790
6086
  )
5791
6087
 
5792
6088
  resumed = (
@@ -8,7 +8,11 @@ can be retried, and to remember which escalations were already raised.
8
8
  The review-response edge keeps its own additive table, `fix_spawns`, keyed
9
9
  (repo_slug, number, round, attempt): `number` is the PULL REQUEST number (the
10
10
  edge has no issue), `round` is the substantive-review count the fix session
11
- answers, and `attempt` retries a presumed-dead fix session within one round.
11
+ is CURRENTLY ANSWERING (the round it was spawned for, until a handover -- see
12
+ `hand_over_fix_spawn` -- moves the row to a later round without spawning),
13
+ `attempt` retries a presumed-dead fix session within one round, and
14
+ `head_sha` is the PR head the row's session took that round against (the
15
+ spawn head, refreshed on handover; '' on rows written before the column).
12
16
  A separate table rather than new columns on `spawns` because the two edges
13
17
  age and dedupe on different keys (issue vs PR+round), and an additive table
14
18
  leaves every existing ledger untouched. Fix-round cap-outs reuse the
@@ -135,6 +139,7 @@ CREATE TABLE IF NOT EXISTS fix_spawns (
135
139
  attempt INTEGER NOT NULL,
136
140
  session TEXT NOT NULL,
137
141
  spawned_at INTEGER NOT NULL,
142
+ head_sha TEXT NOT NULL DEFAULT '',
138
143
  PRIMARY KEY (repo_slug, number, round, attempt)
139
144
  );
140
145
 
@@ -193,6 +198,10 @@ CREATE TABLE IF NOT EXISTS poll_snapshots (
193
198
  _COLUMN_MIGRATIONS = (
194
199
  ("poll_snapshots", "deferred", "INTEGER NOT NULL DEFAULT 0"),
195
200
  ("poll_snapshots", "foreign_reaped", "INTEGER NOT NULL DEFAULT 0"),
201
+ # The head a fix session took its round against (issue #121): rows
202
+ # written before the column read '' and the handover check falls back to
203
+ # the previous verdict's head for them.
204
+ ("fix_spawns", "head_sha", "TEXT NOT NULL DEFAULT ''"),
196
205
  )
197
206
 
198
207
 
@@ -558,18 +567,74 @@ class State:
558
567
  return row is not None
559
568
 
560
569
  def record_fix_spawn(
561
- self, *, repo_slug: str, number: int, round_: int, attempt: int, session: str
570
+ self, *, repo_slug: str, number: int, round_: int, attempt: int, session: str,
571
+ head_sha: str = "",
562
572
  ) -> None:
563
573
  """Record a fix-session spawn for one review round. `repo_slug` is
564
- `owner/repo`; `number` is the PR number."""
574
+ `owner/repo`; `number` is the PR number; `head_sha` is the PR head the
575
+ session was spawned against (the handover check's first tier -- see
576
+ loop.DevWatcher._hand_over_round)."""
565
577
  self._db.execute(
566
578
  "INSERT OR REPLACE INTO fix_spawns "
567
- "(repo_slug, number, round, attempt, session, spawned_at) "
568
- "VALUES (?,?,?,?,?,?)",
569
- (repo_slug, number, round_, attempt, session, int(time.time())),
579
+ "(repo_slug, number, round, attempt, session, spawned_at, head_sha) "
580
+ "VALUES (?,?,?,?,?,?,?)",
581
+ (repo_slug, number, round_, attempt, session, int(time.time()),
582
+ head_sha or ""),
570
583
  )
571
584
  self._db.commit()
572
585
 
586
+ def newest_fix_spawn(
587
+ self, repo_slug: str, number: int, round_: int
588
+ ) -> "sqlite3.Row | None":
589
+ """The newest fix-spawn row of THIS PR round (session, attempt,
590
+ spawned_at, head_sha), or None when the round was never spawned --
591
+ newest_fix_spawn_session with the rest of the row, for the handover
592
+ check that needs the head and the spawn instant too."""
593
+ return self._db.execute(
594
+ "SELECT session, attempt, spawned_at, head_sha FROM fix_spawns "
595
+ "WHERE repo_slug=? AND number=? AND round=? "
596
+ "ORDER BY spawned_at DESC, attempt DESC LIMIT 1",
597
+ (repo_slug, number, round_),
598
+ ).fetchone()
599
+
600
+ def hand_over_fix_spawn(
601
+ self, *, repo_slug: str, number: int, session: str, round_: int,
602
+ head_sha: str,
603
+ ) -> bool:
604
+ """Move a live fix session's row to the round it is now answering
605
+ (issue #121): a later review round landed on the head that session is
606
+ still working, so the daemon handed the round to it instead of
607
+ spawning a second session. The row's `round` becomes `round_`, its
608
+ `head_sha` the head handed over, and `spawned_at` NOW -- the staleness
609
+ clock restarts at the handover, because the session took the new
610
+ round at that instant and the stale path must not fire on it one poll
611
+ later just because the session was already old.
612
+
613
+ ONE row moves, not a copy: the console's inbox then shows one session
614
+ answering one round, `max_fix_round` reads the handed-over round (so
615
+ the reaper never counts the session as superseded by the round it is
616
+ answering), and the round's attempt budget starts at this row -- so
617
+ the row is RENUMBERED to attempt 1 (PR #122 review round 1). The
618
+ attempt ledger's contract is that a round's attempts are contiguous
619
+ from 1 (`fix_attempt_count` is a COUNT, and `attempts + 1` must be a
620
+ free slot for the next spawn); a resumed session's row is attempt 2,
621
+ and moving it as attempt 2 would leave the new round with one row
622
+ that COUNTs as one attempt -- the stale respawn would then
623
+ `INSERT OR REPLACE` over it as attempt 2 every stale window, and the
624
+ attempt cap would never fire. The target key (repo_slug, number,
625
+ round_, 1) is free by construction: the caller only hands over a
626
+ round nothing was spawned for. Strict like record_fix_spawn: a row
627
+ the daemon believes it moved but did not would spawn over the live
628
+ session next poll. Returns False when no row carries `session` on
629
+ this PR."""
630
+ cursor = self._db.execute(
631
+ "UPDATE fix_spawns SET round=?, attempt=1, head_sha=?, spawned_at=? "
632
+ "WHERE repo_slug=? AND number=? AND session=?",
633
+ (round_, head_sha or "", int(time.time()), repo_slug, number, session),
634
+ )
635
+ self._db.commit()
636
+ return cursor.rowcount > 0
637
+
573
638
  def fix_attempt_count(self, repo_slug: str, number: int, round_: int) -> int:
574
639
  """Fix attempts already spawned for THIS round of this PR. Keyed per
575
640
  round on purpose: a new review round starts a fresh attempt budget."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.22
3
+ Version: 0.8.23
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera