alissa-tools-github-devloop 0.8.17__tar.gz → 0.8.18__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {alissa_tools_github_devloop-0.8.17/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.18}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/__main__.py +11 -0
  3. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/config.py +72 -0
  4. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/ghclient.py +11 -0
  5. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/loop.py +286 -5
  6. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/loop_events.py +6 -2
  7. alissa_tools_github_devloop-0.8.18/src/main/alissa/tools/github/devloop/version +1 -0
  8. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  9. alissa_tools_github_devloop-0.8.17/src/main/alissa/tools/github/devloop/version +0 -1
  10. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/LICENSE +0 -0
  11. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/MANIFEST.in +0 -0
  12. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/NOTICE +0 -0
  13. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/README.md +0 -0
  14. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/requirements.txt +0 -0
  15. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/setup.cfg +0 -0
  16. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/setup.py +0 -0
  17. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  18. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  19. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  20. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  21. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  22. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  23. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/state.py +0 -0
  24. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/version.py +0 -0
  25. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  26. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  27. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  28. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  29. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  30. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  31. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  32. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  33. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  34. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  35. {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.17
3
+ Version: 0.8.18
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -256,6 +256,16 @@ def build_parser() -> argparse.ArgumentParser:
256
256
  help="never complete a draft's handoff mechanically — an unhanded-off "
257
257
  "draft waits for an operator (also narrows the PR search to non-drafts)",
258
258
  )
259
+ over.add_argument(
260
+ "--wedge-minutes",
261
+ type=int,
262
+ metavar="MINUTES",
263
+ help="wedge detection: kill an ALIVE session behind its own still-"
264
+ "draft, never-handed-off PR once its activity footprint (spawn, last "
265
+ "head commit, last PR update) is older than this, so the next pass "
266
+ "resumes it as attempt k+1; 0 = off (the default); floored at "
267
+ "stale_minutes",
268
+ )
259
269
  over.add_argument(
260
270
  "--orphan-grace-minutes",
261
271
  type=int,
@@ -401,6 +411,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
401
411
  "maintain_label": args.maintain_label,
402
412
  "maintain_enabled": args.maintain_enabled,
403
413
  "resume_enabled": args.resume_enabled,
414
+ "wedge_minutes": args.wedge_minutes,
404
415
  "rerequest_enabled": args.rerequest_enabled,
405
416
  "rerequest_grace_minutes": args.rerequest_grace_minutes,
406
417
  "orphan_sweep_enabled": args.orphan_sweep_enabled,
@@ -28,12 +28,15 @@ through to the config rather than fail the daemon's boot on a mode of `''`.
28
28
  from __future__ import annotations
29
29
 
30
30
  import json
31
+ import logging
31
32
  import os
32
33
  import re
33
34
  from dataclasses import dataclass, field
34
35
  from pathlib import Path
35
36
  from typing import Any, Mapping
36
37
 
38
+ log = logging.getLogger(__name__)
39
+
37
40
  # What to do when a labeled issue has no matching Alissa origin task (the
38
41
  # label may have been applied by hand, outside the Alissa task flow).
39
42
  ON_MISSING_WARN = "warn_and_spawn" # spawn, but log loudly
@@ -147,6 +150,7 @@ CONFIG_KEYS = (
147
150
  "maintain_label",
148
151
  "maintain_enabled",
149
152
  "resume_enabled",
153
+ "wedge_minutes",
150
154
  "rerequest_enabled",
151
155
  "rerequest_grace_minutes",
152
156
  "orphan_sweep_enabled",
@@ -452,6 +456,33 @@ class Config:
452
456
  # operator who wants a dead-with-PR lane to wait for a human.
453
457
  resume_enabled: bool = True
454
458
 
459
+ # Wedge detection on the issue edge (issue #110): the activity-footprint
460
+ # window behind the resume above. Liveness measures session EXISTENCE,
461
+ # not work: a worker wedged at an interactive prompt reads ALIVE forever,
462
+ # and behind a still-DRAFT PR (never handed off -- nothing else will ever
463
+ # move it) the ALIVE defer above is an infinite one (observed on
464
+ # fahera-mx/studio.alissa.app#1121: 31+ hours behind draft PR #1123,
465
+ # one stalled page, then silence). With a positive value, an ALIVE
466
+ # session whose lane is otherwise RESUMABLE (exactly the `_resumable_pr`
467
+ # admission -- still a draft, no reviewer, self-authored, daemon-shaped
468
+ # head, a body that CLOSES the issue) and whose activity footprint -- the
469
+ # NEWEST of the ledger row's spawn, the PR's last update, and the head
470
+ # commit's date -- is older than this many minutes is killed (the ledger
471
+ # row's own session name, the same `alissa tmux kill` a healthy handoff
472
+ # ends with), so the next pass reads it DEAD and the ordinary resume path
473
+ # above takes over as attempt k+1, counting toward attempt_cap like any
474
+ # resume. 0 (the default) DISABLES it: absent or 0 preserves the ALIVE
475
+ # defer bit for bit, so a deployment opts in explicitly. Never fires on
476
+ # INDETERMINATE (absence of evidence keeps the defer), never on a PR that
477
+ # left draft or has a reviewer engaged, and never with resume_enabled
478
+ # false (a kill whose resume cannot fire would only orphan the lane).
479
+ # Floored at stale_minutes: a wedge window shorter than the staleness
480
+ # window is a misconfiguration (the branch this runs on is only reached
481
+ # past stale_minutes), so `build()` clamps it up and warns rather than
482
+ # failing the boot. Read `wedge_seconds`, which applies the same floor,
483
+ # never this field.
484
+ wedge_minutes: int = 0
485
+
455
486
  # The wedge self-heal (issue #61): the fix edge's crash-recovery net under
456
487
  # FIX_DIRECTIVE's terminal step. A fix session that posted its triage
457
488
  # replies and PUSHED, then died before re-requesting review, leaves the PR
@@ -642,6 +673,22 @@ class Config:
642
673
  ready-and-request a draft opened seconds ago on a default config."""
643
674
  return (self.orphan_grace_minutes or self.stale_minutes) * 60
644
675
 
676
+ @property
677
+ def wedge_seconds(self) -> int:
678
+ """The activity-footprint window behind which an ALIVE session on a
679
+ resumable draft is killed for resume, in seconds -- 0 means the wedge
680
+ path is OFF. Read this, never the raw field: unlike the grace knobs
681
+ above, 0 here is not a sentinel for the stale window but the OFF
682
+ switch, and a positive value is floored at stale_minutes (the branch
683
+ this gates is only reached past the stale window, so a shorter
684
+ window would fire on its first evaluation). `build()` already clamps
685
+ the field and warns; the floor is applied again here so a Config
686
+ assembled without `build()` (tests, `dataclasses.replace`) can never
687
+ read a window below the staleness one."""
688
+ if self.wedge_minutes <= 0:
689
+ return 0
690
+ return max(self.wedge_minutes, self.stale_minutes) * 60
691
+
645
692
  @property
646
693
  def janitor_grace_seconds(self) -> int:
647
694
  """How long an anchor must have been terminal before its workspace may
@@ -904,6 +951,30 @@ class Config:
904
951
  f"got {orphan_grace}"
905
952
  )
906
953
 
954
+ # The wedge window (issue #110). 0 is the OFF switch, not a fallback
955
+ # sentinel, so the floor is 0 -- a negative value is the only invalid
956
+ # one. A positive value below stale_minutes is a misconfiguration
957
+ # (the wedge branch is only reached past the stale window), but not
958
+ # one worth failing the boot over: clamp it up to stale_minutes and
959
+ # say so, exactly as the issue's contract asks.
960
+ wedge = int(raw.get("wedge_minutes", cls.wedge_minutes))
961
+ if wedge < 0:
962
+ raise ValueError(
963
+ f"wedge_minutes must be >= 0 (0 = wedge detection off), "
964
+ f"got {wedge}"
965
+ )
966
+ if 0 < wedge < stale:
967
+ log.warning(
968
+ "wedge_minutes=%d is below stale_minutes=%d — a wedge window "
969
+ "shorter than the staleness window cannot be honoured (the "
970
+ "wedge branch is only reached past the stale window); "
971
+ "clamping wedge_minutes up to %d",
972
+ wedge,
973
+ stale,
974
+ stale,
975
+ )
976
+ wedge = stale
977
+
907
978
  # The janitor's grace, the reaper's sentinel one rung longer (0 = use
908
979
  # reap_grace_minutes). Negative is the only invalid value, and it is
909
980
  # the dangerous one: it would license reclaiming a workspace the
@@ -999,6 +1070,7 @@ class Config:
999
1070
  resume_enabled=bool(
1000
1071
  raw.get("resume_enabled", cls.resume_enabled)
1001
1072
  ),
1073
+ wedge_minutes=wedge,
1002
1074
  rerequest_enabled=bool(
1003
1075
  raw.get("rerequest_enabled", cls.rerequest_enabled)
1004
1076
  ),
@@ -146,6 +146,16 @@ class PullRequest:
146
146
  # base nobody observed. Defaulted like node_id and closed_at -- the edges
147
147
  # that only spawn never ask.
148
148
  base_ref: str = ""
149
+ # When GitHub last touched this PR (`updated_at`), unix seconds; None when
150
+ # the payload carried no readable stamp. GitHub bumps it on every event
151
+ # that lands on the PR -- a push to the head branch (synchronize), an
152
+ # edit, a comment, a label, a review -- so it is the cheapest upper bound
153
+ # on "when did anything last happen here". The issue edge's wedge
154
+ # detection (issue #110) reads it as one leg of the activity footprint
155
+ # behind an ALIVE session; it rides the `pull_request()` fetch the resume
156
+ # probe already pays. Defaulted like the fields above it: the other edges
157
+ # and their fakes never ask.
158
+ updated_at: "int | None" = None
149
159
 
150
160
  @property
151
161
  def full_name(self) -> str:
@@ -201,6 +211,7 @@ def _pull_request_from(
201
211
  node_id=data.get("node_id") or "",
202
212
  closed_at=parse_github_timestamp(data.get("closed_at")),
203
213
  base_ref=base.get("ref") or "",
214
+ updated_at=parse_github_timestamp(data.get("updated_at")),
204
215
  )
205
216
 
206
217
 
@@ -72,6 +72,30 @@ behind `resume_enabled`: that switch gates an ACTION the daemon gained, and
72
72
  turning it off restores a conservative legacy; this is a guard against a
73
73
  DOUBLE SPAWN, and no version of "off" for it is safe.
74
74
 
75
+ ALIVE has a blind spot of its own: liveness measures session EXISTENCE, not
76
+ work. A worker wedged at an interactive prompt is a live process and a dead
77
+ worker, and it reads ALIVE forever. Behind a handed-off PR that is harmless
78
+ (the review lifecycle moves the PR, and the reaper collects the session once
79
+ the PR is terminal), but behind a STILL-DRAFT PR nothing else will ever move
80
+ it: pre-handoff the dev session owns the pace, the "review loop can outlast
81
+ stale_minutes" rationale does not apply, and the deferral above is infinite
82
+ (studio.alissa.app#1121 sat 31+ hours behind draft PR #1123, paged once,
83
+ then silence). So the issue edge has an OPT-IN fifth signal on exactly that
84
+ branch -- ALIVE, and `_resumable_pr` admits the PR -- an ACTIVITY FOOTPRINT:
85
+ the newest of the ledger row's spawn, the PR's last update, and the head
86
+ commit's date. A footprint older than `wedge_minutes` (0 = off, the default)
87
+ is a session that exists and does nothing, and the daemon KILLS it -- the
88
+ ledger row's own session name only, the same `alissa tmux kill` a healthy
89
+ handoff ends with -- so the next pass reads it DEAD and the ordinary resume
90
+ above takes over as attempt k+1, counting toward attempt_cap like any
91
+ resume. Nothing about the kill is new machinery on the resume side: it
92
+ manufactures the one signal the matrix already acts on. The invariants are
93
+ 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
96
+ kill whose resume cannot fire would only orphan the lane), and never a
97
+ roster sweep.
98
+
75
99
  The same pass then runs the REVIEW-RESPONSE edge (fix_rounds_enabled): open
76
100
  PRs authored by the token identity whose newest substantive review verdict is
77
101
  request_changes, with no pending re-request and no push since that review --
@@ -1046,14 +1070,24 @@ ACTIVITY_REREQUESTED = "re-requested"
1046
1070
  # including the one that wrote it.
1047
1071
  ACTIVITY_HANDED_OFF = "handed off"
1048
1072
 
1073
+ # The activity-log context prefix for a wedge kill (see ESCALATION_WEDGED).
1074
+ # Non-spawn like the three above, and more so: the line records that the
1075
+ # lane's session was KILLED, the exact opposite of a running session. A reader
1076
+ # that took it for a spawn claim would park the lane behind a phantom for up
1077
+ # to a stale_minutes window -- against a peer instance during an overlapping
1078
+ # deploy, or this daemon after ledger loss, the cases external_claim exists
1079
+ # for -- delaying the very resume the kill was issued to unblock.
1080
+ ACTIVITY_WEDGE_KILLED = "wedge-killed"
1081
+
1049
1082
  # Line contexts that record something OTHER than a spawn. `activity_claims`
1050
1083
  # skips them: a claim is a promise that a session is running on the lane, and
1051
- # neither a capacity deferral, a mechanical re-request, nor a mechanical
1052
- # handoff makes one.
1084
+ # neither a capacity deferral, a mechanical re-request, a mechanical handoff,
1085
+ # nor a wedge kill makes one.
1053
1086
  ACTIVITY_NON_SPAWN = (
1054
1087
  ACTIVITY_DEFERRED,
1055
1088
  ACTIVITY_REREQUESTED,
1056
1089
  ACTIVITY_HANDED_OFF,
1090
+ ACTIVITY_WEDGE_KILLED,
1057
1091
  )
