alissa-tools-github-devloop 0.8.17__tar.gz → 0.8.19__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.19}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/__main__.py +11 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/config.py +72 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/ghclient.py +11 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/loop.py +548 -100
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/loop_events.py +6 -2
- alissa_tools_github_devloop-0.8.19/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19/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.19}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.8.17 → alissa_tools_github_devloop-0.8.19}/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.19}/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.19}/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.19}/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
|
|
|
@@ -27,11 +27,15 @@ and the merge, a finished session's PR sits open for the whole review loop.
|
|
|
27
27
|
DEAD alone would therefore fire on the modal happy path. So the resume takes
|
|
28
28
|
a FOURTH signal from the PR the session left behind: the handoff publishes
|
|
29
29
|
itself (`gh pr ready` flips the draft off, then the re-request puts a
|
|
30
|
-
reviewer on it), which makes a PR that is STILL A DRAFT
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
reviewer on it), which makes a PR that is STILL A DRAFT the one state that
|
|
31
|
+
says "this session never handed off" -- whatever reviewer is on it, because
|
|
32
|
+
a review request on a draft publishes nothing (the reviewer daemon only
|
|
33
|
+
reviews non-draft PRs and the fix edge refuses drafts, so nothing downstream
|
|
34
|
+
can ever move a draft; studio.alissa.app#1204 sat 80 minutes behind exactly
|
|
35
|
+
that shape). That test also keeps the edges disjoint -- the moment a PR
|
|
36
|
+
leaves draft it belongs to the review lifecycle and the fix edge owns it
|
|
37
|
+
exclusively, so no `develop-*` resume can land on a branch a `fix-*` session
|
|
38
|
+
is already working.
|
|
35
39
|
|
|
36
40
|
The signals so far all describe the PR's STATE, and a sibling task's young
|
|
37
41
|
draft satisfies every one of them; a cross-reference is raised by ANY mention
|
|
@@ -72,6 +76,30 @@ behind `resume_enabled`: that switch gates an ACTION the daemon gained, and
|
|
|
72
76
|
turning it off restores a conservative legacy; this is a guard against a
|
|
73
77
|
DOUBLE SPAWN, and no version of "off" for it is safe.
|
|
74
78
|
|
|
79
|
+
ALIVE has a blind spot of its own: liveness measures session EXISTENCE, not
|
|
80
|
+
work. A worker wedged at an interactive prompt is a live process and a dead
|
|
81
|
+
worker, and it reads ALIVE forever. Behind a handed-off PR that is harmless
|
|
82
|
+
(the review lifecycle moves the PR, and the reaper collects the session once
|
|
83
|
+
the PR is terminal), but behind a STILL-DRAFT PR nothing else will ever move
|
|
84
|
+
it: pre-handoff the dev session owns the pace, the "review loop can outlast
|
|
85
|
+
stale_minutes" rationale does not apply, and the deferral above is infinite
|
|
86
|
+
(studio.alissa.app#1121 sat 31+ hours behind draft PR #1123, paged once,
|
|
87
|
+
then silence). So the issue edge has an OPT-IN fifth signal on exactly that
|
|
88
|
+
branch -- ALIVE, and `_resumable_pr` admits the PR -- an ACTIVITY FOOTPRINT:
|
|
89
|
+
the newest of the ledger row's spawn, the PR's last update, and the head
|
|
90
|
+
commit's date. A footprint older than `wedge_minutes` (0 = off, the default)
|
|
91
|
+
is a session that exists and does nothing, and the daemon KILLS it -- the
|
|
92
|
+
ledger row's own session name only, the same `alissa tmux kill` a healthy
|
|
93
|
+
handoff ends with -- so the next pass reads it DEAD and the ordinary resume
|
|
94
|
+
above takes over as attempt k+1, counting toward attempt_cap like any
|
|
95
|
+
resume. Nothing about the kill is new machinery on the resume side: it
|
|
96
|
+
manufactures the one signal the matrix already acts on. The invariants are
|
|
97
|
+
the matrix's own -- never on INDETERMINATE (absence of evidence keeps the
|
|
98
|
+
defer), never on a PR that left draft (the wedge sits strictly behind
|
|
99
|
+
`_resumable_pr`), never with `resume_enabled` false (a
|
|
100
|
+
kill whose resume cannot fire would only orphan the lane), and never a
|
|
101
|
+
roster sweep.
|
|
102
|
+
|
|
75
103
|
The same pass then runs the REVIEW-RESPONSE edge (fix_rounds_enabled): open
|
|
76
104
|
PRs authored by the token identity whose newest substantive review verdict is
|
|
77
105
|
request_changes, with no pending re-request and no push since that review --
|
|
@@ -149,7 +177,9 @@ self-heal needs an unanswered review, and the issue-edge resume keys off an
|
|
|
149
177
|
OPEN labeled issue, which a draft replayed after its issue merged does not
|
|
150
178
|
have (fahera-mx/studio.alissa.app#279, 2026-07-28: an operator found it by
|
|
151
179
|
accident). So the PR search is widened to drafts and every draft is asked the
|
|
152
|
-
handoff question instead of being skipped: self-authored
|
|
180
|
+
handoff question instead of being skipped: self-authored (a reviewer already
|
|
181
|
+
requested on it is NOT a reason to skip -- a request on a draft arms nothing,
|
|
182
|
+
so the sweep readies it and requests only the reviewers still missing),
|
|
153
183
|
a daemon-shaped head ref naming an implementation task (a `TASK-<n>-<DESC>`
|
|
154
184
|
branch or a task ref in the body -- the provenance test, `closes_issue`'s
|
|
155
185
|
role here), no live worker session for the PR (the SAME liveness rule --
|
|
@@ -717,8 +747,9 @@ RESUME_DIRECTIVE = (
|
|
|
717
747
|
"previous developer session on GitHub issue {issue_url} died AFTER "
|
|
718
748
|
"opening its pull request but BEFORE handing it off for review — the "
|
|
719
749
|
"daemon confirmed its session `{dead_session}` is gone from the machine, "
|
|
720
|
-
"and its DRAFT pull request {pr_url} is still open
|
|
721
|
-
"
|
|
750
|
+
"and its DRAFT pull request {pr_url} is still open (a reviewer may "
|
|
751
|
+
"already be requested on it — that is not a handoff; the ready flip is). "
|
|
752
|
+
"This is attempt {attempt} of {cap}, and your job is to FINISH THAT "
|
|
722
753
|
"PULL REQUEST, not to start the work over. "
|
|
723
754
|
"{task_instruction} "
|
|
724
755
|
"FIRST hydrate the context you did not live through: read the issue (its "
|
|
@@ -1046,14 +1077,24 @@ ACTIVITY_REREQUESTED = "re-requested"
|
|
|
1046
1077
|
# including the one that wrote it.
|
|
1047
1078
|
ACTIVITY_HANDED_OFF = "handed off"
|
|
1048
1079
|
|
|
1080
|
+
# The activity-log context prefix for a wedge kill (see ESCALATION_WEDGED).
|
|
1081
|
+
# Non-spawn like the three above, and more so: the line records that the
|
|
1082
|
+
# lane's session was KILLED, the exact opposite of a running session. A reader
|
|
1083
|
+
# that took it for a spawn claim would park the lane behind a phantom for up
|
|
1084
|
+
# to a stale_minutes window -- against a peer instance during an overlapping
|
|
1085
|
+
# deploy, or this daemon after ledger loss, the cases external_claim exists
|
|
1086
|
+
# for -- delaying the very resume the kill was issued to unblock.
|
|
1087
|
+
ACTIVITY_WEDGE_KILLED = "wedge-killed"
|
|
1088
|
+
|
|
1049
1089
|
# Line contexts that record something OTHER than a spawn. `activity_claims`
|
|
1050
1090
|
# 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
|
-
#
|
|
1091
|
+
# neither a capacity deferral, a mechanical re-request, a mechanical handoff,
|
|
1092
|
+
# nor a wedge kill makes one.
|
|
1053
1093
|
ACTIVITY_NON_SPAWN = (
|
|
1054
1094
|
ACTIVITY_DEFERRED,
|
|
1055
1095
|
ACTIVITY_REREQUESTED,
|
|
1056
1096
|
ACTIVITY_HANDED_OFF,
|
|
1097
|
+
ACTIVITY_WEDGE_KILLED,
|
|
1057
1098
|
)
|
|
1058
1099
|
|
|
1059
1100
|
# The reader's grammar for a line `_activity_line` wrote: bullet, UTC
|
|
@@ -1110,6 +1151,16 @@ ESCALATION_FIX_STALLED = "fix-stalled"
|
|
|
1110
1151
|
# neither ping may silence the other.
|
|
1111
1152
|
ESCALATION_LIVE_STALLED = "live-stalled"
|
|
1112
1153
|
|
|
1154
|
+
# The wedge kill's once-per-episode marker (see wedge_kind). Not an operator
|
|
1155
|
+
# page -- the daemon acted instead of asking -- but, like ESCALATION_REREQUEST,
|
|
1156
|
+
# a once-per-key act the `escalations` table is the ledger of: it dedupes the
|
|
1157
|
+
# activity-comment line (a kill that has to be repeated must not grow the
|
|
1158
|
+
# comment by one line per poll) and it is what the loop-events emitter turns
|
|
1159
|
+
# into an `escalation.wedged` event. The KILL itself is never deduped on it:
|
|
1160
|
+
# a session that survives its kill is still wedged, and re-killing it every
|
|
1161
|
+
# poll is the remedy, not a bug.
|
|
1162
|
+
ESCALATION_WEDGED = "wedged"
|
|
1163
|
+
|
|
1113
1164
|
# The maintenance edge's cap-out kind prefix. The `maintain-` prefix keeps
|
|
1114
1165
|
# rows from ever colliding with the issue edge's `cap` when an issue and a
|
|
1115
1166
|
# PR share a number; the epoch suffix is maintain_cap_kind's job below.
|
|
@@ -1425,6 +1476,17 @@ def live_stalled_kind(attempt: int) -> str:
|
|
|
1425
1476
|
return f"{ESCALATION_LIVE_STALLED}:a{attempt}"
|
|
1426
1477
|
|
|
1427
1478
|
|
|
1479
|
+
def wedge_kind(attempt: int) -> str:
|
|
1480
|
+
"""The ledger kind for ONE wedge-kill episode's activity line.
|
|
1481
|
+
|
|
1482
|
+
stalled_kind's episode reasoning again: every attempt can wedge behind
|
|
1483
|
+
its own draft, and attempt k's row must not silence attempt k+1's line.
|
|
1484
|
+
Only the activity line is keyed on it -- the kill is repeated whenever
|
|
1485
|
+
the footprint still warrants it (see ESCALATION_WEDGED).
|
|
1486
|
+
"""
|
|
1487
|
+
return f"{ESCALATION_WEDGED}:a{attempt}"
|
|
1488
|
+
|
|
1489
|
+
|
|
1428
1490
|
def deferral_activity_kind(session: str) -> str:
|
|
1429
1491
|
"""The activity-log kind that dedupes ONE capacity-deferral episode's line.
|
|
1430
1492
|
|
|
@@ -2356,9 +2418,10 @@ def activity_claims(body: str, now: "float | None" = None) -> "list[ActivityClai
|
|
|
2356
2418
|
Spawn records only: a capacity-deferral line explicitly claims nothing (no
|
|
2357
2419
|
self-assign, no ledger row, no session -- see _spawn's gate), and neither
|
|
2358
2420
|
does a mechanical re-request (it completes a DEAD session's protocol; no
|
|
2359
|
-
session is running afterwards)
|
|
2360
|
-
|
|
2361
|
-
|
|
2421
|
+
session is running afterwards) nor a wedge kill (it records that the
|
|
2422
|
+
lane's session was just KILLED), so reading any of them as a claim would
|
|
2423
|
+
let an instance park the lane against everybody including itself. The
|
|
2424
|
+
context field is what tells them apart -- see ACTIVITY_NON_SPAWN.
|
|
2362
2425
|
|
|
2363
2426
|
Everything else fails open, one line at a time: a line that does not match
|
|
2364
2427
|
the grammar, or whose stamp is not an instant, or whose stamp is
|
|
@@ -2573,11 +2636,16 @@ class DevWatcher:
|
|
|
2573
2636
|
# the session behind the open PR is not running -- but NOT that it was
|
|
2574
2637
|
# abandoned, because a healthy handoff ends in `alissa tmux kill`. So
|
|
2575
2638
|
# DEAD only opens the question, and `_resumable_pr` answers it: a
|
|
2576
|
-
# still-draft
|
|
2639
|
+
# still-draft PR that CLOSES this issue (a reviewer on the draft
|
|
2640
|
+
# changes nothing -- a draft is never handed off) is one of this
|
|
2577
2641
|
# issue's own sessions that was never handed off, and it
|
|
2578
2642
|
# is the one case the deferral was always wrong about -- resumed as
|
|
2579
2643
|
# attempt k+1 with RESUME_DIRECTIVE instead of parking the lane behind
|
|
2580
|
-
# an operator ping.
|
|
2644
|
+
# an operator ping. (When the orphan sweep is on it runs EARLIER in
|
|
2645
|
+
# the same pass and readies such a draft mechanically, so the fetch
|
|
2646
|
+
# here sees a non-draft PR and defers -- the sweep's handoff wins
|
|
2647
|
+
# over a resume without any coordination between the edges.)
|
|
2648
|
+
# Everything else -- ALIVE, INDETERMINATE, and DEAD
|
|
2581
2649
|
# behind a PR already in the review lifecycle -- keeps the floored
|
|
2582
2650
|
# defer: past STALLED_DEFER_MULTIPLE stale windows it posts a
|
|
2583
2651
|
# "stalled" operator comment (this deferral sits above the cap check,
|
|
@@ -2607,6 +2675,32 @@ class DevWatcher:
|
|
|
2607
2675
|
liveness = self.session_liveness(row_session, roster)
|
|
2608
2676
|
if self.config.resume_enabled and liveness is Liveness.DEAD:
|
|
2609
2677
|
resume_pr = self._resumable_pr(issue, linked)
|
|
2678
|
+
elif (
|
|
2679
|
+
self.config.resume_enabled
|
|
2680
|
+
and self.config.wedge_seconds
|
|
2681
|
+
and liveness is Liveness.ALIVE
|
|
2682
|
+
and row_session
|
|
2683
|
+
):
|
|
2684
|
+
# Wedge detection (issue #110), the opt-in escape from
|
|
2685
|
+
# the infinite ALIVE defer. Reached ONLY on ALIVE -- a
|
|
2686
|
+
# SUCCESSFUL listing that carries the row's own name --
|
|
2687
|
+
# never on INDETERMINATE, whose absence of evidence keeps
|
|
2688
|
+
# the defer exactly as the Liveness docstring argues.
|
|
2689
|
+
# `_wedge_candidate` sits strictly behind `_resumable_pr`
|
|
2690
|
+
# (a handed-off PR belongs to the review lifecycle) and
|
|
2691
|
+
# then asks the one question liveness cannot: has this
|
|
2692
|
+
# lane shown ANY activity inside the wedge window? If
|
|
2693
|
+
# not, the session is killed here and nothing else
|
|
2694
|
+
# happens this pass: the next pass reads it DEAD and the
|
|
2695
|
+
# ordinary resume branch above does the rest. A fresh
|
|
2696
|
+
# footprint, or a footprint that cannot be read, falls
|
|
2697
|
+
# through to the floored defer below, unchanged.
|
|
2698
|
+
wedged = self._wedge_candidate(issue, linked, age)
|
|
2699
|
+
if wedged is not None:
|
|
2700
|
+
wedged_pr, footprint = wedged
|
|
2701
|
+
return self._kill_wedged(
|
|
2702
|
+
issue, attempts, row_session, wedged_pr, footprint
|
|
2703
|
+
)
|
|
2610
2704
|
if resume_pr is not None:
|
|
2611
2705
|
# Fall through to the cap check and the spawn below: a
|
|
2612
2706
|
# resume is an ORDINARY attempt (it counts toward
|
|
@@ -2626,9 +2720,9 @@ class DevWatcher:
|
|
|
2626
2720
|
# an operator reading the log must not have to guess.
|
|
2627
2721
|
why = (
|
|
2628
2722
|
"no linked PR is resumable (a handed-off PR — out of "
|
|
2629
|
-
"draft
|
|
2630
|
-
"
|
|
2631
|
-
"
|
|
2723
|
+
"draft — belongs to the review lifecycle, not to a "
|
|
2724
|
+
"resume; a PR that only MENTIONS this issue is "
|
|
2725
|
+
"another task's work)"
|
|
2632
2726
|
if liveness is Liveness.DEAD
|
|
2633
2727
|
and self.config.resume_enabled
|
|
2634
2728
|
else "work may be alive (the review loop can outlast "
|
|
@@ -2998,12 +3092,23 @@ class DevWatcher:
|
|
|
2998
3092
|
|
|
2999
3093
|
What separates the two is the PR the session left behind, because the
|
|
3000
3094
|
handoff PUBLISHES itself: `gh pr ready` flips the draft off, then the
|
|
3001
|
-
re-request puts a reviewer on it. So a PR that is STILL A DRAFT
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3095
|
+
re-request puts a reviewer on it. So a PR that is STILL A DRAFT is one
|
|
3096
|
+
its session never handed off -- the only state a resume is owed --
|
|
3097
|
+
and a reviewer requested on that draft does not change the reading: a
|
|
3098
|
+
request on a draft arms nothing (the reviewer daemon reviews only
|
|
3099
|
+
non-draft PRs, the fix edge refuses drafts), so no automation
|
|
3100
|
+
downstream can ever move it. The two half-states a session can die in
|
|
3101
|
+
are therefore read differently: (a) READY + NO reviewer -- the ready
|
|
3102
|
+
flip landed, the request did not -- reads handed-off here and keeps
|
|
3103
|
+
the legacy floored defer, conservative on purpose since that is also
|
|
3104
|
+
exactly what a live review loop looks like from this edge; where this
|
|
3105
|
+
daemon's own sweep made that flip, the `orphan-ready@<head>` row lets
|
|
3106
|
+
the PR edge's `_finish_orphan_handoff` complete it. (b) DRAFT +
|
|
3107
|
+
reviewer -- the request landed, the flip did not (studio#1204: 80
|
|
3108
|
+
minutes behind a draft with `alissa-app` on it) -- is resumable when
|
|
3109
|
+
the session is DEAD, exactly like a review-less draft, and deferred
|
|
3110
|
+
while ALIVE; with the orphan sweep on, that sweep readies it earlier
|
|
3111
|
+
in the same pass, so the resume yields to the mechanical handoff.
|
|
3007
3112
|
|
|
3008
3113
|
That same test settles the cross-edge race: the moment a PR leaves
|
|
3009
3114
|
draft it belongs to the review lifecycle, and the fix edge -- which
|
|
@@ -3080,16 +3185,24 @@ class DevWatcher:
|
|
|
3080
3185
|
# still a PR whose base can strand it, and the guard's own author
|
|
3081
3186
|
# test is what keeps it off PRs that are not ours.
|
|
3082
3187
|
self._check_stacked_base(pr)
|
|
3083
|
-
if not pr.draft
|
|
3188
|
+
if not pr.draft:
|
|
3084
3189
|
log.info(
|
|
3085
|
-
"%s: %s has been handed off (draft
|
|
3086
|
-
"not a resume candidate; the review lifecycle owns it",
|
|
3190
|
+
"%s: %s has been handed off (out of draft, reviewers=%s) "
|
|
3191
|
+
"— not a resume candidate; the review lifecycle owns it",
|
|
3087
3192
|
issue.issue_slug,
|
|
3088
3193
|
url,
|
|
3089
|
-
pr.draft,
|
|
3090
3194
|
", ".join(pr.requested_reviewers) or "none",
|
|
3091
3195
|
)
|
|
3092
3196
|
continue
|
|
3197
|
+
if pr.requested_reviewers:
|
|
3198
|
+
log.info(
|
|
3199
|
+
"%s: %s is still a DRAFT with %s requested — a review "
|
|
3200
|
+
"request on a draft is not a handoff (the ready flip is), "
|
|
3201
|
+
"so this PR is a resume candidate",
|
|
3202
|
+
issue.issue_slug,
|
|
3203
|
+
url,
|
|
3204
|
+
", ".join(pr.requested_reviewers),
|
|
3205
|
+
)
|
|
3093
3206
|
if pr.author != self.github.login:
|
|
3094
3207
|
log.warning(
|
|
3095
3208
|
"%s: %s is authored by %r, not the developer identity — "
|
|
@@ -3167,6 +3280,206 @@ class DevWatcher:
|
|
|
3167
3280
|
)
|
|
3168
3281
|
return False
|
|
3169
3282
|
|
|
3283
|
+
def _wedge_candidate(
|
|
3284
|
+
self, issue: Issue, linked: "list[str]", spawn_age: float
|
|
3285
|
+
) -> "tuple[PullRequest, float] | None":
|
|
3286
|
+
"""The wedge decision's evidence: the one resumable PR behind this
|
|
3287
|
+
ALIVE session and the age of the lane's activity footprint, or None
|
|
3288
|
+
when the lane must keep its defer.
|
|
3289
|
+
|
|
3290
|
+
Two gates, in order. First `_resumable_pr`, verbatim -- the wedge
|
|
3291
|
+
path inherits every admission test the resume has (still a draft,
|
|
3292
|
+
self-authored, daemon-shaped head, a body that CLOSES this issue,
|
|
3293
|
+
exactly one candidate; a reviewer requested on the draft is not a
|
|
3294
|
+
handoff and does not exclude it), because the kill it licenses is
|
|
3295
|
+
only safe where the resume that follows it is: a PR that has left
|
|
3296
|
+
draft belongs to the review lifecycle, and a live review loop behind
|
|
3297
|
+
an ALIVE session is the healthy shape this daemon was built around.
|
|
3298
|
+
Then the footprint (`_footprint_age`):
|
|
3299
|
+
None means unreadable, and unreadable keeps the defer -- the same
|
|
3300
|
+
fail-safe every other presumed-dead decision here makes.
|
|
3301
|
+
|
|
3302
|
+
Cost: nothing beyond what the DEAD branch already pays for the same
|
|
3303
|
+
lane -- `_resumable_pr`'s one GET per linked PR -- except the head
|
|
3304
|
+
commit fetch, which `_footprint_age` only makes once the cheap legs
|
|
3305
|
+
have already run past the window."""
|
|
3306
|
+
pr = self._resumable_pr(issue, linked)
|
|
3307
|
+
if pr is None:
|
|
3308
|
+
return None
|
|
3309
|
+
footprint = self._footprint_age(pr, spawn_age)
|
|
3310
|
+
if footprint is None:
|
|
3311
|
+
return None
|
|
3312
|
+
window = self.config.wedge_seconds
|
|
3313
|
+
if footprint < window:
|
|
3314
|
+
log.debug(
|
|
3315
|
+
"%s: session behind draft PR %s reads alive with a footprint "
|
|
3316
|
+
"%.0f min old (wedge window %d min) — not wedged",
|
|
3317
|
+
issue.issue_slug,
|
|
3318
|
+
pr.url,
|
|
3319
|
+
footprint / 60,
|
|
3320
|
+
window // 60,
|
|
3321
|
+
)
|
|
3322
|
+
return None
|
|
3323
|
+
return pr, footprint
|
|
3324
|
+
|
|
3325
|
+
def _footprint_age(
|
|
3326
|
+
self, pr: PullRequest, spawn_age: float
|
|
3327
|
+
) -> "float | None":
|
|
3328
|
+
"""How long ago this lane last showed ANY activity, in seconds: the
|
|
3329
|
+
YOUNGEST of the ledger row's spawn, the PR's last update, and the
|
|
3330
|
+
head commit's committer date. None when that cannot be established.
|
|
3331
|
+
|
|
3332
|
+
The three legs are the three places a working session leaves a
|
|
3333
|
+
mark. The spawn is the floor (a session younger than the window
|
|
3334
|
+
cannot be wedged yet -- it is still starting). `updated_at` is
|
|
3335
|
+
GitHub's own "anything happened here" stamp: it moves on every push
|
|
3336
|
+
to the head (synchronize), every edit, comment, label and review,
|
|
3337
|
+
and it rides the `pull_request()` fetch the resume probe already
|
|
3338
|
+
paid, so on the common path the footprint costs no call at all. The
|
|
3339
|
+
head commit's date is the third leg, read only once the first two
|
|
3340
|
+
have ALREADY run past the window: it is redundant with `updated_at`
|
|
3341
|
+
on every push GitHub saw, so paying for it while the cheap legs
|
|
3342
|
+
still prove activity would be a per-poll GET for a question already
|
|
3343
|
+
answered. Where it IS read it is the last word before a kill, and
|
|
3344
|
+
an unreadable commit (CommandError, or a payload with no date)
|
|
3345
|
+
answers None rather than "old" -- the daemon does not kill a
|
|
3346
|
+
session over evidence it could not read. RateLimited is NOT caught:
|
|
3347
|
+
like every other GitHub call in the pass it belongs to run_forever's
|
|
3348
|
+
backoff."""
|
|
3349
|
+
now = time.time()
|
|
3350
|
+
youngest = spawn_age
|
|
3351
|
+
if pr.updated_at is not None:
|
|
3352
|
+
youngest = min(youngest, max(0.0, now - pr.updated_at))
|
|
3353
|
+
if youngest < self.config.wedge_seconds:
|
|
3354
|
+
return youngest
|
|
3355
|
+
try:
|
|
3356
|
+
committed = self.github.commit_time(pr.owner, pr.repo, pr.head_sha)
|
|
3357
|
+
except CommandError as exc:
|
|
3358
|
+
log.warning(
|
|
3359
|
+
"wedge: could not read the head commit date of %s (%s) — "
|
|
3360
|
+
"the footprint cannot be established, so the session keeps "
|
|
3361
|
+
"its defer this poll",
|
|
3362
|
+
pr.pr_slug,
|
|
3363
|
+
exc,
|
|
3364
|
+
)
|
|
3365
|
+
return None
|
|
3366
|
+
if committed is None:
|
|
3367
|
+
log.warning(
|
|
3368
|
+
"wedge: head commit %s of %s carries no readable date — the "
|
|
3369
|
+
"footprint cannot be established, so the session keeps its "
|
|
3370
|
+
"defer this poll",
|
|
3371
|
+
pr.head_sha[:8],
|
|
3372
|
+
pr.pr_slug,
|
|
3373
|
+
)
|
|
3374
|
+
return None
|
|
3375
|
+
return min(youngest, max(0.0, now - committed))
|
|
3376
|
+
|
|
3377
|
+
def _kill_wedged(
|
|
3378
|
+
self,
|
|
3379
|
+
issue: Issue,
|
|
3380
|
+
attempts: int,
|
|
3381
|
+
session: str,
|
|
3382
|
+
pr: PullRequest,
|
|
3383
|
+
footprint: float,
|
|
3384
|
+
) -> Decision:
|
|
3385
|
+
"""Kill the ledger row's own session so the next pass can resume
|
|
3386
|
+
the lane, and say so everywhere an operator would look.
|
|
3387
|
+
|
|
3388
|
+
The kill is the daemon's own session-kill machinery -- one `alissa
|
|
3389
|
+
tmux kill <name>` on exactly the row's recorded name, the same act a
|
|
3390
|
+
healthy handoff ends with, never a roster sweep. It is the ONLY side
|
|
3391
|
+
effect this pass has on the lane: no assignment changes, no ledger
|
|
3392
|
+
row is written for an attempt, nothing is enqueued. The decision
|
|
3393
|
+
stays IN_FLIGHT -- the lane is still deferred behind its draft --
|
|
3394
|
+
and what changes is the NEXT evaluation: a successful listing
|
|
3395
|
+
without this name reads DEAD, `_resumable_pr` admits the same PR
|
|
3396
|
+
it admitted here, and the ordinary resume spawns attempt k+1 (or,
|
|
3397
|
+
at the cap, pages the operator exactly as any resume would). A
|
|
3398
|
+
kill that fails is logged and the defer holds; the next poll makes
|
|
3399
|
+
the same decision and tries again, which is the remedy for a
|
|
3400
|
+
session that will not die, not a loop to guard against.
|
|
3401
|
+
|
|
3402
|
+
The activity comment gets ONE line per episode (keyed
|
|
3403
|
+
wedge_kind(attempts)), recorded only after the line lands so a
|
|
3404
|
+
transient failure retries -- the comment is the cross-actor record
|
|
3405
|
+
of why this session vanished, and the recorded row is what the
|
|
3406
|
+
loop-events emitter turns into `escalation.wedged`. Every side
|
|
3407
|
+
effect honours dry_run."""
|
|
3408
|
+
minutes = int(footprint / 60)
|
|
3409
|
+
window = self.config.wedge_seconds // 60
|
|
3410
|
+
log.warning(
|
|
3411
|
+
"wedged: alive session %s with no footprint for %d min behind "
|
|
3412
|
+
"draft PR #%d (%s) — killed for resume (%s attempt %d; wedge "
|
|
3413
|
+
"window %d min; the next pass reads it dead and resumes as "
|
|
3414
|
+
"attempt %d, counting toward attempt_cap %d)",
|
|
3415
|
+
session,
|
|
3416
|
+
minutes,
|
|
3417
|
+
pr.number,
|
|
3418
|
+
pr.url,
|
|
3419
|
+
issue.issue_slug,
|
|
3420
|
+
attempts,
|
|
3421
|
+
window,
|
|
3422
|
+
attempts + 1,
|
|
3423
|
+
self.config.attempt_cap,
|
|
3424
|
+
)
|
|
3425
|
+
reason = (
|
|
3426
|
+
f"attempt {attempts} is wedged: its session {session} reads alive "
|
|
3427
|
+
f"but the lane's activity footprint is {minutes} min old (wedge "
|
|
3428
|
+
f"window {window} min) behind still-draft PR {pr.url} — "
|
|
3429
|
+
)
|
|
3430
|
+
if self.config.dry_run:
|
|
3431
|
+
log.info("[dry-run] would kill wedged session %s", session)
|
|
3432
|
+
return Decision(
|
|
3433
|
+
Action.IN_FLIGHT,
|
|
3434
|
+
reason + "[dry-run] would kill it for resume",
|
|
3435
|
+
attempts,
|
|
3436
|
+
session=session,
|
|
3437
|
+
defer_token=loop_events_mod.DEFER_IN_FLIGHT,
|
|
3438
|
+
)
|
|
3439
|
+
try:
|
|
3440
|
+
self.alissa.kill_session(session)
|
|
3441
|
+
except CommandError as exc:
|
|
3442
|
+
log.warning(
|
|
3443
|
+
"wedge: could not kill %s (%s) — kills are best-effort; the "
|
|
3444
|
+
"lane keeps its defer and the kill is retried next poll",
|
|
3445
|
+
session,
|
|
3446
|
+
exc,
|
|
3447
|
+
)
|
|
3448
|
+
return Decision(
|
|
3449
|
+
Action.IN_FLIGHT,
|
|
3450
|
+
reason + f"the kill failed ({exc}); deferring",
|
|
3451
|
+
attempts,
|
|
3452
|
+
session=session,
|
|
3453
|
+
defer_token=loop_events_mod.DEFER_IN_FLIGHT,
|
|
3454
|
+
)
|
|
3455
|
+
kind = wedge_kind(attempts)
|
|
3456
|
+
if not self.state.escalated(issue.full_name, issue.number, kind):
|
|
3457
|
+
landed = self._append_activity(
|
|
3458
|
+
issue.owner,
|
|
3459
|
+
issue.repo,
|
|
3460
|
+
issue.number,
|
|
3461
|
+
self._activity_line(
|
|
3462
|
+
session,
|
|
3463
|
+
"issue edge",
|
|
3464
|
+
f"{ACTIVITY_WEDGE_KILLED} — the session read alive but the lane's "
|
|
3465
|
+
f"activity footprint (spawn / last head commit / last PR "
|
|
3466
|
+
f"update) was {minutes} min old behind still-draft PR "
|
|
3467
|
+
f"{pr.url}; killed so the next pass resumes it as attempt "
|
|
3468
|
+
f"{attempts + 1}",
|
|
3469
|
+
),
|
|
3470
|
+
issue.issue_slug,
|
|
3471
|
+
)
|
|
3472
|
+
if landed:
|
|
3473
|
+
self.state.record_escalation(issue.full_name, issue.number, kind)
|
|
3474
|
+
return Decision(
|
|
3475
|
+
Action.IN_FLIGHT,
|
|
3476
|
+
reason + f"killed for resume; the next pass reads it dead and "
|
|
3477
|
+
f"resumes as attempt {attempts + 1}",
|
|
3478
|
+
attempts,
|
|
3479
|
+
session=session,
|
|
3480
|
+
defer_token=loop_events_mod.DEFER_IN_FLIGHT,
|
|
3481
|
+
)
|
|
3482
|
+
|
|
3170
3483
|
def _escalate_rejection(self, issue: Issue, exc: AssignmentRejected) -> Decision:
|
|
3171
3484
|
"""Escalate-don't-retry: a silently dropped assignment is a permanent,
|
|
3172
3485
|
operator-fixable condition (push access), and without the in-flight
|
|
@@ -4178,17 +4491,18 @@ class DevWatcher:
|
|
|
4178
4491
|
f"identity — not this daemon's to hand off",
|
|
4179
4492
|
)
|
|
4180
4493
|
|
|
4181
|
-
# A draft already carrying a review request is
|
|
4182
|
-
#
|
|
4183
|
-
#
|
|
4184
|
-
#
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4494
|
+
# A draft already carrying a review request is NOT excluded (issue
|
|
4495
|
+
# #113). The sweep used to read a reviewer on a draft as someone's
|
|
4496
|
+
# deliberate work in progress, but a request on a draft arms nothing
|
|
4497
|
+
# -- the reviewer daemon reviews only non-draft PRs and the fix edge
|
|
4498
|
+
# refuses drafts -- and the issue edge read the same shape as "handed
|
|
4499
|
+
# off", so a session that died between its `--add-reviewer` and its
|
|
4500
|
+
# `gh pr ready` (studio.alissa.app#1204: 80 minutes of silence after
|
|
4501
|
+
# a 10-hour attempt) left a draft NO edge would ever move. The
|
|
4502
|
+
# liveness gate below is what protects a deliberately-parked draft:
|
|
4503
|
+
# a live session behind it is never swept, and for a DEAD one the
|
|
4504
|
+
# sweep does the FIRST half only -- the ready flip -- and requests
|
|
4505
|
+
# just the configured reviewers that are not already on the PR.
|
|
4192
4506
|
|
|
4193
4507
|
# head_ref reaches nothing but log lines here (the sweep spawns no
|
|
4194
4508
|
# session, so it never becomes a worktree path or a shell token), but
|
|
@@ -4523,36 +4837,64 @@ class DevWatcher:
|
|
|
4523
4837
|
not retried on this head (escalate-don't-retry, `_escalate_rejection`
|
|
4524
4838
|
verbatim).
|
|
4525
4839
|
|
|
4840
|
+
A draft that ALREADY carries a review request (issue #113) takes the
|
|
4841
|
+
same walk minus the request: the session died between its
|
|
4842
|
+
`--add-reviewer` and its `gh pr ready`, so the flip is the only act
|
|
4843
|
+
still owed. `request_reviews` is skipped entirely when every
|
|
4844
|
+
configured reviewer is already on the PR -- re-requesting an
|
|
4845
|
+
already-requested reviewer is a no-op on GitHub, but it must not be
|
|
4846
|
+
recorded as a fresh request -- and requests only the missing ones
|
|
4847
|
+
otherwise. Both ledger rows still land, so the once-per-(PR, head)
|
|
4848
|
+
guarantee and `_owes_orphan_handoff` keep holding on this shape.
|
|
4849
|
+
|
|
4526
4850
|
The activity line comes LAST and stays best-effort: it is telemetry
|
|
4527
4851
|
about acts that have already happened, and it is written as a NON-SPAWN
|
|
4528
4852
|
context because no session exists for it to claim a lane on behalf of.
|
|
4529
4853
|
"""
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4854
|
+
already, missing = self._orphan_reviewers_split(pr)
|
|
4855
|
+
if already:
|
|
4856
|
+
log.warning(
|
|
4857
|
+
"ORPHAN DRAFT %s (%s): head %s has been public %.0f min, %s "
|
|
4858
|
+
"already requested on it, and no worker session for this PR "
|
|
4859
|
+
"is listed — the session that opened it died after its "
|
|
4860
|
+
"review request but before flipping it ready (a request on a "
|
|
4861
|
+
"draft arms nothing); marking it ready mechanically%s (once "
|
|
4862
|
+
"per PR/head)",
|
|
4863
|
+
pr.pr_slug,
|
|
4864
|
+
task_ref,
|
|
4865
|
+
pr.head_sha[:8],
|
|
4866
|
+
waited / 60,
|
|
4867
|
+
", ".join(already),
|
|
4868
|
+
f" and requesting {', '.join(missing)}" if missing else "",
|
|
4869
|
+
)
|
|
4870
|
+
else:
|
|
4871
|
+
log.warning(
|
|
4872
|
+
"ORPHAN DRAFT %s (%s): head %s has been public %.0f min, no "
|
|
4873
|
+
"reviewer is requested, and no worker session for this PR is "
|
|
4874
|
+
"listed — the session that opened it died before handing it "
|
|
4875
|
+
"off; marking it ready and requesting %s mechanically (once "
|
|
4876
|
+
"per PR/head)",
|
|
4877
|
+
pr.pr_slug,
|
|
4878
|
+
task_ref,
|
|
4879
|
+
pr.head_sha[:8],
|
|
4880
|
+
waited / 60,
|
|
4881
|
+
", ".join(missing),
|
|
4882
|
+
)
|
|
4543
4883
|
|
|
4544
4884
|
if self.config.dry_run:
|
|
4885
|
+
request = (
|
|
4886
|
+
f"request {', '.join(missing)}"
|
|
4887
|
+
if missing
|
|
4888
|
+
else f"request nobody ({', '.join(already)} already requested)"
|
|
4889
|
+
)
|
|
4545
4890
|
log.info(
|
|
4546
|
-
"[dry-run] would mark %s ready and
|
|
4547
|
-
pr.pr_slug,
|
|
4548
|
-
", ".join(reviewers),
|
|
4891
|
+
"[dry-run] would mark %s ready and %s", pr.pr_slug, request
|
|
4549
4892
|
)
|
|
4550
4893
|
return Decision(
|
|
4551
4894
|
Action.IN_FLIGHT,
|
|
4552
4895
|
f"[dry-run] would mechanically hand off draft head "
|
|
4553
4896
|
f"{pr.head_sha[:8]} (public {int(waited / 60)} min, its "
|
|
4554
|
-
f"session gone): mark ready + request
|
|
4555
|
-
f"{', '.join(reviewers)}",
|
|
4897
|
+
f"session gone): mark ready + {request}",
|
|
4556
4898
|
task_ref=task_ref,
|
|
4557
4899
|
)
|
|
4558
4900
|
|
|
@@ -4582,6 +4924,28 @@ class DevWatcher:
|
|
|
4582
4924
|
|
|
4583
4925
|
return self._request_orphan_reviewers(pr, task_ref, waited)
|
|
4584
4926
|
|
|
4927
|
+
def _orphan_reviewers_split(
|
|
4928
|
+
self, pr: PullRequest
|
|
4929
|
+
) -> "tuple[tuple[str, ...], tuple[str, ...]]":
|
|
4930
|
+
"""The configured reviewers, split by what the PR already carries:
|
|
4931
|
+
(already requested, still missing), each in config order.
|
|
4932
|
+
|
|
4933
|
+
Casefolded like `_reviewer_reviews`: the configured set is
|
|
4934
|
+
operator-typed and GitHub logins are case-insensitive, so `Alissa-App`
|
|
4935
|
+
in the config and `alissa-app` on the PR are one reviewer, not a
|
|
4936
|
+
missing one. Stated once because the sweep and the completion path
|
|
4937
|
+
both need exactly this answer -- the request must never name a
|
|
4938
|
+
reviewer who is already on the PR (a no-op on GitHub that would still
|
|
4939
|
+
be recorded, and logged, as a fresh request)."""
|
|
4940
|
+
on_pr = {r.casefold() for r in pr.requested_reviewers}
|
|
4941
|
+
already = tuple(
|
|
4942
|
+
r for r in self.config.reviewers if r.casefold() in on_pr
|
|
4943
|
+
)
|
|
4944
|
+
missing = tuple(
|
|
4945
|
+
r for r in self.config.reviewers if r.casefold() not in on_pr
|
|
4946
|
+
)
|
|
4947
|
+
return already, missing
|
|
4948
|
+
|
|
4585
4949
|
def _owes_orphan_handoff(self, pr: PullRequest) -> bool:
|
|
4586
4950
|
"""Did THIS daemon ready this exact head and still owe it a reviewer?
|
|
4587
4951
|
|
|
@@ -4623,6 +4987,14 @@ class DevWatcher:
|
|
|
4623
4987
|
entitled to finish its own handoff). Not re-run is the grace window --
|
|
4624
4988
|
it was satisfied when the flip fired, and the head has not moved since
|
|
4625
4989
|
(a moved head would be a different key with no ready row at all).
|
|
4990
|
+
|
|
4991
|
+
Reached only for a PR with NO pending request (evaluate_pr's own
|
|
4992
|
+
guard sits above this). So a draft that already carried a reviewer
|
|
4993
|
+
can never arrive here: it left draft carrying that reviewer, and the
|
|
4994
|
+
pending-request guard reads it as in review. That is why
|
|
4995
|
+
`_request_orphan_reviewers` never leaves that shape half-recorded --
|
|
4996
|
+
a failed request for the MISSING reviewers there is given up on and
|
|
4997
|
+
the handoff completed on the spot (round-1 review fix, PR #114).
|
|
4626
4998
|
"""
|
|
4627
4999
|
if not self._owes_orphan_handoff(pr) or not self.config.reviewers:
|
|
4628
5000
|
return None
|
|
@@ -4671,43 +5043,73 @@ class DevWatcher:
|
|
|
4671
5043
|
this is the completion path (where that number is a poll or more old
|
|
4672
5044
|
and would be reported as fact).
|
|
4673
5045
|
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
5046
|
+
Only the configured reviewers NOT already on the PR are requested
|
|
5047
|
+
(`_orphan_reviewers_split`, issue #113). When every one of them is
|
|
5048
|
+
already requested -- the draft the session put a reviewer on before
|
|
5049
|
+
dying, or a completion-path PR someone requested by hand between
|
|
5050
|
+
polls -- no call is made at all: the DONE marker lands, the activity
|
|
5051
|
+
line says who was already there, and the reviewer daemon already sees
|
|
5052
|
+
the PR.
|
|
5053
|
+
|
|
5054
|
+
Failure handling on the review-less shape mirrors
|
|
5055
|
+
`_rerequest_review`'s exactly: a ReviewRequestRejected is permanent
|
|
5056
|
+
and operator-fixable (escalate, don't retry, and never record the
|
|
5057
|
+
DONE marker); a CommandError is transient (record nothing, retry next
|
|
5058
|
+
poll). The READY row stays in both cases -- the flip really did
|
|
5059
|
+
happen, and re-flipping an already-ready PR is not what the retry
|
|
5060
|
+
needs to do.
|
|
5061
|
+
|
|
5062
|
+
On the PARTIAL shape -- some configured reviewers already on the PR,
|
|
5063
|
+
the call carrying only the missing rest -- neither doctrine applies
|
|
5064
|
+
(round-1 review fix, PR #114). `request_reviews` raises only when
|
|
5065
|
+
nothing in THAT call landed, which here does not mean nobody is on
|
|
5066
|
+
the PR: the flip landed and `already` is requested, so the PR is in
|
|
5067
|
+
review and the reviewer daemon sees it. The refusal page would be
|
|
5068
|
+
false ("never marked ready or sent to a reviewer"), and a retry is
|
|
5069
|
+
structurally impossible: next poll the pending-request guard in
|
|
5070
|
+
evaluate_pr reads the PR as in review before `_finish_orphan_handoff`
|
|
5071
|
+
is reached, so a ready row left down would stick on that head
|
|
5072
|
+
forever. So a failed request for the missing subset, permanent or
|
|
5073
|
+
transient, is logged as given up on -- naming who IS on the PR --
|
|
5074
|
+
and the handoff is completed: DONE row, activity line."""
|
|
5075
|
+
already, missing = self._orphan_reviewers_split(pr)
|
|
5076
|
+
landed: tuple[str, ...] = ()
|
|
5077
|
+
if missing:
|
|
5078
|
+
try:
|
|
5079
|
+
landed = self.github.request_reviews(
|
|
5080
|
+
pr.owner, pr.repo, pr.number, missing
|
|
5081
|
+
)
|
|
5082
|
+
except ReviewRequestRejected as exc:
|
|
5083
|
+
if not already:
|
|
5084
|
+
return self._escalate_orphan_rejected(pr, task_ref, exc)
|
|
5085
|
+
self._log_orphan_reviewers_given_up(pr, missing, already, exc)
|
|
5086
|
+
except CommandError as exc:
|
|
5087
|
+
if not already:
|
|
5088
|
+
log.error(
|
|
5089
|
+
"could not request review from %s on %s: %s — NOT "
|
|
5090
|
+
"recording the handoff; the sweep retries next poll",
|
|
5091
|
+
", ".join(missing),
|
|
5092
|
+
pr.pr_slug,
|
|
5093
|
+
exc,
|
|
5094
|
+
)
|
|
5095
|
+
return Decision(
|
|
5096
|
+
Action.SKIPPED,
|
|
5097
|
+
f"the PR was marked ready but the review request "
|
|
5098
|
+
f"did not land ({exc}) — transient; not recorded, "
|
|
5099
|
+
f"retrying next poll",
|
|
5100
|
+
task_ref=task_ref,
|
|
5101
|
+
)
|
|
5102
|
+
self._log_orphan_reviewers_given_up(pr, missing, already, exc)
|
|
4701
5103
|
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
5104
|
+
dropped = tuple(r for r in missing if r not in landed)
|
|
5105
|
+
if dropped and landed:
|
|
5106
|
+
log.warning(
|
|
5107
|
+
"%s: GitHub silently dropped reviewer(s) %s from the "
|
|
5108
|
+
"mechanical handoff; %s landed, so the PR IS in review",
|
|
5109
|
+
pr.pr_slug,
|
|
5110
|
+
", ".join(dropped),
|
|
5111
|
+
", ".join(landed + already),
|
|
5112
|
+
)
|
|
4711
5113
|
|
|
4712
5114
|
self.state.record_escalation(
|
|
4713
5115
|
pr.full_name, pr.number, orphan_kind(pr.head_sha)
|
|
@@ -4717,6 +5119,23 @@ class DevWatcher:
|
|
|
4717
5119
|
if waited is not None
|
|
4718
5120
|
else ""
|
|
4719
5121
|
)
|
|
5122
|
+
if landed and already:
|
|
5123
|
+
acted = (
|
|
5124
|
+
f"marked ready and requested review from {', '.join(landed)} "
|
|
5125
|
+
f"({', '.join(already)} already requested)"
|
|
5126
|
+
)
|
|
5127
|
+
died = "before handing it off"
|
|
5128
|
+
elif landed:
|
|
5129
|
+
acted = f"marked ready and requested review from {', '.join(landed)}"
|
|
5130
|
+
died = "before handing it off"
|
|
5131
|
+
else:
|
|
5132
|
+
noun = "reviewer" if len(already) == 1 else "reviewers"
|
|
5133
|
+
verb = "was" if len(already) == 1 else "were"
|
|
5134
|
+
acted = (
|
|
5135
|
+
f"marked ready ({noun} {', '.join(already)} {verb} already "
|
|
5136
|
+
f"requested)"
|
|
5137
|
+
)
|
|
5138
|
+
died = "before flipping it"
|
|
4720
5139
|
self._append_activity(
|
|
4721
5140
|
pr.owner,
|
|
4722
5141
|
pr.repo,
|
|
@@ -4724,22 +5143,51 @@ class DevWatcher:
|
|
|
4724
5143
|
self._activity_line(
|
|
4725
5144
|
orphan_activity_lane(pr),
|
|
4726
5145
|
"orphan sweep",
|
|
4727
|
-
f"{ACTIVITY_HANDED_OFF} —
|
|
4728
|
-
f"
|
|
4729
|
-
f"
|
|
4730
|
-
f"protocol (no session spawned, once per PR/head)",
|
|
5146
|
+
f"{ACTIVITY_HANDED_OFF} — {acted}; the session that opened "
|
|
5147
|
+
f"this draft died {died}, so the daemon completed its "
|
|
5148
|
+
f"protocol{age} (no session spawned, once per PR/head)",
|
|
4731
5149
|
),
|
|
4732
5150
|
pr.pr_slug,
|
|
4733
5151
|
)
|
|
4734
5152
|
|
|
5153
|
+
if landed:
|
|
5154
|
+
requested = f"and review requested from {', '.join(landed)}"
|
|
5155
|
+
else:
|
|
5156
|
+
requested = (
|
|
5157
|
+
f"(review already requested from {', '.join(already)}, no "
|
|
5158
|
+
f"new request made)"
|
|
5159
|
+
)
|
|
4735
5160
|
return Decision(
|
|
4736
5161
|
Action.IN_FLIGHT,
|
|
4737
|
-
f"orphaned draft handed off — marked ready
|
|
4738
|
-
f"
|
|
4739
|
-
f"in the reviewer's court",
|
|
5162
|
+
f"orphaned draft handed off — marked ready {requested} for head "
|
|
5163
|
+
f"{pr.head_sha[:8]}; the ball is in the reviewer's court",
|
|
4740
5164
|
task_ref=task_ref,
|
|
4741
5165
|
)
|
|
4742
5166
|
|
|
5167
|
+
@staticmethod
|
|
5168
|
+
def _log_orphan_reviewers_given_up(
|
|
5169
|
+
pr: PullRequest,
|
|
5170
|
+
missing: "tuple[str, ...]",
|
|
5171
|
+
already: "tuple[str, ...]",
|
|
5172
|
+
exc: Exception,
|
|
5173
|
+
) -> None:
|
|
5174
|
+
"""The partial shape's one failure path (see
|
|
5175
|
+
`_request_orphan_reviewers`): the request for the configured
|
|
5176
|
+
reviewers NOT yet on the PR failed, `already` IS on it, and no retry
|
|
5177
|
+
can ever reach this PR again -- so the missing reviewers are given up
|
|
5178
|
+
on, out loud, and the handoff completes with who is there."""
|
|
5179
|
+
log.warning(
|
|
5180
|
+
"%s: could not request review from %s on the mechanical handoff "
|
|
5181
|
+
"(%s) — giving up on them: %s already requested and the PR is out "
|
|
5182
|
+
"of draft, so it IS in review; the daemon will not retry (the "
|
|
5183
|
+
"pending request reads as in review from here on), request them "
|
|
5184
|
+
"by hand if wanted",
|
|
5185
|
+
pr.pr_slug,
|
|
5186
|
+
", ".join(missing),
|
|
5187
|
+
exc,
|
|
5188
|
+
", ".join(already),
|
|
5189
|
+
)
|
|
5190
|
+
|
|
4743
5191
|
def _escalate_orphan_rejected(
|
|
4744
5192
|
self, pr: PullRequest, task_ref: "str | None", exc: Exception
|
|
4745
5193
|
) -> Decision:
|
|
@@ -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.19
|
|
@@ -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
|