alissa-tools-github-devloop 0.6.2__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.
- {alissa_tools_github_devloop-0.6.2/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.6.3}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/alissa.py +33 -11
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/loop.py +238 -8
- alissa_tools_github_devloop-0.6.3/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_devloop-0.6.2/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/README.md +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/setup.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/config.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.6.3}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -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`).
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
1316
|
-
"
|
|
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
|
-
#
|
|
1492
|
-
#
|
|
1493
|
-
#
|
|
1494
|
-
#
|
|
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 =
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.6.3
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.6.2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|