1058
1092
 
1059
1093
  # The reader's grammar for a line `_activity_line` wrote: bullet, UTC
@@ -1110,6 +1144,16 @@ ESCALATION_FIX_STALLED = "fix-stalled"
1110
1144
  # neither ping may silence the other.
1111
1145
  ESCALATION_LIVE_STALLED = "live-stalled"
1112
1146
 
1147
+ # The wedge kill's once-per-episode marker (see wedge_kind). Not an operator
1148
+ # page -- the daemon acted instead of asking -- but, like ESCALATION_REREQUEST,
1149
+ # a once-per-key act the `escalations` table is the ledger of: it dedupes the
1150
+ # activity-comment line (a kill that has to be repeated must not grow the
1151
+ # comment by one line per poll) and it is what the loop-events emitter turns
1152
+ # into an `escalation.wedged` event. The KILL itself is never deduped on it:
1153
+ # a session that survives its kill is still wedged, and re-killing it every
1154
+ # poll is the remedy, not a bug.
1155
+ ESCALATION_WEDGED = "wedged"
1156
+
1113
1157
  # The maintenance edge's cap-out kind prefix. The `maintain-` prefix keeps
1114
1158
  # rows from ever colliding with the issue edge's `cap` when an issue and a
1115
1159
  # PR share a number; the epoch suffix is maintain_cap_kind's job below.
@@ -1425,6 +1469,17 @@ def live_stalled_kind(attempt: int) -> str:
1425
1469
  return f"{ESCALATION_LIVE_STALLED}:a{attempt}"
1426
1470
 
1427
1471
 
1472
+ def wedge_kind(attempt: int) -> str:
1473
+ """The ledger kind for ONE wedge-kill episode's activity line.
1474
+
1475
+ stalled_kind's episode reasoning again: every attempt can wedge behind
1476
+ its own draft, and attempt k's row must not silence attempt k+1's line.
1477
+ Only the activity line is keyed on it -- the kill is repeated whenever
1478
+ the footprint still warrants it (see ESCALATION_WEDGED).
1479
+ """
1480
+ return f"{ESCALATION_WEDGED}:a{attempt}"
1481
+
1482
+
1428
1483
  def deferral_activity_kind(session: str) -> str:
1429
1484
  """The activity-log kind that dedupes ONE capacity-deferral episode's line.
1430
1485
 
@@ -2356,9 +2411,10 @@ def activity_claims(body: str, now: "float | None" = None) -> "list[ActivityClai
2356
2411
  Spawn records only: a capacity-deferral line explicitly claims nothing (no
2357
2412
  self-assign, no ledger row, no session -- see _spawn's gate), and neither
2358
2413
  does a mechanical re-request (it completes a DEAD session's protocol; no
2359
- session is running afterwards), so reading either as a claim would let an
2360
- instance park the lane against everybody including itself. The context
2361
- field is what tells them apart -- see ACTIVITY_NON_SPAWN.
2414
+ session is running afterwards) nor a wedge kill (it records that the
2415
+ lane's session was just KILLED), so reading any of them as a claim would
2416
+ let an instance park the lane against everybody including itself. The
2417
+ context field is what tells them apart -- see ACTIVITY_NON_SPAWN.
2362
2418
 
2363
2419
  Everything else fails open, one line at a time: a line that does not match
2364
2420
  the grammar, or whose stamp is not an instant, or whose stamp is
@@ -2607,6 +2663,32 @@ class DevWatcher:
2607
2663
  liveness = self.session_liveness(row_session, roster)
2608
2664
  if self.config.resume_enabled and liveness is Liveness.DEAD:
2609
2665
  resume_pr = self._resumable_pr(issue, linked)
2666
+ elif (
2667
+ self.config.resume_enabled
2668
+ and self.config.wedge_seconds
2669
+ and liveness is Liveness.ALIVE
2670
+ and row_session
2671
+ ):
2672
+ # Wedge detection (issue #110), the opt-in escape from
2673
+ # the infinite ALIVE defer. Reached ONLY on ALIVE -- a
2674
+ # SUCCESSFUL listing that carries the row's own name --
2675
+ # never on INDETERMINATE, whose absence of evidence keeps
2676
+ # the defer exactly as the Liveness docstring argues.
2677
+ # `_wedge_candidate` sits strictly behind `_resumable_pr`
2678
+ # (a handed-off PR belongs to the review lifecycle) and
2679
+ # then asks the one question liveness cannot: has this
2680
+ # lane shown ANY activity inside the wedge window? If
2681
+ # not, the session is killed here and nothing else
2682
+ # happens this pass: the next pass reads it DEAD and the
2683
+ # ordinary resume branch above does the rest. A fresh
2684
+ # footprint, or a footprint that cannot be read, falls
2685
+ # through to the floored defer below, unchanged.
2686
+ wedged = self._wedge_candidate(issue, linked, age)
2687
+ if wedged is not None:
2688
+ wedged_pr, footprint = wedged
2689
+ return self._kill_wedged(
2690
+ issue, attempts, row_session, wedged_pr, footprint
2691
+ )
2610
2692
  if resume_pr is not None:
2611
2693
  # Fall through to the cap check and the spawn below: a
2612
2694
  # resume is an ORDINARY attempt (it counts toward
@@ -3167,6 +3249,205 @@ class DevWatcher:
3167
3249
  )
3168
3250
  return False
3169
3251
 
3252
+ def _wedge_candidate(
3253
+ self, issue: Issue, linked: "list[str]", spawn_age: float
3254
+ ) -> "tuple[PullRequest, float] | None":
3255
+ """The wedge decision's evidence: the one resumable PR behind this
3256
+ ALIVE session and the age of the lane's activity footprint, or None
3257
+ when the lane must keep its defer.
3258
+
3259
+ Two gates, in order. First `_resumable_pr`, verbatim -- the wedge
3260
+ 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
3263
+ 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`):
3267
+ None means unreadable, and unreadable keeps the defer -- the same
3268
+ fail-safe every other presumed-dead decision here makes.
3269
+
3270
+ Cost: nothing beyond what the DEAD branch already pays for the same
3271
+ lane -- `_resumable_pr`'s one GET per linked PR -- except the head
3272
+ commit fetch, which `_footprint_age` only makes once the cheap legs
3273
+ have already run past the window."""
3274
+ pr = self._resumable_pr(issue, linked)
3275
+ if pr is None:
3276
+ return None
3277
+ footprint = self._footprint_age(pr, spawn_age)
3278
+ if footprint is None:
3279
+ return None
3280
+ window = self.config.wedge_seconds
3281
+ if footprint < window:
3282
+ log.debug(
3283
+ "%s: session behind draft PR %s reads alive with a footprint "
3284
+ "%.0f min old (wedge window %d min) — not wedged",
3285
+ issue.issue_slug,
3286
+ pr.url,
3287
+ footprint / 60,
3288
+ window // 60,
3289
+ )
3290
+ return None
3291
+ return pr, footprint
3292
+
3293
+ def _footprint_age(
3294
+ self, pr: PullRequest, spawn_age: float
3295
+ ) -> "float | None":
3296
+ """How long ago this lane last showed ANY activity, in seconds: the
3297
+ YOUNGEST of the ledger row's spawn, the PR's last update, and the
3298
+ head commit's committer date. None when that cannot be established.
3299
+
3300
+ The three legs are the three places a working session leaves a
3301
+ mark. The spawn is the floor (a session younger than the window
3302
+ cannot be wedged yet -- it is still starting). `updated_at` is
3303
+ GitHub's own "anything happened here" stamp: it moves on every push
3304
+ to the head (synchronize), every edit, comment, label and review,
3305
+ and it rides the `pull_request()` fetch the resume probe already
3306
+ paid, so on the common path the footprint costs no call at all. The
3307
+ head commit's date is the third leg, read only once the first two
3308
+ have ALREADY run past the window: it is redundant with `updated_at`
3309
+ on every push GitHub saw, so paying for it while the cheap legs
3310
+ still prove activity would be a per-poll GET for a question already
3311
+ answered. Where it IS read it is the last word before a kill, and
3312
+ an unreadable commit (CommandError, or a payload with no date)
3313
+ answers None rather than "old" -- the daemon does not kill a
3314
+ session over evidence it could not read. RateLimited is NOT caught:
3315
+ like every other GitHub call in the pass it belongs to run_forever's
3316
+ backoff."""
3317
+ now = time.time()
3318
+ youngest = spawn_age
3319
+ if pr.updated_at is not None:
3320
+ youngest = min(youngest, max(0.0, now - pr.updated_at))
3321
+ if youngest < self.config.wedge_seconds:
3322
+ return youngest
3323
+ try:
3324
+ committed = self.github.commit_time(pr.owner, pr.repo, pr.head_sha)
3325
+ except CommandError as exc:
3326
+ log.warning(
3327
+ "wedge: could not read the head commit date of %s (%s) — "
3328
+ "the footprint cannot be established, so the session keeps "
3329
+ "its defer this poll",
3330
+ pr.pr_slug,
3331
+ exc,
3332
+ )
3333
+ return None
3334
+ if committed is None:
3335
+ log.warning(
3336
+ "wedge: head commit %s of %s carries no readable date — the "
3337
+ "footprint cannot be established, so the session keeps its "
3338
+ "defer this poll",
3339
+ pr.head_sha[:8],
3340
+ pr.pr_slug,
3341
+ )
3342
+ return None
3343
+ return min(youngest, max(0.0, now - committed))
3344
+
3345
+ def _kill_wedged(
3346
+ self,
3347
+ issue: Issue,
3348
+ attempts: int,
3349
+ session: str,
3350
+ pr: PullRequest,
3351
+ footprint: float,
3352
+ ) -> Decision:
3353
+ """Kill the ledger row's own session so the next pass can resume
3354
+ the lane, and say so everywhere an operator would look.
3355
+
3356
+ The kill is the daemon's own session-kill machinery -- one `alissa
3357
+ tmux kill <name>` on exactly the row's recorded name, the same act a
3358
+ healthy handoff ends with, never a roster sweep. It is the ONLY side
3359
+ effect this pass has on the lane: no assignment changes, no ledger
3360
+ row is written for an attempt, nothing is enqueued. The decision
3361
+ stays IN_FLIGHT -- the lane is still deferred behind its draft --
3362
+ and what changes is the NEXT evaluation: a successful listing
3363
+ without this name reads DEAD, `_resumable_pr` admits the same PR
3364
+ it admitted here, and the ordinary resume spawns attempt k+1 (or,
3365
+ at the cap, pages the operator exactly as any resume would). A
3366
+ kill that fails is logged and the defer holds; the next poll makes
3367
+ the same decision and tries again, which is the remedy for a
3368
+ session that will not die, not a loop to guard against.
3369
+
3370
+ The activity comment gets ONE line per episode (keyed
3371
+ wedge_kind(attempts)), recorded only after the line lands so a
3372
+ transient failure retries -- the comment is the cross-actor record
3373
+ of why this session vanished, and the recorded row is what the
3374
+ loop-events emitter turns into `escalation.wedged`. Every side
3375
+ effect honours dry_run."""
3376
+ minutes = int(footprint / 60)
3377
+ window = self.config.wedge_seconds // 60
3378
+ log.warning(
3379
+ "wedged: alive session %s with no footprint for %d min behind "
3380
+ "draft PR #%d (%s) — killed for resume (%s attempt %d; wedge "
3381
+ "window %d min; the next pass reads it dead and resumes as "
3382
+ "attempt %d, counting toward attempt_cap %d)",
3383
+ session,
3384
+ minutes,
3385
+ pr.number,
3386
+ pr.url,
3387
+ issue.issue_slug,
3388
+ attempts,
3389
+ window,
3390
+ attempts + 1,
3391
+ self.config.attempt_cap,
3392
+ )
3393
+ reason = (
3394
+ f"attempt {attempts} is wedged: its session {session} reads alive "
3395
+ f"but the lane's activity footprint is {minutes} min old (wedge "
3396
+ f"window {window} min) behind still-draft PR {pr.url} — "
3397
+ )
3398
+ if self.config.dry_run:
3399
+ log.info("[dry-run] would kill wedged session %s", session)
3400
+ return Decision(
3401
+ Action.IN_FLIGHT,
3402
+ reason + "[dry-run] would kill it for resume",
3403
+ attempts,
3404
+ session=session,
3405
+ defer_token=loop_events_mod.DEFER_IN_FLIGHT,
3406
+ )
3407
+ try:
3408
+ self.alissa.kill_session(session)
3409
+ except CommandError as exc:
3410
+ log.warning(
3411
+ "wedge: could not kill %s (%s) — kills are best-effort; the "
3412
+ "lane keeps its defer and the kill is retried next poll",
3413
+ session,
3414
+ exc,
3415
+ )
3416
+ return Decision(
3417
+ Action.IN_FLIGHT,
3418
+ reason + f"the kill failed ({exc}); deferring",
3419
+ attempts,
3420
+ session=session,
3421
+ defer_token=loop_events_mod.DEFER_IN_FLIGHT,
3422
+ )
3423
+ kind = wedge_kind(attempts)
3424
+ if not self.state.escalated(issue.full_name, issue.number, kind):
3425
+ landed = self._append_activity(
3426
+ issue.owner,
3427
+ issue.repo,
3428
+ issue.number,
3429
+ self._activity_line(
3430
+ session,
3431
+ "issue edge",
3432
+ f"{ACTIVITY_WEDGE_KILLED} — the session read alive but the lane's "
3433
+ f"activity footprint (spawn / last head commit / last PR "
3434
+ f"update) was {minutes} min old behind still-draft PR "
3435
+ f"{pr.url}; killed so the next pass resumes it as attempt "
3436
+ f"{attempts + 1}",
3437
+ ),
3438
+ issue.issue_slug,
3439
+ )
3440
+ if landed:
3441
+ self.state.record_escalation(issue.full_name, issue.number, kind)
3442
+ return Decision(
3443
+ Action.IN_FLIGHT,
3444
+ reason + f"killed for resume; the next pass reads it dead and "
3445
+ f"resumes as attempt {attempts + 1}",
3446
+ attempts,
3447
+ session=session,
3448
+ defer_token=loop_events_mod.DEFER_IN_FLIGHT,
3449
+ )
3450
+
3170
3451
  def _escalate_rejection(self, issue: Issue, exc: AssignmentRejected) -> Decision:
3171
3452
  """Escalate-don't-retry: a silently dropped assignment is a permanent,
3172
3453
  operator-fixable condition (push access), and without the in-flight
@@ -122,8 +122,12 @@ _FOREIGN_RE = re.compile(r"^foreign-session:(.+)$")
122
122
 
123
123
  # Escalation kinds anchored on a repo ISSUE; everything else that names a
124
124
  # number names a PULL REQUEST (the fix/maintain/orphan/rerequest families all
125
- # key the PR number into the ledger's `issue` column).
126
- _ISSUE_ANCHORED = ("cap", "assignment", "stalled", "live-stalled")
125
+ # key the PR number into the ledger's `issue` column). `wedged` is the issue
126
+ # edge's wedge kill (loop.ESCALATION_WEDGED, recorded against the issue the
127
+ # lane is working, not the draft PR behind it) -- PR and issue numbers share
128
+ # GitHub's namespace, so a kind missing here does not merely mislabel, it
129
+ # points the operator at an unrelated PR or at nothing.
130
+ _ISSUE_ANCHORED = ("cap", "assignment", "stalled", "live-stalled", "wedged")
127
131
 
128
132
 
129
133
  def _ms(ts: "int | float | str") -> int:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.17
3
+ Version: 0.8.18
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera