alissa-tools-github-devloop 0.6.2__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {alissa_tools_github_devloop-0.6.2/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.7.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/__main__.py +31 -1
  3. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/alissa.py +33 -11
  4. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/config.py +49 -0
  5. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/ghclient.py +102 -0
  6. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/loop.py +993 -38
  7. alissa_tools_github_devloop-0.7.0/src/main/alissa/tools/github/devloop/version +1 -0
  8. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  9. alissa_tools_github_devloop-0.6.2/src/main/alissa/tools/github/devloop/version +0 -1
  10. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/MANIFEST.in +0 -0
  11. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/README.md +0 -0
  12. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/requirements.txt +0 -0
  13. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/setup.cfg +0 -0
  14. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/setup.py +0 -0
  15. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  16. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  17. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/state.py +0 -0
  18. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/version.py +0 -0
  19. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  20. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  21. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  22. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  23. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  24. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  25. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  26. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  27. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  28. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  29. {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.6.2
3
+ Version: 0.7.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -165,6 +165,32 @@ def build_parser() -> argparse.ArgumentParser:
165
165
  "never auto-resume — a dead-with-PR lane waits for an operator",
166
166
  )
167
167
 
168
+ rerequest = over.add_mutually_exclusive_group()
169
+ rerequest.add_argument(
170
+ "--rerequest",
171
+ dest="rerequest_enabled",
172
+ action="store_true",
173
+ default=None,
174
+ help="self-heal a PR whose fix session pushed and then died before "
175
+ "re-requesting review: once no worker session for it is listed and "
176
+ "the head has been public past the grace window, re-request review "
177
+ "mechanically, once per PR/round/head (the default)",
178
+ )
179
+ rerequest.add_argument(
180
+ "--no-rerequest",
181
+ dest="rerequest_enabled",
182
+ action="store_false",
183
+ help="never re-request review mechanically — a push whose session "
184
+ "died before its terminal act waits for an operator",
185
+ )
186
+ over.add_argument(
187
+ "--rerequest-grace-minutes",
188
+ type=int,
189
+ metavar="MINUTES",
190
+ help="how long the pushed head must have been public before the "
191
+ "wedge self-heal fires; 0 = use stale_minutes (the default)",
192
+ )
193
+
168
194
  claims = over.add_mutually_exclusive_group()
169
195
  claims.add_argument(
170
196
  "--activity-claims",
@@ -221,6 +247,8 @@ def overrides_from(args: argparse.Namespace) -> dict:
221
247
  "maintain_label": args.maintain_label,
222
248
  "maintain_enabled": args.maintain_enabled,
223
249
  "resume_enabled": args.resume_enabled,
250
+ "rerequest_enabled": args.rerequest_enabled,
251
+ "rerequest_grace_minutes": args.rerequest_grace_minutes,
224
252
  "activity_claims_enabled": args.activity_claims_enabled,
225
253
  "dry_run": args.dry_run,
226
254
  }
@@ -254,13 +282,15 @@ def log_effective_config(config: Config, login: str) -> None:
254
282
  log.info(
255
283
  "poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
256
284
  "max_sessions=%s; fix_rounds=%s; maintain=%s (label %r); resume=%s; "
257
- "activity_claims=%s",
285
+ "rerequest=%s (grace %s min); activity_claims=%s",
258
286
  config.poll_interval, config.dry_run, config.attempt_cap,
259
287
  config.stale_minutes,
260
288
  config.max_sessions or "unlimited",
261
289
  config.fix_rounds_enabled,
262
290
  config.maintain_enabled, config.maintain_label,
263
291
  config.resume_enabled,
292
+ config.rerequest_enabled,
293
+ config.rerequest_grace_seconds // 60,
264
294
  config.activity_claims_enabled,
265
295
  )
266
296
  log.debug("hub_template: %s", config.hub_template)
@@ -70,23 +70,45 @@ class Alissa:
70
70
 
71
71
  def list_sessions(self) -> "list[dict]":
72
72
  """Managed tmux sessions as `alissa tmux ls --json` reports them
73
- (dicts carrying at least `name` and `status`). Raises CommandError
74
- when the CLI cannot answer at all (the caller skips that sweep);
75
- unparseable or non-list JSON degrades to an empty list -- a broken
76
- listing must read as "nothing to reap", never crash the poll."""
77
- out = run(["alissa", "tmux", "ls", "--json"], timeout=60)
73
+ (dicts carrying at least `name` and `status`).
74
+
75
+ Raises CommandError whenever the CLI does not answer USABLY: a
76
+ non-zero exit, or a zero exit whose stdout is empty, unparseable, or
77
+ not a JSON list. `--json` serialises its array and returns BEFORE the
78
+ human-readable empty case, so a working CLI with nothing to report
79
+ prints `[]`, never nothing -- which makes empty/garbled output a CLI
80
+ that failed to answer, not an answer of "no sessions". A PARSED empty
81
+ list is the genuine zero-session listing and is returned as such.
82
+
83
+ That distinction is load-bearing, not pedantry: callers feed this to
84
+ SessionLiveness, where a successful listing missing a name means the
85
+ session is DEAD. Degrading unusable output to `[]` would hand every
86
+ caller a confident, empty, WRONG roster -- respawning over sessions
87
+ that are merely unlistable, which is the double-spawn this daemon's
88
+ staleness matrix exists to prevent.
89
+
90
+ Raising cannot crash a poll: every caller already treats CommandError
91
+ as skip-this-pass (the reaper skips its sweep, the max_sessions budget
92
+ fails open, and the roster reads INDETERMINATE, so neither a respawn
93
+ nor a resume can fire on a listing that did not happen)."""
94
+ argv = ["alissa", "tmux", "ls", "--json"]
95
+ out = run(argv, timeout=60)
78
96
  if not out.strip():
79
- return []
97
+ raise CommandError(
98
+ argv, 0, "`alissa tmux ls --json` printed nothing (a working "
99
+ "CLI prints `[]` when there are no sessions)",
100
+ )
80
101
  try:
81
102
  data = json.loads(out)
82
103
  except json.JSONDecodeError:
83
- log.warning(
84
- "`alissa tmux ls --json` returned unparseable output -- "
85
- "treating as no sessions this sweep"
104
+ raise CommandError(
105
+ argv, 0, "`alissa tmux ls --json` returned unparseable output",
86
106
  )
87
- return []
88
107
  if not isinstance(data, list):
89
- return []
108
+ raise CommandError(
109
+ argv, 0, "`alissa tmux ls --json` returned "
110
+ f"{type(data).__name__}, not a JSON list",
111
+ )
90
112
  return [entry for entry in data if isinstance(entry, dict)]
91
113
 
92
114
  def kill_session(self, name: str) -> None:
@@ -63,6 +63,8 @@ CONFIG_KEYS = (
63
63
  "maintain_label",
64
64
  "maintain_enabled",
65
65
  "resume_enabled",
66
+ "rerequest_enabled",
67
+ "rerequest_grace_minutes",
66
68
  "activity_claims_enabled",
67
69
  "dry_run",
68
70
  )
@@ -181,6 +183,30 @@ class Config:
181
183
  # operator who wants a dead-with-PR lane to wait for a human.
182
184
  resume_enabled: bool = True
183
185
 
186
+ # The wedge self-heal (issue #61): the fix edge's crash-recovery net under
187
+ # FIX_DIRECTIVE's terminal step. A fix session that posted its triage
188
+ # replies and PUSHED, then died before re-requesting review, leaves the PR
189
+ # in a state NEITHER daemon can exit -- revloop only sees PRs with a review
190
+ # requested from its identity, and the fix edge reads the push as "an
191
+ # implementer is already active". Rather than spawn a session, the daemon
192
+ # completes the dead one's protocol itself: it re-requests review from the
193
+ # reviewer whose verdict is being answered, exactly once per (PR, round,
194
+ # head). Default ON -- it closes a 3+ hour operator-only wedge (observed on
195
+ # fahera-mx/studio.alissa.app#242, 2026-07-27), it re-requests rather than
196
+ # spawns (so it can never double-drive a branch), and every side effect
197
+ # still honors dry_run. false restores the legacy read: the push closes the
198
+ # edge and a human re-requests.
199
+ rerequest_enabled: bool = True
200
+
201
+ # How long the pushed head must have been public before the self-heal
202
+ # fires. A live session re-requests SECONDS after its push, so firing on
203
+ # sight would race it; the window is what turns "a push exists" into "a
204
+ # push exists and nobody followed it up". 0 means "the fix-session stale
205
+ # window" (stale_minutes) -- the same clock every other presumed-dead
206
+ # decision on this edge is made on, and the documented default. Set a
207
+ # positive value to widen or narrow it independently.
208
+ rerequest_grace_minutes: int = 0
209
+
184
210
  # Cross-instance spawn dedupe (issue #53): before spawning on any edge,
185
211
  # read the trigger artifact's mechanical activity comment and treat a
186
212
  # spawn line younger than stale_minutes as an in-flight claim EVEN when
@@ -219,6 +245,15 @@ class Config:
219
245
  return default_state_path(self.workspace_root)
220
246
  return Path(self.state_path).expanduser()
221
247
 
248
+ @property
249
+ def rerequest_grace_seconds(self) -> int:
250
+ """How long a pushed head must have been public before the wedge
251
+ self-heal fires, in seconds. Read this, never the raw field: 0 is the
252
+ sentinel for "the fix-session stale window", so the fallback has to
253
+ live in ONE place or a caller reading the field directly would fire
254
+ the re-request instantly on a default config."""
255
+ return (self.rerequest_grace_minutes or self.stale_minutes) * 60
256
+
222
257
  @property
223
258
  def manifest_path(self) -> Path:
224
259
  return self.workspace_root / "alissa-workspace.yaml"
@@ -321,6 +356,16 @@ class Config:
321
356
  f"max_sessions must be >= 0 (0 = unlimited), got {max_sessions}"
322
357
  )
323
358
 
359
+ # 0 is the sentinel for "fall back to stale_minutes" (the documented
360
+ # default), so the floor is 0 like max_sessions' -- a negative grace
361
+ # would make the self-heal fire on a head pushed in the future.
362
+ grace = int(raw.get("rerequest_grace_minutes", cls.rerequest_grace_minutes))
363
+ if grace < 0:
364
+ raise ValueError(
365
+ f"rerequest_grace_minutes must be >= 0 (0 = use stale_minutes), "
366
+ f"got {grace}"
367
+ )
368
+
324
369
  interval = int(raw.get("poll_interval", cls.poll_interval))
325
370
  if interval < MIN_POLL_INTERVAL:
326
371
  raise ValueError(
@@ -353,6 +398,10 @@ class Config:
353
398
  resume_enabled=bool(
354
399
  raw.get("resume_enabled", cls.resume_enabled)
355
400
  ),
401
+ rerequest_enabled=bool(
402
+ raw.get("rerequest_enabled", cls.rerequest_enabled)
403
+ ),
404
+ rerequest_grace_minutes=grace,
356
405
  activity_claims_enabled=bool(
357
406
  raw.get("activity_claims_enabled", cls.activity_claims_enabled)
358
407
  ),
@@ -176,6 +176,35 @@ class IdentityMismatch(RuntimeError):
176
176
  """Configured developer identity disagrees with the gh token."""
177
177
 
178
178
 
179
+ class ReviewRequestRejected(RuntimeError):
180
+ """`request_review` returned 2xx but the reviewer is not on the PR.
181
+
182
+ The `requested_reviewers` endpoint answers with the (possibly unchanged)
183
+ pull request, so a request GitHub declined to honour -- a reviewer without
184
+ read access, a login that has since been renamed, an author who cannot
185
+ request themselves -- can come back 2xx with nothing added. The mechanical
186
+ re-request leans on that field being TRUE afterwards (it is the whole
187
+ remedy: the reviewer daemon's only entry point is a
188
+ `review-requested:@me` search), so the response is checked exactly as
189
+ `assign_self` checks its own.
190
+
191
+ The caller's contract is escalate-don't-retry, the same one
192
+ AssignmentRejected gets on the issue edge and for the same reason: every
193
+ cause above is PERMANENT and operator-fixable, so retrying achieves
194
+ nothing but write traffic against an endpoint GitHub has already refused.
195
+ `loop.DevWatcher._escalate_rerequest_rejected` pages the operator on the
196
+ PR once and records `loop.rerequest_rejected_kind`, which short-circuits
197
+ the next poll before any probe is paid for; a NEW head is a new key and
198
+ gets a fresh attempt.
199
+
200
+ What still separates this from AssignmentRejected is the OTHER ledger row
201
+ it must not write: a refused re-request is never recorded as DONE
202
+ (`loop.rerequest_kind`). The remedy did not happen, and a row saying it
203
+ did would re-wedge the PR permanently with nothing left that can see it.
204
+ Distinct from a transient CommandError on the same call, which keeps the
205
+ plain retry."""
206
+
207
+
179
208
  class AssignmentRejected(RuntimeError):
180
209
  """`assign_self` returned 2xx but the login is not on the issue.
181
210
 
@@ -609,6 +638,79 @@ class GitHub:
609
638
  if isinstance(r, dict)
610
639
  ]
611
640
 
641
+ def commit_time(self, owner: str, repo: str, sha: str) -> "int | None":
642
+ """A commit's COMMITTER date as unix seconds, or None when it cannot
643
+ be read -- the grace window's clock behind the mechanical re-request.
644
+
645
+ GitHub's REST API exposes no "when was this ref pushed" field on a pull
646
+ request, and plain pushes raise no timeline event (only force-pushes
647
+ do), so the head commit's committer date is the cheapest instant
648
+ available. It is a LOWER BOUND on the push: a commit created locally
649
+ and pushed minutes later stamps the earlier moment, so an age measured
650
+ from it over-estimates how long the head has been public and the grace
651
+ window can expire marginally early. For the sessions this daemon
652
+ spawns, commit and push are seconds apart (the directives push
653
+ immediately after committing), and the caller tightens the bound
654
+ further by taking the later of this and the review the push answers --
655
+ the push necessarily postdates that review.
656
+
657
+ The GIT-OBJECT endpoint, not `repos/:o/:r/commits/:sha`: the latter
658
+ ships the commit's full file list and patches (up to 300 files) to
659
+ deliver one date, while `git/commits/:sha` returns the object alone --
660
+ a few hundred bytes whatever the commit's size, with the date one
661
+ level shallower. Immaterial on the steady-state path (the done-marker
662
+ short-circuits ahead of this call once the remedy has fired) and not
663
+ immaterial on the transient-retry path, where a poll that keeps
664
+ failing would otherwise re-fetch a possibly-large merge commit every
665
+ cycle.
666
+
667
+ None on anything unreadable (no such commit, a malformed payload, a
668
+ date GitHub did not spell in UTC `Z`): the caller treats an unknown
669
+ push instant as "cannot prove the grace has passed" and defers, which
670
+ is the fail-safe direction for a call that ends in a mutation."""
671
+ data = self._api(f"repos/{owner}/{repo}/git/commits/{sha}") or {}
672
+ committer = data.get("committer") or {}
673
+ return parse_github_timestamp(committer.get("date"))
674
+
675
+ def request_review(self, owner: str, repo: str, number: int, reviewer: str) -> None:
676
+ """Request review from ONE login on a pull request.
677
+
678
+ The API call behind a fix session's terminal step (`gh pr edit
679
+ --add-reviewer`), performed by the daemon itself when the session that
680
+ owed it died first. `--input` rather than `-f reviewers[]=...` for the
681
+ same reason `assign_self` uses it: gh 2.4.0 predates the array syntax
682
+ and the endpoint wants `{"reviewers": [...]}`.
683
+
684
+ The response is the pull request, so the request is verified to have
685
+ LANDED (see ReviewRequestRejected) -- casefolded, because GitHub logins
686
+ are case-insensitive while the reviewer set is operator-typed."""
687
+ payload = json.dumps({"reviewers": [reviewer]})
688
+ fd, body_path = tempfile.mkstemp(prefix="devloop-rereview-", suffix=".json")
689
+ try:
690
+ with os.fdopen(fd, "w") as handle:
691
+ handle.write(payload)
692
+ data = self._api(
693
+ "-X",
694
+ "POST",
695
+ f"repos/{owner}/{repo}/pulls/{number}/requested_reviewers",
696
+ "--input",
697
+ body_path,
698
+ )
699
+ finally:
700
+ os.unlink(body_path)
701
+
702
+ landed = tuple(
703
+ (r.get("login") or "")
704
+ for r in ((data or {}).get("requested_reviewers") or [])
705
+ )
706
+ if reviewer.casefold() not in {name.casefold() for name in landed}:
707
+ raise ReviewRequestRejected(
708
+ f"{owner}/{repo}#{number}: GitHub accepted the request but "
709
+ f"{reviewer!r} is not among the requested reviewers {landed!r} "
710
+ f"-- the review request did not land, so the reviewer daemon "
711
+ f"still cannot see this PR."
712
+ )
713
+
612
714
  def issue(self, owner: str, repo: str, number: int) -> Issue:
613
715
  # `or {}`: run_json returns None on empty stdout, and the siblings
614
716
  # (search_issues, assign_self) already guard the same way.