alissa-tools-github-devloop 0.6.1__tar.gz → 0.6.3__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.1/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.6.3}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/alissa.py +33 -11
  3. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/loop.py +238 -8
  4. alissa_tools_github_devloop-0.6.3/src/main/alissa/tools/github/devloop/version +1 -0
  5. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  6. alissa_tools_github_devloop-0.6.1/src/main/alissa/tools/github/devloop/version +0 -1
  7. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/MANIFEST.in +0 -0
  8. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/README.md +0 -0
  9. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/requirements.txt +0 -0
  10. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/setup.cfg +0 -0
  11. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/setup.py +0 -0
  12. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  13. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  14. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/config.py +0 -0
  15. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  16. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  17. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/state.py +0 -0
  18. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/version.py +0 -0
  19. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  20. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  21. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  22. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  23. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  24. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  25. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  26. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  27. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  28. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  29. {alissa_tools_github_devloop-0.6.1 → alissa_tools_github_devloop-0.6.3}/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.1
3
+ Version: 0.6.3
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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:
@@ -55,6 +55,23 @@ wholesale and restores the legacy two-signal matrix. The local ledger only
55
55
  counts attempts, ages the newest spawn so a dead session can be retried, and
56
56
  remembers which escalation kinds were already raised.
57
57
 
58
+ The third signal is load-bearing in the OTHER direction too, on the branch
59
+ where no PR is linked at all -- the implement phase, before the session has
60
+ anything to show for itself. There the second signal is STRUCTURALLY absent
61
+ (not "no evidence of life" but "no place life could have left evidence
62
+ yet"), so elapsed time alone used to decide, and any honest implementation
63
+ longer than stale_minutes was read as a death: studio.alissa.app#231 spawned
64
+ a duplicate attempt over a session that opened its PR four minutes later,
65
+ and both PRs ran a full review. The roster answers what the missing PR
66
+ cannot -- a name present in a listing is a session that is RUNNING, whatever
67
+ it has published -- so only DEAD (absent from a SUCCESSFUL listing) respawns,
68
+ exactly as this edge always did, while ALIVE and INDETERMINATE take the same
69
+ floored defer, with its own "live stalled" episode ping naming the session
70
+ and the `alissa tmux kill` that would release the lane. This guard is NOT
71
+ behind `resume_enabled`: that switch gates an ACTION the daemon gained, and
72
+ turning it off restores a conservative legacy; this is a guard against a
73
+ DOUBLE SPAWN, and no version of "off" for it is safe.
74
+
58
75
  The same pass then runs the REVIEW-RESPONSE edge (fix_rounds_enabled): open
59
76
  PRs authored by the token identity whose newest substantive review verdict is
60
77
  request_changes, with no pending re-request and no push since that review --
@@ -485,6 +502,18 @@ STALLED_COMMENT = (
485
502
  "attempt while the PR is open."
486
503
  )
487
504
 
505
+ LIVE_STALLED_COMMENT = (
506
+ "**Dev loop stalled?** — attempt {attempts} has been in flight "
507
+ "{minutes} min and has opened no pull request, but its session "
508
+ "`{session}` {status}. The daemon never respawns an attempt it cannot "
509
+ "prove is gone (a second session would land on the same branch in the "
510
+ "same worktree and open a duplicate PR), so it keeps deferring the "
511
+ "retry — is that session still making progress? Operator options: check "
512
+ "it, and if it is wedged `alissa tmux kill {session}` to let the retry "
513
+ "proceed, or finish the work by hand. Unassigning the issue alone is "
514
+ "not enough — the daemon's ledger keeps aging this attempt regardless."
515
+ )
516
+
488
517
  FIX_CAP_COMMENT = (
489
518
  "**Fix loop cap-out** — {attempts} fix attempt(s) were spawned on review "
490
519
  "round {round} of this PR without any of them completing the round "
@@ -595,6 +624,15 @@ ESCALATION_STALLED = "stalled"
595
624
  ESCALATION_FIX_CAP = "fix-cap"
596
625
  ESCALATION_FIX_STALLED = "fix-stalled"
597
626
 
627
+ # The pre-PR liveness deferral's own kind (see live_stalled_kind). Distinct
628
+ # from ESCALATION_STALLED because the two stories are different and so are
629
+ # their remedies: `stalled` says "a PR is open and holding the retry -- close
630
+ # it", `live-stalled` says "no PR exists and the session itself is still
631
+ # listed -- kill it if it is wedged". A lane can hit both (a session that
632
+ # stalls before its PR and a later attempt that stalls behind one), and
633
+ # neither ping may silence the other.
634
+ ESCALATION_LIVE_STALLED = "live-stalled"
635
+
598
636
  # The maintenance edge's cap-out kind prefix. The `maintain-` prefix keeps
599
637
  # rows from ever colliding with the issue edge's `cap` when an issue and a
600
638
  # PR share a number; the epoch suffix is maintain_cap_kind's job below.
@@ -676,6 +714,25 @@ def stalled_kind(attempt: int) -> str:
676
714
  return f"{ESCALATION_STALLED}:a{attempt}"
677
715
 
678
716
 
717
+ def live_stalled_kind(attempt: int) -> str:
718
+ """The ledger kind for the stalled ping of ONE pre-PR liveness-deferral
719
+ episode.
720
+
721
+ stalled_kind's episode reasoning, transposed to the deferral that holds
722
+ when the timer is stale, no PR exists yet, and the attempt's session is
723
+ still listed (or unresolvable). It recurs for the same reason: every
724
+ attempt can wedge before opening its PR, and attempt k's ping must not
725
+ silence attempt k+1's -- keyed on the bare kind, the TERMINAL attempt
726
+ wedging would defer silently forever, with the cap-out (below the
727
+ deferral) unreachable. The `live-` prefix keeps it independent of the
728
+ linked-PR stall: one lane can raise both, and each names a remedy the
729
+ other does not. Delivery contract as stalled_kind's -- the row lands only
730
+ AFTER the comment posts, so a transient failure retries next poll and the
731
+ ping lands exactly once per episode.
732
+ """
733
+ return f"{ESCALATION_LIVE_STALLED}:a{attempt}"
734
+
735
+
679
736
  def deferral_activity_kind(session: str) -> str:
680
737
  """The activity-log kind that dedupes ONE capacity-deferral episode's line.
681
738
 
@@ -796,6 +853,29 @@ class Liveness(str, Enum):
796
853
  INDETERMINATE = "indeterminate"
797
854
 
798
855
 
856
+ # The two readings that hold the pre-PR deferral, spelled for the operator who
857
+ # reads LIVE_STALLED_COMMENT. ALIVE is evidence, INDETERMINATE is the absence
858
+ # of it, and the remedy differs (kill a wedged session vs repair the session
859
+ # listing), so the ping must not blur them into one sentence. DEAD is absent
860
+ # on purpose: it never defers.
861
+ #
862
+ # The call site reads this with a fallback, and the dead branch is deliberate.
863
+ # What keeps DEAD out is not the enum -- it is the CALL-SITE INVARIANT (`if
864
+ # liveness is not Liveness.DEAD:`), which lives ~500 lines away in the branch
865
+ # most likely to be edited when the matrix grows again. A total lookup would
866
+ # be correct today and raise KeyError the moment a fourth Liveness member or
867
+ # a new caller reaches this, crashing the poll on the one path whose whole
868
+ # job is to page a human about a lane nothing else can reach. Clumsy wording
869
+ # still reaches the operator; a KeyError does not.
870
+ LIVE_STALLED_STATUS = {
871
+ Liveness.ALIVE: "is still present in the machine's session listing",
872
+ Liveness.INDETERMINATE: (
873
+ "cannot be resolved — the session listing is unavailable, so the "
874
+ "daemon has no evidence either way"
875
+ ),
876
+ }
877
+
878
+
799
879
  class SessionLiveness:
800
880
  """The pass's tmux roster -- the oracle behind `session_liveness`.
801
881
 
@@ -807,6 +887,14 @@ class SessionLiveness:
807
887
  listing SUCCEEDED and the row's name is absent from it. A tmux hiccup can
808
888
  never manufacture a respawn.
809
889
 
890
+ That argument is only as good as the caller's ability to tell a listing
891
+ that SUCCEEDED from one that merely exited 0, so `Alissa.list_sessions`
892
+ raises CommandError on empty, unparseable, or non-list output rather than
893
+ degrading it to `[]`: an empty ROSTER (`listed`, nothing live) must mean
894
+ the machine really is idle, because every lookup against it answers DEAD.
895
+ A garbled listing read as an empty one would be an oracle that is
896
+ confident, wrong, and wrong in the respawning direction.
897
+
810
898
  `reaped` is the set of names the sweep just killed: they were live in the
811
899
  listing but are not any more, so they resolve DEAD (the same discounting
812
900
  `_count_active_sessions` does for the budget). A `live: False` row (a
@@ -1125,7 +1213,12 @@ class DevWatcher:
1125
1213
  edit) must resolve to whatever it actually says. `roster` None -- no
1126
1214
  listing was taken for this call at all, which is how every direct
1127
1215
  `evaluate()` caller that does not run a sweep behaves -- reads
1128
- INDETERMINATE, i.e. today's legacy matrix."""
1216
+ INDETERMINATE, exactly like a listing that failed: on the resume
1217
+ branches that is the legacy matrix (no resume can fire), and on the
1218
+ pre-PR branch it is the deferral, because "the caller gave me no way
1219
+ to check" is the same ambiguity as "the check did not answer", and
1220
+ neither may license a respawn onto a possibly-live worktree. The
1221
+ polling path always supplies a roster (see `_sweep_and_budget`)."""
1129
1222
  if roster is None:
1130
1223
  return Liveness.INDETERMINATE
1131
1224
  return roster.of(session)
@@ -1283,6 +1376,72 @@ class DevWatcher:
1283
1376
  attempts,
1284
1377
  session=session_name(issue, attempts),
1285
1378
  )
1379
+ else:
1380
+ # No linked open PR: the IMPLEMENT phase, before the session
1381
+ # has anything to show for itself. The second signal is
1382
+ # STRUCTURALLY absent here -- not "no evidence of life" but
1383
+ # "no place life could have left evidence yet" -- so without
1384
+ # the third signal elapsed time alone decided, and any honest
1385
+ # implementation longer than stale_minutes was read as a
1386
+ # death (fahera-mx/studio.alissa.app#231: attempt 1 opened its
1387
+ # PR four minutes after attempt 2 was spawned on top of it,
1388
+ # and both PRs ran a full review). The roster answers the
1389
+ # question the missing PR cannot: a name present in the
1390
+ # listing is a session that is RUNNING, whatever it has
1391
+ # published. Only DEAD -- a name absent from a SUCCESSFUL
1392
+ # listing -- respawns, exactly as this edge always did;
1393
+ # ALIVE and INDETERMINATE take the same floored defer the
1394
+ # linked-PR branch takes, and for the same fail-safe reason
1395
+ # (a respawn lands on the SAME branch in the SAME worktree as
1396
+ # the session it doubles, so ambiguity must never respawn).
1397
+ #
1398
+ # Unlike the resume branch above, this guard is NOT behind
1399
+ # `resume_enabled`: that switch gates an ACTION the daemon
1400
+ # gained (spawning onto a dead session's PR), and turning it
1401
+ # off restores a legacy behavior that was merely conservative.
1402
+ # This is the opposite -- a guard against a double spawn --
1403
+ # and there is no version of "off" for it that is safe.
1404
+ #
1405
+ # The session name comes from the ledger row (the identity the
1406
+ # reaper and the console key on), falling back to the
1407
+ # deterministic name for a row that carries none: `unnamed`
1408
+ # rows resolve INDETERMINATE otherwise, and since they respawn
1409
+ # unconditionally TODAY, deferring them forever would be a
1410
+ # regression dressed as caution.
1411
+ live_session = self.state.newest_spawn_session(
1412
+ issue.full_name, number
1413
+ ) or session_name(issue, attempts)
1414
+ liveness = self.session_liveness(live_session, roster)
1415
+ # Falling through means DEAD, and DEAD here is CONFIRMED (a
1416
+ # successful listing without this name) -- so the retry below
1417
+ # can name the session it proved gone instead of presuming.
1418
+ dead_session = live_session
1419
+ if liveness is not Liveness.DEAD:
1420
+ # Floored exactly like the linked-PR deferral, and needing
1421
+ # it for the same reason: this sits ABOVE the cap check,
1422
+ # so while it holds no other escalation can reach the
1423
+ # issue. A wedged-but-listed session would otherwise be
1424
+ # deferred in silence forever.
1425
+ stalled_after = (
1426
+ STALLED_DEFER_MULTIPLE * self.config.stale_minutes * 60
1427
+ )
1428
+ if age >= stalled_after and not self.state.escalated(
1429
+ issue.full_name, number, live_stalled_kind(attempts)
1430
+ ):
1431
+ self._escalate_live_stalled(
1432
+ issue, attempts, age, live_session, liveness
1433
+ )
1434
+ return Decision(
1435
+ Action.IN_FLIGHT,
1436
+ f"attempt {attempts} is stale ({int(age / 60)} min) "
1437
+ f"with no linked open PR, but its session "
1438
+ f"{live_session} reads {liveness.value} — the "
1439
+ f"implement phase publishes nothing until the PR "
1440
+ f"exists; not respawning over a session that may "
1441
+ f"still be running",
1442
+ attempts,
1443
+ session=live_session,
1444
+ )
1286
1445
 
1287
1446
  # Never spawn attempt cap+1. Escalate ONCE; `escalated()` dedupes, and
1288
1447
  # raising attempt_cap re-enters the loop without clearing the ledger.
@@ -1312,11 +1471,13 @@ class DevWatcher:
1312
1471
  elif age is not None:
1313
1472
  log.warning(
1314
1473
  "%s attempt %d has been in flight %.0f min with no completion "
1315
- "and no linked open PR re-enqueuing as attempt %d (developer "
1316
- "session presumed dead)",
1474
+ "and no linked open PR, and its session %s is absent from the "
1475
+ "tmux listing (CONFIRMED dead, not presumed) — re-enqueuing as "
1476
+ "attempt %d",
1317
1477
  issue.issue_slug,
1318
1478
  attempts,
1319
1479
  age / 60,
1480
+ dead_session,
1320
1481
  attempts + 1,
1321
1482
  )
1322
1483
  attempt = attempts + 1
@@ -1488,10 +1649,14 @@ class DevWatcher:
1488
1649
  # still best-effort -- `_append_activity` swallows its failures and a
1489
1650
  # failed line never gates the spawn -- and still fully dry-run gated.
1490
1651
  #
1491
- # A resume names the confirmation that unblocked it (an operator
1492
- # reading the issue must be able to tell a proven-dead resume from a
1493
- # timer-only retry); a plain retry says presumed; attempt 1 is a plain
1494
- # spawn.
1652
+ # Both spawn-over-a-predecessor contexts name the CONFIRMATION that
1653
+ # unblocked them. Neither is a presumption any more: this edge reaches
1654
+ # a retry only past the liveness guard, so the prior session was
1655
+ # absent from a successful listing either way. What still separates
1656
+ # them is what the dead session left behind -- a draft PR to finish,
1657
+ # or nothing at all (it died in the implement phase) -- which is
1658
+ # exactly what tells the operator whether work was lost. Attempt 1 is
1659
+ # a plain spawn.
1495
1660
  if resume_pr is not None:
1496
1661
  context = (
1497
1662
  f"resumed — previous session confirmed dead (absent from the "
@@ -1499,7 +1664,10 @@ class DevWatcher:
1499
1664
  f"PR {resume_pr.url}"
1500
1665
  )
1501
1666
  elif reenqueued:
1502
- context = "re-enqueued — previous session presumed dead"
1667
+ context = (
1668
+ "re-enqueued — previous session confirmed dead (absent from "
1669
+ "the tmux listing) with no PR opened"
1670
+ )
1503
1671
  else:
1504
1672
  context = "spawned"
1505
1673
  self._append_activity(
@@ -1792,6 +1960,68 @@ class DevWatcher:
1792
1960
  issue.full_name, issue.number, stalled_kind(attempts)
1793
1961
  )
1794
1962
 
1963
+ def _escalate_live_stalled(
1964
+ self,
1965
+ issue: Issue,
1966
+ attempts: int,
1967
+ age: float,
1968
+ session: str,
1969
+ liveness: Liveness,
1970
+ ) -> None:
1971
+ """Operator ping for the OTHER long deferral: no PR was ever opened,
1972
+ and what holds the retry back is the session itself still reading
1973
+ ALIVE (or unresolvable) in the tmux listing.
1974
+
1975
+ Same contract as `_escalate_stalled` in every respect that matters --
1976
+ one comment per deferral EPISODE (keyed live_stalled_kind(attempts),
1977
+ so a later attempt wedging pings again), the row recorded only AFTER
1978
+ the comment posts so a transient failure retries next poll, and the
1979
+ decision stays IN_FLIGHT regardless. What differs is the STORY, and
1980
+ with it the remedy the operator is handed: `stalled` says a PR is
1981
+ holding the lane and closing it frees the retry; this says there is
1982
+ no PR at all and the session is the thing to look at -- so the ping
1983
+ names it and the `alissa tmux kill` that would release the lane.
1984
+ `status` spells ALIVE and INDETERMINATE apart (evidence vs the
1985
+ absence of it: kill a wedged session vs repair the listing), with a
1986
+ fallback because a daemon must never crash a poll over wording."""
1987
+ status = LIVE_STALLED_STATUS.get(
1988
+ liveness, "reads " + liveness.value + " in the machine's session listing"
1989
+ )
1990
+ body = LIVE_STALLED_COMMENT.format(
1991
+ attempts=attempts,
1992
+ minutes=int(age / 60),
1993
+ session=session,
1994
+ status=status,
1995
+ )
1996
+ log.warning(
1997
+ "STALLED %s attempt %d has been deferred %.0f min with no linked "
1998
+ "open PR while its session %s reads %s — escalating to operator "
1999
+ "(once per attempt)",
2000
+ issue.issue_slug,
2001
+ attempts,
2002
+ age / 60,
2003
+ session,
2004
+ liveness.value,
2005
+ )
2006
+
2007
+ if self.config.dry_run:
2008
+ log.info("[dry-run] would comment on %s:\n%s", issue.issue_slug, body)
2009
+ return
2010
+
2011
+ try:
2012
+ self.github.comment(issue.owner, issue.repo, issue.number, body)
2013
+ except CommandError as exc:
2014
+ log.error(
2015
+ "could not post the live-stalled comment on %s: %s — not "
2016
+ "recording the episode; the ping retries next poll",
2017
+ issue.issue_slug,
2018
+ exc,
2019
+ )
2020
+ return
2021
+ self.state.record_escalation(
2022
+ issue.full_name, issue.number, live_stalled_kind(attempts)
2023
+ )
2024
+
1795
2025
  def _escalate(self, issue: Issue, attempts: int) -> None:
1796
2026
  body = ESCALATION_COMMENT.format(
1797
2027
  attempts=attempts, cap=self.config.attempt_cap, label=self.config.label
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.6.1
3
+ Version: 0.6.3
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera