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.
- {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
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/__main__.py +11 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/config.py +72 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/ghclient.py +11 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/loop.py +286 -5
- {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
- alissa_tools_github_devloop-0.8.18/src/main/alissa/tools/github/devloop/version +1 -0
- {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
- alissa_tools_github_devloop-0.8.17/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {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
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.18}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
|
@@ -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,
|
|
1052
|
-
#
|
|
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)
|
|
2360
|
-
|
|
2361
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.8.18
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.8.17
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|