alissa-tools-github-revloop 0.31.0__tar.gz → 0.31.2__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_revloop-0.31.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.2}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/alissa.py +57 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/loop.py +415 -19
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/state.py +57 -1
- alissa_tools_github_revloop-0.31.2/src/main/alissa/tools/github/revloop/trust.py +473 -0
- alissa_tools_github_revloop-0.31.2/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
- alissa_tools_github_revloop-0.31.0/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/README.md +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/setup.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/config.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -267,6 +267,43 @@ def clean_readiness_reason(text: object) -> str:
|
|
|
267
267
|
return flat[:MAX_READINESS_REASON_CHARS].rstrip()
|
|
268
268
|
|
|
269
269
|
|
|
270
|
+
# The trailer's own grammar (issue #134) -- the bare-line sibling of
|
|
271
|
+
# _READINESS_RE. This is the line a native review ENDS with, whoever wrote
|
|
272
|
+
# it: the daemon's emitter (loop.readiness_trailer) when it posts the verdict
|
|
273
|
+
# itself, or the reviewer session's own `gh pr review` on the normal path.
|
|
274
|
+
# It is the consumer's grammar verbatim (README, "The `Merge-Readiness`
|
|
275
|
+
# trailer"): line-anchored, no bullet, no bold, no backticks, first match
|
|
276
|
+
# wins, value case-sensitive. The label is the ONE constant the emitter
|
|
277
|
+
# builds from, so the two cannot drift: a line the emitter writes is, by
|
|
278
|
+
# construction, a line this regex reads.
|
|
279
|
+
READINESS_TRAILER_LABEL = "Merge-Readiness:"
|
|
280
|
+
READINESS_MISSING = "missing"
|
|
281
|
+
_TRAILER_RE = re.compile(
|
|
282
|
+
r"^" + re.escape(READINESS_TRAILER_LABEL)
|
|
283
|
+
+ r"[ \t]*(auto|operator)(?:[ \t]*[—-][ \t]*(.+))?[ \t]*$",
|
|
284
|
+
re.MULTILINE,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def parse_trailer(body: object) -> "tuple[str | None, str]":
|
|
289
|
+
"""`(value, reason)` from the first bare `Merge-Readiness:` line in a
|
|
290
|
+
review body, or `(None, "")` when no line matches the trailer grammar.
|
|
291
|
+
|
|
292
|
+
Strict where parse_readiness is tolerant: the line inside backticks
|
|
293
|
+
mid-sentence (studio #1258) and the envelope's `- **Merge-Readiness:**`
|
|
294
|
+
bullet both read as MISSING here, because the consumer's regex is what
|
|
295
|
+
decides whether the merge edge sees the judgment at all. CRLF bodies (a
|
|
296
|
+
review typed into the web form) are normalised first; the grammar itself
|
|
297
|
+
is unchanged.
|
|
298
|
+
"""
|
|
299
|
+
if not isinstance(body, str):
|
|
300
|
+
return (None, "")
|
|
301
|
+
match = _TRAILER_RE.search(body.replace("\r\n", "\n"))
|
|
302
|
+
if match is None:
|
|
303
|
+
return (None, "")
|
|
304
|
+
return (match.group(1), clean_readiness_reason(match.group(2)))
|
|
305
|
+
|
|
306
|
+
|
|
270
307
|
def parse_readiness(blob: object) -> "tuple[str | None, str]":
|
|
271
308
|
"""`(value, reason)` from the first Merge-Readiness line in `blob`.
|
|
272
309
|
|
|
@@ -1104,6 +1141,26 @@ class Alissa:
|
|
|
1104
1141
|
"""
|
|
1105
1142
|
run(["alissa", "tmux", "kill", session], timeout=30, check=False)
|
|
1106
1143
|
|
|
1144
|
+
def tail_session(self, session: str, lines: int) -> str:
|
|
1145
|
+
"""The last `lines` of ONE session's terminal, via `alissa tmux tail`.
|
|
1146
|
+
|
|
1147
|
+
The stale-round probe's evidence seam (issue #136): it asks whether a
|
|
1148
|
+
session that reads alive is parked on Claude Code's first-run dialog.
|
|
1149
|
+
Read-only, and best-effort by contract -- a CLI that cannot capture
|
|
1150
|
+
the pane (the session just died, no tmux server, a timeout) answers
|
|
1151
|
+
the EMPTY string, which the classifier treats as "no evidence", never
|
|
1152
|
+
as the dialog: absence of a capture keeps the existing defer.
|
|
1153
|
+
"""
|
|
1154
|
+
try:
|
|
1155
|
+
return run(
|
|
1156
|
+
["alissa", "tmux", "tail", "-n", str(lines), session],
|
|
1157
|
+
timeout=30,
|
|
1158
|
+
check=False,
|
|
1159
|
+
)
|
|
1160
|
+
except CommandError as exc:
|
|
1161
|
+
log.debug("could not tail %s: %s", session, exc)
|
|
1162
|
+
return ""
|
|
1163
|
+
|
|
1107
1164
|
def add_repo_to_workspace(
|
|
1108
1165
|
self, owner: str, repo: str, workspace_root: Path, *, dry_run: bool = False
|
|
1109
1166
|
) -> None:
|
|
@@ -23,7 +23,9 @@ from pathlib import Path
|
|
|
23
23
|
|
|
24
24
|
from .alissa import (
|
|
25
25
|
READINESS_AUTO,
|
|
26
|
+
READINESS_MISSING,
|
|
26
27
|
READINESS_OPERATOR,
|
|
28
|
+
READINESS_TRAILER_LABEL,
|
|
27
29
|
VERDICT_APPROVE,
|
|
28
30
|
VERDICT_REQUEST_CHANGES,
|
|
29
31
|
VerdictEnvelope,
|
|
@@ -33,10 +35,20 @@ from .alissa import (
|
|
|
33
35
|
Task,
|
|
34
36
|
TaskDetail,
|
|
35
37
|
is_review_task_for,
|
|
38
|
+
parse_trailer,
|
|
36
39
|
session_repo_slug,
|
|
37
40
|
)
|
|
38
41
|
from .alissa_client import AlissaClient
|
|
39
42
|
from .bows import EMPTY_SET_WARNING, FEED_PREFIX, BowRepoSource
|
|
43
|
+
from .trust import (
|
|
44
|
+
FIRST_RUN_DIALOG_MARKERS,
|
|
45
|
+
WEDGE_FIRST_RUN_DIALOG,
|
|
46
|
+
hub_root as hub_root_of,
|
|
47
|
+
hub_trust_paths,
|
|
48
|
+
pane_shows_first_run_dialog,
|
|
49
|
+
seed_trust,
|
|
50
|
+
write_derived_repos,
|
|
51
|
+
)
|
|
40
52
|
from .config import (
|
|
41
53
|
HUB_ADD,
|
|
42
54
|
REPOS_BOWS,
|
|
@@ -413,7 +425,11 @@ NATIVE_VERDICT_BODY = (
|
|
|
413
425
|
# an approve the checks gate downgraded carries nothing), and an envelope
|
|
414
426
|
# with no parseable line fails CLOSED to `operator`, with a reason that tells
|
|
415
427
|
# the operator why the merge waited on them.
|
|
416
|
-
|
|
428
|
+
#
|
|
429
|
+
# The label and the grammar live in alissa.py (READINESS_TRAILER_LABEL,
|
|
430
|
+
# parse_trailer): the emitter below builds from the label the regex is built
|
|
431
|
+
# from, and _observe_session_readiness reads a session's own review with that
|
|
432
|
+
# same regex (issue #134) -- one grammar, two writers, no drift.
|
|
417
433
|
READINESS_MISSING_REASON = "envelope carries no Merge-Readiness line"
|
|
418
434
|
|
|
419
435
|
|
|
@@ -644,15 +660,28 @@ _RECORD_THE_CAP = (
|
|
|
644
660
|
"from a stale template default. "
|
|
645
661
|
)
|
|
646
662
|
|
|
647
|
-
# The merge-readiness line (issue #130). The verdict envelope is where
|
|
648
|
-
# reviewer's judgment lives
|
|
649
|
-
#
|
|
650
|
-
#
|
|
663
|
+
# The merge-readiness line (issue #130, #134). The verdict envelope is where
|
|
664
|
+
# the reviewer's judgment lives. On the NORMAL path the session posts its own
|
|
665
|
+
# native review, and that body passes through nothing -- so the session must
|
|
666
|
+
# write the trailer itself, as the bare last non-empty line, in the consumer's
|
|
667
|
+
# grammar (studio #1258 wrote it inside backticks mid-sentence and the merge
|
|
668
|
+
# edge held the approve). The daemon copies the envelope's line onto a native
|
|
669
|
+
# review only on the rounds where it posts the verdict itself, and an
|
|
670
|
+
# envelope without the line posts as `operator` -- so a reviewer who skips it
|
|
671
|
+
# has silently withheld auto-merge. The two grammar sentences are the
|
|
672
|
+
# README's ("The `Merge-Readiness` trailer on a native approve"), verbatim.
|
|
651
673
|
_MERGE_READINESS_LINE = (
|
|
652
674
|
"Your verdict envelope MUST carry the skill's `- **Merge-Readiness:** auto | "
|
|
653
|
-
"operator — <reason>` line
|
|
654
|
-
"the
|
|
655
|
-
"
|
|
675
|
+
"operator — <reason>` line, AND your OWN native review (every `gh pr review` "
|
|
676
|
+
"form and the reviews-API POST alike) MUST END with the bare line "
|
|
677
|
+
"`Merge-Readiness: auto` or `Merge-Readiness: operator — <one-line reason>` "
|
|
678
|
+
"as its last non-empty line — plain text at the start of the line, not in "
|
|
679
|
+
"backticks, not bold, not mid-sentence — byte-equal in value and reason to "
|
|
680
|
+
"the envelope's line; `auto` only on an APPROVE of the reviewed head. The "
|
|
681
|
+
"merge edge reads that line off the review and holds an approve without "
|
|
682
|
+
"it. The daemon copies the envelope's line onto a native review only when "
|
|
683
|
+
"it posts the verdict itself, and an envelope without the line posts as "
|
|
684
|
+
"`operator`. "
|
|
656
685
|
)
|
|
657
686
|
|
|
658
687
|
# -- the reviewer session's own CI gate (issue #84) ---------------------------
|
|
@@ -1220,6 +1249,39 @@ def stalled_kind(session: str) -> str:
|
|
|
1220
1249
|
return f"{ESCALATION_STALLED}:{session}"
|
|
1221
1250
|
|
|
1222
1251
|
|
|
1252
|
+
# The first-run-dialog wedge's ping-ledger kind prefix (issue #136; see
|
|
1253
|
+
# ReviewWatcher._wedged_on_first_run_dialog). Like the stalled ping it recurs
|
|
1254
|
+
# per EPISODE, and the episode identity is the session name: the daemon
|
|
1255
|
+
# killed a session whose pane sat on one of Claude Code's first-run gates,
|
|
1256
|
+
# seeded the trust the gate was asking for, and re-queued the round itself.
|
|
1257
|
+
# The row dedupes the WARNING (one per episode -- a kill that fails and is
|
|
1258
|
+
# retried next poll logs its retry at INFO) and, under its own kind, the
|
|
1259
|
+
# activity-comment line.
|
|
1260
|
+
ESCALATION_FIRST_RUN_DIALOG = "first-run-dialog"
|
|
1261
|
+
|
|
1262
|
+
# How many pane lines the first-run-dialog check reads. The trust dialog is a
|
|
1263
|
+
# ~12-line box and the bypass-permissions gate ~20; a session sitting on
|
|
1264
|
+
# either has printed nothing since, so the last 40 lines hold the whole
|
|
1265
|
+
# prompt with room for the banner above it.
|
|
1266
|
+
FIRST_RUN_PANE_TAIL_LINES = 40
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def first_run_dialog_kind(session: str) -> str:
|
|
1270
|
+
"""The ping-ledger kind that dedupes ONE first-run-dialog wedge episode's
|
|
1271
|
+
WARNING. stalled_kind's episode reasoning: every spawn of every round can
|
|
1272
|
+
meet the dialog (a hub trusted after session k was killed is trusted for
|
|
1273
|
+
session k+1, but a seeding that failed to land leaves the next session
|
|
1274
|
+
on the same prompt), and episode k's row must not silence episode
|
|
1275
|
+
k+1's. The session name is nonce-unique per spawn, so it is the key."""
|
|
1276
|
+
return f"{ESCALATION_FIRST_RUN_DIALOG}:{session}"
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def first_run_dialog_activity_kind(session: str) -> str:
|
|
1280
|
+
"""The ping-ledger kind that dedupes the episode's activity-comment line;
|
|
1281
|
+
recorded only after the append lands, so a transient failure retries."""
|
|
1282
|
+
return f"activity-{ESCALATION_FIRST_RUN_DIALOG}:{session}"
|
|
1283
|
+
|
|
1284
|
+
|
|
1223
1285
|
def capout_kind(head_sha: str, granted: int) -> str:
|
|
1224
1286
|
"""The ping-ledger kind that dedupes ONE cap-out page.
|
|
1225
1287
|
|
|
@@ -1795,6 +1857,13 @@ class ReviewWatcher:
|
|
|
1795
1857
|
# has listed this pass (the preflight refresh, or a sweep whose list
|
|
1796
1858
|
# failed), and the refresh lists for itself.
|
|
1797
1859
|
self._pass_roster: list[ManagedSession] | None = None
|
|
1860
|
+
# The (repo, number, round) whose alive session the stale-round probe
|
|
1861
|
+
# just killed as `wedged:first-run-dialog` (issue #136), so the
|
|
1862
|
+
# respawn site logs its re-enqueue at INFO instead of a second
|
|
1863
|
+
# WARNING: the classifier's WARNING already said everything the
|
|
1864
|
+
# "presumed dead" line would, and the issue's contract is ONE.
|
|
1865
|
+
# Consumed by the respawn that follows in the same evaluate().
|
|
1866
|
+
self._dialog_wedge_cleared: "tuple[str, int, int] | None" = None
|
|
1798
1867
|
# (repo full name, PR number) -> when the reviewer login was most
|
|
1799
1868
|
# recently asked to review that PR, per the issue timeline (an ISO
|
|
1800
1869
|
# stamp, or None for never) -- the round-admission gate's one GitHub
|
|
@@ -1934,6 +2003,39 @@ class ReviewWatcher:
|
|
|
1934
2003
|
# Telemetry-class: the console's window onto the derived set.
|
|
1935
2004
|
# Best-effort inside State, like every other telemetry write.
|
|
1936
2005
|
self.state.record_derived_repos(source.sources())
|
|
2006
|
+
self._record_derived(source.derived)
|
|
2007
|
+
|
|
2008
|
+
def _record_derived(self, derived: "tuple[str, ...]") -> None:
|
|
2009
|
+
"""Issue #136: make the derived allowlist visible to the entrypoint's
|
|
2010
|
+
boot-time trust seeding (`{root}/.alissa-derived-repos`) and trust
|
|
2011
|
+
the derived hubs NOW -- hub-ified or not, exactly as the entrypoint
|
|
2012
|
+
trusts a static allowlist's hubs before they exist. Both are
|
|
2013
|
+
best-effort and idempotent; dry-run writes nothing. Called after
|
|
2014
|
+
every refresh, so a retained set (a refresh that failed keeps the
|
|
2015
|
+
last derived set) is re-asserted too, at no cost when nothing
|
|
2016
|
+
changed."""
|
|
2017
|
+
if not derived:
|
|
2018
|
+
return
|
|
2019
|
+
if self.config.dry_run:
|
|
2020
|
+
log.info(
|
|
2021
|
+
"[dry-run] would record %d derived repo(s) and pre-trust "
|
|
2022
|
+
"their hubs for claude", len(derived),
|
|
2023
|
+
)
|
|
2024
|
+
return
|
|
2025
|
+
write_derived_repos(self.config.workspace_root, derived)
|
|
2026
|
+
paths: "list[Path]" = []
|
|
2027
|
+
for full_name in derived:
|
|
2028
|
+
owner, _, repo = full_name.partition("/")
|
|
2029
|
+
if not owner or not repo:
|
|
2030
|
+
continue
|
|
2031
|
+
paths.extend(hub_trust_paths(self.config.hub_for(owner, repo)))
|
|
2032
|
+
changed = seed_trust(paths)
|
|
2033
|
+
if changed:
|
|
2034
|
+
log.info(
|
|
2035
|
+
"repos_source=bows: pre-trusted the hubs of %d derived "
|
|
2036
|
+
"repo(s) for claude (root and main/ each) in %s",
|
|
2037
|
+
len(derived), ", ".join(str(t) for t in changed),
|
|
2038
|
+
)
|
|
1937
2039
|
|
|
1938
2040
|
def _search_allowlist(self) -> "tuple[str, ...] | None":
|
|
1939
2041
|
"""The allowlist the review-requested search runs over, or None when
|
|
@@ -2191,6 +2293,14 @@ class ReviewWatcher:
|
|
|
2191
2293
|
# "that round has no native verdict", producing a duplicate post over a
|
|
2192
2294
|
# session that closed its own round correctly.
|
|
2193
2295
|
owed = completed - self.state.abandoned_rounds(pr.full_name, number)
|
|
2296
|
+
|
|
2297
|
+
# Observation only (issue #134): a session-posted APPROVE on the
|
|
2298
|
+
# current head is looked at once for the Merge-Readiness trailer the
|
|
2299
|
+
# merge edge reads. Above the native post and convergence because it
|
|
2300
|
+
# decides nothing -- it reports; and it must run on the pass that
|
|
2301
|
+
# converges, since a converged PR leaves the search set.
|
|
2302
|
+
self._observe_session_readiness(pr, my_reviews, completed)
|
|
2303
|
+
|
|
2194
2304
|
if task is not None and owed > native:
|
|
2195
2305
|
# Terminal for this pass either way. On a landed post the review
|
|
2196
2306
|
# request it consumed drops the PR out of the search, so
|
|
@@ -2343,14 +2453,28 @@ class ReviewWatcher:
|
|
|
2343
2453
|
if age is not None:
|
|
2344
2454
|
# Logged only once the gate has let the respawn through, so the
|
|
2345
2455
|
# line cannot claim a re-enqueue that back-pressure then deferred.
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2456
|
+
# A round whose session the probe killed as wedged on the
|
|
2457
|
+
# first-run dialog was already WARNED once by the classifier, so
|
|
2458
|
+
# its re-enqueue is INFO (issue #136: one WARNING per episode).
|
|
2459
|
+
wedged = self._dialog_wedge_cleared == (pr.full_name, number, round_)
|
|
2460
|
+
self._dialog_wedge_cleared = None
|
|
2461
|
+
if wedged:
|
|
2462
|
+
log.info(
|
|
2463
|
+
"%s round %d re-enqueuing after its session was killed "
|
|
2464
|
+
"(%s)",
|
|
2465
|
+
pr.slug,
|
|
2466
|
+
round_,
|
|
2467
|
+
WEDGE_FIRST_RUN_DIALOG,
|
|
2468
|
+
)
|
|
2469
|
+
else:
|
|
2470
|
+
log.warning(
|
|
2471
|
+
"%s round %d has been in flight %.0f min with no submitted "
|
|
2472
|
+
"review and its session is gone or finished — re-enqueuing "
|
|
2473
|
+
"(reviewer session presumed dead)",
|
|
2474
|
+
pr.slug,
|
|
2475
|
+
round_,
|
|
2476
|
+
age / 60,
|
|
2477
|
+
)
|
|
2354
2478
|
|
|
2355
2479
|
return self._spawn(
|
|
2356
2480
|
pr,
|
|
@@ -4013,6 +4137,22 @@ class ReviewWatcher:
|
|
|
4013
4137
|
if ses.is_idle and quiet_for >= self.config.reap_grace_seconds:
|
|
4014
4138
|
return None # idle-finished: it died without submitting -> respawn
|
|
4015
4139
|
|
|
4140
|
+
# Issue #136: alive is a process, not a reviewer. A session sitting
|
|
4141
|
+
# on Claude Code's first-run "trust this folder?" (or the
|
|
4142
|
+
# bypass-permissions) gate is listed, registers activity, and will
|
|
4143
|
+
# never submit anything -- the directive typed into the pane was
|
|
4144
|
+
# swallowed by the prompt. Only here, on a SUCCESSFUL listing that
|
|
4145
|
+
# names the row, and only when the pane SHOWS the gate: every other
|
|
4146
|
+
# alive-but-idle case keeps the floored defer below, exactly as
|
|
4147
|
+
# before. A match has been killed and its hub seeded, so the round
|
|
4148
|
+
# re-queues NOW as the dead-session path would (same attempt/round
|
|
4149
|
+
# accounting: `reenqueued`, the `stale_reenqueued` bucket).
|
|
4150
|
+
if self._wedged_on_first_run_dialog(
|
|
4151
|
+
pr, round_, session, str(row["task_ref"] or "") or None, cap
|
|
4152
|
+
):
|
|
4153
|
+
self._dialog_wedge_cleared = (pr.full_name, pr.number, round_)
|
|
4154
|
+
return None
|
|
4155
|
+
|
|
4016
4156
|
if (
|
|
4017
4157
|
age >= STALLED_DEFER_MULTIPLE * STALE_ROUND_SECONDS
|
|
4018
4158
|
and not self.state.pinged(pr.full_name, pr.number, stalled_kind(session))
|
|
@@ -4042,6 +4182,95 @@ class ReviewWatcher:
|
|
|
4042
4182
|
deferred=True,
|
|
4043
4183
|
)
|
|
4044
4184
|
|
|
4185
|
+
def _observe_session_readiness(
|
|
4186
|
+
self, pr: PullRequest, my_reviews: list[Review], round_: int
|
|
4187
|
+
) -> None:
|
|
4188
|
+
"""Say whether the session's own APPROVE carries the trailer (issue #134).
|
|
4189
|
+
|
|
4190
|
+
The merge edge reads `Merge-Readiness:` off the reviewer identity's
|
|
4191
|
+
APPROVE on the current head. On the normal path that review is the
|
|
4192
|
+
SESSION's -- posted by its own `gh pr review`, passing through nothing
|
|
4193
|
+
here -- so a body without the bare line (studio #1258 had it inside
|
|
4194
|
+
backticks mid-sentence) is an approve the edge holds, silently. The
|
|
4195
|
+
fix is the skill's and the directive's; this is the daemon SEEING it:
|
|
4196
|
+
one WARNING and one activity row per (PR, head) when the trailer is
|
|
4197
|
+
missing, the `readiness=auto|operator` term when it parses.
|
|
4198
|
+
|
|
4199
|
+
Observation only, by contract: never a review posted, never the
|
|
4200
|
+
session's review edited, never a round re-run. Scoped to the newest
|
|
4201
|
+
reviewer-identity APPROVED review on the current head -- the review the
|
|
4202
|
+
merge edge reads, whatever the identity wrote after it (a round-k
|
|
4203
|
+
reviewer that fell back to `--comment`, a follow-up write-up) -- and
|
|
4204
|
+
NOT the daemon's own post: that one is emitter-built and already
|
|
4205
|
+
reported `readiness=` at post time (a second row for it would be
|
|
4206
|
+
noise). Once per (PR, head) via the ledger flag beside the verdicts
|
|
4207
|
+
row: a new head re-arms, a re-poll of the same head is silent.
|
|
4208
|
+
|
|
4209
|
+
Emit, then record, like every other activity note here: the ledger
|
|
4210
|
+
flag lands only after the activity row did, so a transient comment
|
|
4211
|
+
failure retries next poll instead of losing the row; and never under
|
|
4212
|
+
`--dry-run`, where the row cannot land and a durable flag would let a
|
|
4213
|
+
diagnostic pass silence the daemon it was run to diagnose (see
|
|
4214
|
+
`_warn_identity_drift`). The flag rides a verdict row that
|
|
4215
|
+
`last_verdict_at` also reads, so it is stamped only with the review's
|
|
4216
|
+
own time -- an unreadable GitHub stamp keeps the log line and skips
|
|
4217
|
+
both the row and the flag rather than inventing a verdict at "now".
|
|
4218
|
+
"""
|
|
4219
|
+
approves = [
|
|
4220
|
+
r for r in my_reviews
|
|
4221
|
+
if r.state == "APPROVED" and r.commit_id and r.commit_id == pr.head_sha
|
|
4222
|
+
]
|
|
4223
|
+
newest = approves[-1] if approves else None
|
|
4224
|
+
if newest is None or newest.verdict_round is not None:
|
|
4225
|
+
return
|
|
4226
|
+
if self.state.readiness_observed(pr.full_name, pr.number, pr.head_sha) is not None:
|
|
4227
|
+
return
|
|
4228
|
+
|
|
4229
|
+
value, reason = parse_trailer(newest.body)
|
|
4230
|
+
head7 = pr.head_sha[:7]
|
|
4231
|
+
if value is None:
|
|
4232
|
+
readiness = READINESS_MISSING
|
|
4233
|
+
log.warning(
|
|
4234
|
+
"%s approve at %s by %s carries no Merge-Readiness trailer — the "
|
|
4235
|
+
"merge edge will hold it; the session must end its review body "
|
|
4236
|
+
"with the line (see directive)",
|
|
4237
|
+
pr.slug, head7, self.github.login,
|
|
4238
|
+
)
|
|
4239
|
+
line = (
|
|
4240
|
+
f"- {_now()} — round {round_} — session-posted `APPROVE` review by "
|
|
4241
|
+
f"`{self.github.login}` at `{head7}` — readiness=missing (no "
|
|
4242
|
+
f"`{READINESS_TRAILER_LABEL}` trailer on the review body; the merge "
|
|
4243
|
+
f"edge will hold it)"
|
|
4244
|
+
)
|
|
4245
|
+
else:
|
|
4246
|
+
readiness = value
|
|
4247
|
+
term = f"{value} — {reason}" if reason else value
|
|
4248
|
+
log.info(
|
|
4249
|
+
"%s approve at %s by %s carries readiness=%s",
|
|
4250
|
+
pr.slug, head7, self.github.login, term,
|
|
4251
|
+
)
|
|
4252
|
+
line = (
|
|
4253
|
+
f"- {_now()} — round {round_} — session-posted `APPROVE` review by "
|
|
4254
|
+
f"`{self.github.login}` at `{head7}` — readiness={term}"
|
|
4255
|
+
)
|
|
4256
|
+
|
|
4257
|
+
posted_at = _epoch(newest.submitted_at)
|
|
4258
|
+
if posted_at is None:
|
|
4259
|
+
# No stamp to key a verdict row on; the log line above is the
|
|
4260
|
+
# whole report for this poll, and the next poll says it again.
|
|
4261
|
+
log.debug(
|
|
4262
|
+
"%s: approve at %s has no readable submitted_at; readiness row "
|
|
4263
|
+
"and flag skipped", pr.slug, head7,
|
|
4264
|
+
)
|
|
4265
|
+
return
|
|
4266
|
+
if not self._append_activity(pr, line):
|
|
4267
|
+
return # retried next poll, like every other activity note
|
|
4268
|
+
if self.config.dry_run:
|
|
4269
|
+
return
|
|
4270
|
+
self.state.note_readiness(
|
|
4271
|
+
pr.full_name, pr.number, pr.head_sha, int(posted_at), readiness, newest.url,
|
|
4272
|
+
)
|
|
4273
|
+
|
|
4045
4274
|
def _convergence_reason(
|
|
4046
4275
|
self, my_reviews: list[Review], resolved: "ResolvedTask", head_sha: str
|
|
4047
4276
|
) -> str | None:
|
|
@@ -4239,7 +4468,9 @@ class ReviewWatcher:
|
|
|
4239
4468
|
|
|
4240
4469
|
The two backing stores are not interchangeable and the split is the
|
|
4241
4470
|
whole point: durable in production, process-lifetime in dry-run, so
|
|
4242
|
-
neither mode can ever silence the other. See _warn_identity_drift
|
|
4471
|
+
neither mode can ever silence the other. See _warn_identity_drift;
|
|
4472
|
+
the first-run-dialog wedge (_wedged_on_first_run_dialog) keys its
|
|
4473
|
+
one-WARNING-per-episode gate through the same pair.
|
|
4243
4474
|
"""
|
|
4244
4475
|
if record:
|
|
4245
4476
|
return self.state.pinged(pr.full_name, pr.number, kind)
|
|
@@ -4819,7 +5050,7 @@ class ReviewWatcher:
|
|
|
4819
5050
|
stability=stability.text if stability is not None else "",
|
|
4820
5051
|
)
|
|
4821
5052
|
|
|
4822
|
-
hub, problem = self._ensure_hub(pr)
|
|
5053
|
+
hub, problem = self._ensure_hub(pr, task_ref=task.ref if task else None)
|
|
4823
5054
|
if problem is not None:
|
|
4824
5055
|
return Decision(Action.SKIPPED, problem, round_)
|
|
4825
5056
|
|
|
@@ -4923,7 +5154,9 @@ class ReviewWatcher:
|
|
|
4923
5154
|
env_var=self.config.reviewer_token_env, reviewer=self.github.login
|
|
4924
5155
|
)
|
|
4925
5156
|
|
|
4926
|
-
def _ensure_hub(
|
|
5157
|
+
def _ensure_hub(
|
|
5158
|
+
self, pr: PullRequest, *, task_ref: "str | None" = None
|
|
5159
|
+
) -> tuple[Path, str | None]:
|
|
4927
5160
|
"""Resolve the reviewer's cwd, hub-ifying the repo first if configured.
|
|
4928
5161
|
|
|
4929
5162
|
Returns (hub, problem). `problem` is non-None when the round cannot run.
|
|
@@ -4933,9 +5166,20 @@ class ReviewWatcher:
|
|
|
4933
5166
|
_refused_before_start, above the CI gate. The re-read below is not
|
|
4934
5167
|
redundant with it -- `add` can have created the hub in between, and this
|
|
4935
5168
|
is the check that says so.
|
|
5169
|
+
|
|
5170
|
+
Either way out (hub present, or hub-ified here) the hub is pre-trusted
|
|
5171
|
+
for claude BEFORE the caller enqueues the session (issue #136; see
|
|
5172
|
+
`_trust_hub`). `task_ref` names the round's review task, so the
|
|
5173
|
+
`REVIEW-<task>` checkout the skill may create is trusted too.
|
|
4936
5174
|
"""
|
|
4937
5175
|
hub = self.config.hub_for(pr.owner, pr.repo)
|
|
4938
5176
|
if hub.is_dir():
|
|
5177
|
+
# A hub that appeared between boots was never seeded by the
|
|
5178
|
+
# entrypoint at all, and a REVIEW-* checkout may have appeared
|
|
5179
|
+
# since the last spawn: trust is re-asserted before EVERY spawn.
|
|
5180
|
+
# Idempotent and cheap -- a state file already carrying every
|
|
5181
|
+
# path is not rewritten.
|
|
5182
|
+
self._trust_hub(hub, "before spawn", task_ref)
|
|
4939
5183
|
return hub, None
|
|
4940
5184
|
|
|
4941
5185
|
# Guarded twice: config.load() rejects 'add' without an allowlist, and
|
|
@@ -4968,8 +5212,160 @@ class ReviewWatcher:
|
|
|
4968
5212
|
f"{hub} still does not exist — check hub_template against the "
|
|
4969
5213
|
f"manifest's `dir:` override"
|
|
4970
5214
|
)
|
|
5215
|
+
# Issue #136: the entrypoint pre-trusts only the hubs it can name at
|
|
5216
|
+
# boot, and under `repos_source: bows` a hub created here was never
|
|
5217
|
+
# among them -- the first session on it would sit on Claude Code's
|
|
5218
|
+
# "trust this folder?" dialog while the stale-round probe read it as
|
|
5219
|
+
# alive. Seed the hub root, main/ (the spawn cwd) and the review
|
|
5220
|
+
# checkout BEFORE the session is enqueued.
|
|
5221
|
+
self._trust_hub(hub, "hub-ified", task_ref)
|
|
4971
5222
|
return hub, None
|
|
4972
5223
|
|
|
5224
|
+
def _trust_hub(
|
|
5225
|
+
self, cwd: Path, why: str, task_ref: "str | None" = None
|
|
5226
|
+
) -> "list[Path]":
|
|
5227
|
+
"""Pre-accept Claude Code's trust dialog for every directory a
|
|
5228
|
+
reviewer session on this hub may start in or `cd` into (see
|
|
5229
|
+
`trust.hub_trust_paths`: the hub root, `main/`, the `REVIEW-<task>`
|
|
5230
|
+
checkout), in both claude state files. Returns the state files that
|
|
5231
|
+
changed. Dry-run seeds nothing. Best-effort by construction
|
|
5232
|
+
(`seed_trust` never raises): a session can still be spawned into an
|
|
5233
|
+
untrusted hub -- it just may wedge on the dialog, which the
|
|
5234
|
+
stale-round probe now detects -- so a failed seed must never cost the
|
|
5235
|
+
spawn."""
|
|
5236
|
+
paths = hub_trust_paths(cwd, task_ref=task_ref)
|
|
5237
|
+
if self.config.dry_run:
|
|
5238
|
+
log.info(
|
|
5239
|
+
"[dry-run] would pre-trust %d claude dir(s) for hub %s (%s)",
|
|
5240
|
+
len(paths), hub_root_of(cwd), why,
|
|
5241
|
+
)
|
|
5242
|
+
return []
|
|
5243
|
+
changed = seed_trust(paths)
|
|
5244
|
+
if changed:
|
|
5245
|
+
log.info(
|
|
5246
|
+
"trusted hub %s for claude (%s): %d dir(s) — %s — written to %s",
|
|
5247
|
+
hub_root_of(cwd), why, len(paths),
|
|
5248
|
+
", ".join(str(p) for p in paths),
|
|
5249
|
+
", ".join(str(t) for t in changed),
|
|
5250
|
+
)
|
|
5251
|
+
return changed
|
|
5252
|
+
|
|
5253
|
+
def _wedged_on_first_run_dialog(
|
|
5254
|
+
self,
|
|
5255
|
+
pr: PullRequest,
|
|
5256
|
+
round_: int,
|
|
5257
|
+
session: str,
|
|
5258
|
+
task_ref: "str | None",
|
|
5259
|
+
cap: int,
|
|
5260
|
+
) -> bool:
|
|
5261
|
+
"""Issue #136: is this stale round's alive session sitting on one of
|
|
5262
|
+
Claude Code's first-run gates -- and if so, clear the lane.
|
|
5263
|
+
|
|
5264
|
+
Reads the pane (`alissa tmux tail`, 40 lines) and asks
|
|
5265
|
+
`trust.pane_shows_first_run_dialog` whether the session is PARKED on
|
|
5266
|
+
a gate -- the gate's accept option among the last lines with nothing
|
|
5267
|
+
but the gate's own chrome below it, its question strictly above -- not merely
|
|
5268
|
+
whether the gate's words appear: this repo's own README, CHANGELOG,
|
|
5269
|
+
`trust.py` and issue #136 quote them, so a session that cats or
|
|
5270
|
+
diffs any of them has them on screen while at work, and a kill here
|
|
5271
|
+
would double the round. A pane not parked on a gate, or one the CLI
|
|
5272
|
+
could not capture, answers False and the caller keeps the floored
|
|
5273
|
+
defer it always took (the other alive-but-idle causes -- an expired
|
|
5274
|
+
login, a usage limit, any other permission prompt -- are
|
|
5275
|
+
deliberately NOT classified here).
|
|
5276
|
+
|
|
5277
|
+
A match is `wedged:first-run-dialog`: ONE WARNING per episode (keyed
|
|
5278
|
+
first_run_dialog_kind(session) -- in the durable ping ledger in
|
|
5279
|
+
production, in the process-lifetime `_dry_run_drift` set under
|
|
5280
|
+
dry-run, the `_warn_identity_drift` split, so a diagnostic pass can
|
|
5281
|
+
never silence production nor production the diagnostic; a kill that
|
|
5282
|
+
fails and is retried next poll logs at INFO), then the row's own session is
|
|
5283
|
+
killed (`alissa tmux kill <name>`, never a sweep), the hub it started
|
|
5284
|
+
in is trusted (root, main/, the review checkout -- the very entries
|
|
5285
|
+
the gate was asking for, so the re-queued round does not meet it
|
|
5286
|
+
again), one activity-comment line records the act, and True tells
|
|
5287
|
+
the caller to re-queue the round NOW, exactly as a dead session
|
|
5288
|
+
would (the respawn is `reenqueued`, lands in the `stale_reenqueued`
|
|
5289
|
+
bucket, and burns the round's attempt the same way). Dry-run
|
|
5290
|
+
classifies and logs (once per episode, in memory) but writes no
|
|
5291
|
+
ledger row, kills nothing and answers False (the defer holds). A
|
|
5292
|
+
kill that fails answers False too: the defer holds and the
|
|
5293
|
+
next poll, reading the same pane, tries again -- the remedy for a
|
|
5294
|
+
session that will not die, not a loop to guard against.
|
|
5295
|
+
|
|
5296
|
+
The pane is untrusted third-party terminal content: only its
|
|
5297
|
+
CLASSIFICATION reaches the log; the capture itself stays at DEBUG.
|
|
5298
|
+
"""
|
|
5299
|
+
pane = self.alissa.tail_session(session, FIRST_RUN_PANE_TAIL_LINES)
|
|
5300
|
+
if not pane_shows_first_run_dialog(pane):
|
|
5301
|
+
return False
|
|
5302
|
+
log.debug(
|
|
5303
|
+
"%s: last %d pane lines of %s:\n%s",
|
|
5304
|
+
WEDGE_FIRST_RUN_DIALOG, FIRST_RUN_PANE_TAIL_LINES, session, pane,
|
|
5305
|
+
)
|
|
5306
|
+
cwd = self.config.hub_for(pr.owner, pr.repo)
|
|
5307
|
+
hub = hub_root_of(cwd)
|
|
5308
|
+
kind = first_run_dialog_kind(session)
|
|
5309
|
+
# Durable in production, process-lifetime in dry-run (review round 1
|
|
5310
|
+
# of PR #137): the two stores never meet, so a `--once --dry-run`
|
|
5311
|
+
# pass over the default state path cannot eat production's WARNING.
|
|
5312
|
+
record = not self.config.dry_run
|
|
5313
|
+
if self._drift_gated(pr, kind, record):
|
|
5314
|
+
log.info(
|
|
5315
|
+
"%s: session %s (%s round %d) is still on claude's first-run "
|
|
5316
|
+
"dialog — retrying the kill",
|
|
5317
|
+
WEDGE_FIRST_RUN_DIALOG, session, pr.slug, round_,
|
|
5318
|
+
)
|
|
5319
|
+
else:
|
|
5320
|
+
log.warning(
|
|
5321
|
+
"%s: session %s (%s round %d) reads alive but its pane shows "
|
|
5322
|
+
"claude's first-run dialog (%s) — the directive was swallowed "
|
|
5323
|
+
"by the prompt and no verdict will ever be submitted. Killing "
|
|
5324
|
+
"it, pre-trusting hub %s (root, main/, review checkout) for "
|
|
5325
|
+
"claude, and re-queuing round %d now (counted exactly as a dead "
|
|
5326
|
+
"session's respawn). Operator lever: `alissa tmux tail "
|
|
5327
|
+
"<session>` to see the prompt, `tmux send-keys -t <session> "
|
|
5328
|
+
"Down Enter` to accept it by hand, or kill + seed as the daemon "
|
|
5329
|
+
"does",
|
|
5330
|
+
WEDGE_FIRST_RUN_DIALOG, session, pr.slug, round_,
|
|
5331
|
+
" / ".join(repr(m) for m in FIRST_RUN_DIALOG_MARKERS), hub,
|
|
5332
|
+
round_,
|
|
5333
|
+
)
|
|
5334
|
+
self._note_drift_gate(pr, kind, record)
|
|
5335
|
+
if self.config.dry_run:
|
|
5336
|
+
log.info(
|
|
5337
|
+
"[dry-run] would kill %s, pre-trust %s and re-queue round %d",
|
|
5338
|
+
session, hub, round_,
|
|
5339
|
+
)
|
|
5340
|
+
return False
|
|
5341
|
+
try:
|
|
5342
|
+
self.alissa.kill_session(session)
|
|
5343
|
+
except CommandError as exc:
|
|
5344
|
+
log.warning(
|
|
5345
|
+
"%s: could not kill %s (%s) — kills are best-effort; the round "
|
|
5346
|
+
"keeps its defer and the kill is retried next poll",
|
|
5347
|
+
WEDGE_FIRST_RUN_DIALOG, session, exc,
|
|
5348
|
+
)
|
|
5349
|
+
return False
|
|
5350
|
+
self._trust_hub(cwd, WEDGE_FIRST_RUN_DIALOG, task_ref)
|
|
5351
|
+
activity_kind = first_run_dialog_activity_kind(session)
|
|
5352
|
+
if not self.state.pinged(pr.full_name, pr.number, activity_kind):
|
|
5353
|
+
landed = self._append_activity(
|
|
5354
|
+
pr,
|
|
5355
|
+
self._activity_line(
|
|
5356
|
+
session,
|
|
5357
|
+
round_,
|
|
5358
|
+
f"{WEDGE_FIRST_RUN_DIALOG} — session `{session}` read alive "
|
|
5359
|
+
f"but its pane sat on claude's first-run dialog (the hub was "
|
|
5360
|
+
f"not pre-trusted); killed, hub `{hub}` pre-trusted, round "
|
|
5361
|
+
f"{round_} re-queued",
|
|
5362
|
+
cap,
|
|
5363
|
+
),
|
|
5364
|
+
)
|
|
5365
|
+
if landed:
|
|
5366
|
+
self.state.record_ping(pr.full_name, pr.number, activity_kind)
|
|
5367
|
+
return True
|
|
5368
|
+
|
|
4973
5369
|
def preflight(self) -> list[str]:
|
|
4974
5370
|
"""Startup checks. Returns warnings; raises on anything fatal."""
|
|
4975
5371
|
warnings: list[str] = []
|