alissa-tools-github-devloop 0.5.1__tar.gz → 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {alissa_tools_github_devloop-0.5.1/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.6.1}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/__main__.py +42 -1
  3. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/config.py +40 -0
  4. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/ghclient.py +70 -13
  5. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/loop.py +1225 -165
  6. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/state.py +61 -0
  7. alissa_tools_github_devloop-0.6.1/src/main/alissa/tools/github/devloop/version +1 -0
  8. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/webui/sources.py +2 -0
  9. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  10. alissa_tools_github_devloop-0.5.1/src/main/alissa/tools/github/devloop/version +0 -1
  11. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/MANIFEST.in +0 -0
  12. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/README.md +0 -0
  13. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/requirements.txt +0 -0
  14. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/setup.cfg +0 -0
  15. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/setup.py +0 -0
  16. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  17. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  18. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  19. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/version.py +0 -0
  20. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  21. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  22. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  23. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  24. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  25. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  26. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  27. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  28. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  29. {alissa_tools_github_devloop-0.5.1 → alissa_tools_github_devloop-0.6.1}/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.5.1
3
+ Version: 0.6.1
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -147,6 +147,42 @@ def build_parser() -> argparse.ArgumentParser:
147
147
  "sessions, even if the config enables it",
148
148
  )
149
149
 
150
+ resume = over.add_mutually_exclusive_group()
151
+ resume.add_argument(
152
+ "--resume",
153
+ dest="resume_enabled",
154
+ action="store_true",
155
+ default=None,
156
+ help="auto-resume a stale attempt whose session is provably dead "
157
+ "(absent from a successful `alissa tmux ls`) while its PR stays open, "
158
+ "instead of deferring behind an operator ping (the default)",
159
+ )
160
+ resume.add_argument(
161
+ "--no-resume",
162
+ dest="resume_enabled",
163
+ action="store_false",
164
+ help="legacy two-signal staleness: never read session liveness, "
165
+ "never auto-resume — a dead-with-PR lane waits for an operator",
166
+ )
167
+
168
+ claims = over.add_mutually_exclusive_group()
169
+ claims.add_argument(
170
+ "--activity-claims",
171
+ dest="activity_claims_enabled",
172
+ action="store_true",
173
+ default=None,
174
+ help="read the trigger artifact's activity comment for another "
175
+ "instance's fresh spawn line and defer on it, so spawn dedupe "
176
+ "survives a ledger the local instance never wrote (the default)",
177
+ )
178
+ claims.add_argument(
179
+ "--no-activity-claims",
180
+ dest="activity_claims_enabled",
181
+ action="store_false",
182
+ help="ledger-only spawn dedupe: never read external claim evidence "
183
+ "(two overlapping instances can then both spawn on one item)",
184
+ )
185
+
150
186
  dry = over.add_mutually_exclusive_group()
151
187
  dry.add_argument(
152
188
  "--dry-run",
@@ -184,6 +220,8 @@ def overrides_from(args: argparse.Namespace) -> dict:
184
220
  "fix_rounds_enabled": args.fix_rounds_enabled,
185
221
  "maintain_label": args.maintain_label,
186
222
  "maintain_enabled": args.maintain_enabled,
223
+ "resume_enabled": args.resume_enabled,
224
+ "activity_claims_enabled": args.activity_claims_enabled,
187
225
  "dry_run": args.dry_run,
188
226
  }
189
227
 
@@ -215,12 +253,15 @@ def log_effective_config(config: Config, login: str) -> None:
215
253
  log.info("reviewers: %s", ", ".join(config.reviewers) or "none")
216
254
  log.info(
217
255
  "poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
218
- "max_sessions=%s; fix_rounds=%s; maintain=%s (label %r)",
256
+ "max_sessions=%s; fix_rounds=%s; maintain=%s (label %r); resume=%s; "
257
+ "activity_claims=%s",
219
258
  config.poll_interval, config.dry_run, config.attempt_cap,
220
259
  config.stale_minutes,
221
260
  config.max_sessions or "unlimited",
222
261
  config.fix_rounds_enabled,
223
262
  config.maintain_enabled, config.maintain_label,
263
+ config.resume_enabled,
264
+ config.activity_claims_enabled,
224
265
  )
225
266
  log.debug("hub_template: %s", config.hub_template)
226
267
  log.debug("agent_profile: %s", config.agent_profile)
@@ -62,6 +62,8 @@ CONFIG_KEYS = (
62
62
  "fix_rounds_enabled",
63
63
  "maintain_label",
64
64
  "maintain_enabled",
65
+ "resume_enabled",
66
+ "activity_claims_enabled",
65
67
  "dry_run",
66
68
  )
67
69
 
@@ -161,6 +163,38 @@ class Config:
161
163
  maintain_label: str = "alissa:maintain"
162
164
  maintain_enabled: bool = True
163
165
 
166
+ # The third staleness signal (session liveness, read from the reaper's own
167
+ # tmux listing) and the auto-resume it unlocks: a stale attempt whose
168
+ # ledger session is provably gone is respawned with RESUME_DIRECTIVE /
169
+ # FIX_RESUME_DIRECTIVE instead of parking the lane behind an operator
170
+ # ping. Provably gone is necessary, not sufficient: a healthy handoff ends
171
+ # in `alissa tmux kill` too, so on the ISSUE edge the resume additionally
172
+ # requires the linked PR to show the handoff never happened AND to be this
173
+ # issue's own work -- exactly one linked open PR, still a DRAFT, no
174
+ # reviewer requested, authored by the token identity, daemon-shaped head
175
+ # ref, and a body that CLOSES this issue (a cross-reference is raised by
176
+ # any mention, so a sibling task's PR would otherwise qualify). Default ON
177
+ # -- it closes the loop's one
178
+ # deliberate self-healing gap, and every side effect still honors dry_run.
179
+ # false restores the legacy two-signal matrix WHOLESALE on both edges (the
180
+ # floored defer, no liveness lookup, no resume): the escape hatch for an
181
+ # operator who wants a dead-with-PR lane to wait for a human.
182
+ resume_enabled: bool = True
183
+
184
+ # Cross-instance spawn dedupe (issue #53): before spawning on any edge,
185
+ # read the trigger artifact's mechanical activity comment and treat a
186
+ # spawn line younger than stale_minutes as an in-flight claim EVEN when
187
+ # the local ledger has no row. The ledger is instance-local and its volume
188
+ # follows the active deployment, so during a deploy overlap or a
189
+ # crashloop two daemons run with one GitHub identity and two ledgers and
190
+ # both spawn; the activity comment is the one claim record either can
191
+ # read. Default ON -- it closes a thrice-observed duplicate-PR incident,
192
+ # it can only ever DEFER a spawn, and a claim expires with the same
193
+ # stale_minutes window a ledger row does. false restores the
194
+ # ledger-only dedupe: the escape hatch for a single-instance deployment
195
+ # that would rather not pay the extra comments fetch.
196
+ activity_claims_enabled: bool = True
197
+
164
198
  dry_run: bool = False
165
199
 
166
200
  # Derived at build time: `repos` casefolded for matching. GitHub names are
@@ -316,6 +350,12 @@ class Config:
316
350
  maintain_enabled=bool(
317
351
  raw.get("maintain_enabled", cls.maintain_enabled)
318
352
  ),
353
+ resume_enabled=bool(
354
+ raw.get("resume_enabled", cls.resume_enabled)
355
+ ),
356
+ activity_claims_enabled=bool(
357
+ raw.get("activity_claims_enabled", cls.activity_claims_enabled)
358
+ ),
319
359
  dry_run=bool(raw.get("dry_run", cls.dry_run)),
320
360
  )
321
361
 
@@ -107,6 +107,13 @@ class PullRequest:
107
107
  head_sha: str
108
108
  draft: bool
109
109
  url: str
110
+ # The PR description. Carried for PROVENANCE: a cross-reference proves
111
+ # only that a PR mentioned an issue, while a closing keyword ("Closes
112
+ # #<n>") is the author's claim that this PR is the issue's work -- the
113
+ # one the developer directives mandate, and the only thing that tells
114
+ # THIS issue's PR from a sibling task's PR that merely said "related to
115
+ # #<n>". Empty when the PR has no body.
116
+ body: str
110
117
  # Logins/slugs whose review is CURRENTLY requested (users and teams
111
118
  # merged): non-empty means the ball is in a reviewer's court, so the
112
119
  # review-response edge is closed. GitHub clears an entry when that
@@ -536,9 +543,11 @@ class GitHub:
536
543
  return newest
537
544
 
538
545
  def pull_request(self, owner: str, repo: str, number: int) -> PullRequest:
539
- """The evaluate_pr() re-fetch: draft state, head, author, and the
540
- pending review requests -- the consistency guard over search
541
- staleness, like issue() is for the issue edge."""
546
+ """The evaluate_pr() re-fetch: draft state, head, author, body, and
547
+ the pending review requests -- the consistency guard over search
548
+ staleness, like issue() is for the issue edge. (The body is what the
549
+ issue edge's resume probe reads for provenance; it rides along on the
550
+ same GET rather than costing a call of its own.)"""
542
551
  data = self._api(f"repos/{owner}/{repo}/pulls/{number}") or {}
543
552
  head = data.get("head") or {}
544
553
  # Users carry `login`, teams `slug`; both hold the edge closed -- a
@@ -564,6 +573,8 @@ class GitHub:
564
573
  head_sha=head.get("sha") or "",
565
574
  draft=bool(data.get("draft")),
566
575
  url=data.get("html_url", ""),
576
+ # `or ""`: GitHub sends null for an empty description.
577
+ body=data.get("body") or "",
567
578
  requested_reviewers=requested,
568
579
  )
569
580
 
@@ -619,7 +630,14 @@ class GitHub:
619
630
  ),
620
631
  )
621
632
 
622
- def linked_open_prs(self, owner: str, repo: str, number: int) -> list[str]:
633
+ def linked_open_prs(
634
+ self,
635
+ owner: str,
636
+ repo: str,
637
+ number: int,
638
+ *,
639
+ exhaustive: bool = False,
640
+ ) -> list[str]:
623
641
  """URLs of OPEN same-repo pull requests linked to the issue -- the
624
642
  external liveness signal behind the stale-retry decision.
625
643
 
@@ -647,10 +665,9 @@ class GitHub:
647
665
  comments, labeled/assigned/mentioned events, bot noise -- ahead of
648
666
  the cross-reference, so a long pre-label discussion can push it past
649
667
  the first page, and a first-page-only probe would mis-read live work
650
- as dead (and respawn over it). The walk is bounded: it stops as soon
651
- as a page has yielded any open same-repo PR (every caller's question
652
- is effectively boolean -- further pages could only lengthen a
653
- comment's URL list), it stops at the
668
+ as dead (and respawn over it). The walk is bounded: by default it
669
+ stops as soon as a page has yielded any open same-repo PR (see
670
+ `exhaustive` below), it stops at the
654
671
  first short page (the timeline is exhausted) and hard-caps at
655
672
  TIMELINE_MAX_PAGES pages (TIMELINE_MAX_PAGES * TIMELINE_PAGE_SIZE
656
673
  events) -- a cross-reference past that ceiling is invisible to the
@@ -660,6 +677,21 @@ class GitHub:
660
677
  explicit `page=` loop is the safe shape for this client. Malformed
661
678
  events are skipped, never fatal -- one odd timeline entry must not
662
679
  wedge the staleness decision.
680
+
681
+ `exhaustive` picks WHICH question the walk answers. Default False is
682
+ the boolean one -- is ANY open same-repo PR linked? -- and stops at
683
+ the first page that yields a URL, because for that question later
684
+ pages can only lengthen a log message. True keeps walking to the
685
+ short page (or the TIMELINE_MAX_PAGES ceiling) and returns the
686
+ COMPLETE set within it: the issue edge's resume probe does not ask
687
+ whether a PR is linked but WHICH one, and it deliberately refuses to
688
+ act on more than one candidate -- against a truncated list that
689
+ refusal is unreachable, because "exactly one survivor" can just mean
690
+ the second was on the next page, and the daemon would push to the one
691
+ it happened to page in. The ceiling applies to both modes: a
692
+ cross-reference past TIMELINE_MAX_PAGES * TIMELINE_PAGE_SIZE events
693
+ is invisible either way, so `exhaustive` means complete UP TO the
694
+ documented ceiling, not unbounded.
663
695
  """
664
696
  same_repo_pull = f"https://github.com/{owner}/{repo}/pull/"
665
697
  urls: list[str] = []
@@ -696,11 +728,13 @@ class GitHub:
696
728
  if not url.startswith(same_repo_pull):
697
729
  continue # a mention from another repo says nothing here
698
730
  urls.append(url)
699
- if urls:
700
- # The caller's question is boolean -- is ANY open same-repo
701
- # PR linked? -- so once a page yields one, later pages can
702
- # only add more URLs to a message, never change the
703
- # decision: stop paying for them.
731
+ if urls and not exhaustive:
732
+ # The DEFAULT caller's question is boolean -- is ANY open
733
+ # same-repo PR linked? -- so once a page yields one, later
734
+ # pages can only add more URLs to a message, never change the
735
+ # decision: stop paying for them. An `exhaustive` caller is
736
+ # asking which PR, not whether, and for THAT question the
737
+ # later pages are the whole point -- so it keeps walking.
704
738
  break
705
739
  if len(events) < TIMELINE_PAGE_SIZE:
706
740
  break # short page: the timeline is exhausted
@@ -787,6 +821,29 @@ class GitHub:
787
821
  if isinstance(c, dict)
788
822
  ]
789
823
 
824
+ def marked_comment(
825
+ self, owner: str, repo: str, number: int, marker: str
826
+ ) -> "IssueComment | None":
827
+ """THE own-authored comment carrying `marker` on this issue/PR, or None.
828
+
829
+ The find half of the activity comment's find-or-create, lifted out of
830
+ the writer so the READER shares one definition of "the daemon's
831
+ activity comment" with it -- a marker/author filter that drifted
832
+ between the two would let a spawn append to one comment while the
833
+ cross-instance claim check read another.
834
+
835
+ Both halves of the filter are load-bearing. The marker names the
836
+ mechanical log (an operator page on the same artifact must never be
837
+ mistaken for it), and OWN AUTHORSHIP is what keeps a marker anyone can
838
+ paste from being PATCHed by the writer -- or, for the reader, from
839
+ being able to park a lane by spoofing a claim line. First match wins,
840
+ matching the writer's `mine[0]`.
841
+ """
842
+ for comment in self.issue_comments(owner, repo, number):
843
+ if comment.author == self.login and marker in comment.body:
844
+ return comment
845
+ return None
846
+
790
847
  def update_comment(
791
848
  self, owner: str, repo: str, comment_id: int, body: str
792
849
  ) -> None: