alissa-tools-github-revloop 0.31.8__tar.gz → 0.31.10__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.8/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.10}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/alissa.py +120 -4
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/loop.py +301 -1
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/loop_events.py +177 -22
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/state.py +12 -1
- alissa_tools_github_revloop-0.31.10/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.31.8/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/README.md +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/setup.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/config.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/trust.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -219,6 +219,30 @@ def _advertises(helptext: str, flag: str) -> bool:
|
|
|
219
219
|
) is not None
|
|
220
220
|
|
|
221
221
|
|
|
222
|
+
# The two spawn stamps of the Studio loop cost meter (studio.alissa.app
|
|
223
|
+
# docs/design/loop-cost-meter.md §2.3, §2.4, lane L4; issue #153, the port of
|
|
224
|
+
# devloop's issue #149): `--task` names the ORIGIN task the reviewer serves and
|
|
225
|
+
# `--repo` the canonical lowercase `owner/name`, so the session row carries
|
|
226
|
+
# `focusTaskId` and `repo` from its first second. The reviewer's skill then
|
|
227
|
+
# re-anchors `current_task` on its review task and that write wins the focus;
|
|
228
|
+
# the origin keeps its `code_session` evidence row and the meter resolves the
|
|
229
|
+
# rest at read time (§2.4).
|
|
230
|
+
QUEUE_ADD_TASK_FLAG = "--task"
|
|
231
|
+
QUEUE_ADD_REPO_FLAG = "--repo"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def queue_add_accepts_stamps(helptext: str) -> bool:
|
|
235
|
+
"""Whether `alissa tmux queue add --help` lists BOTH `--task` and `--repo`.
|
|
236
|
+
|
|
237
|
+
Both or neither: the CLI this daemon shipped against (0.3.0) already lists
|
|
238
|
+
`-t, --task <ref>`, but with the older meaning -- link a queue item for
|
|
239
|
+
shell write-back -- and has no `--repo`; that CLI is not the stamp-aware
|
|
240
|
+
one, so it gets neither flag.
|
|
241
|
+
"""
|
|
242
|
+
text = helptext or ""
|
|
243
|
+
return _advertises(text, QUEUE_ADD_TASK_FLAG) and _advertises(text, QUEUE_ADD_REPO_FLAG)
|
|
244
|
+
|
|
245
|
+
|
|
222
246
|
# CR6 verdict envelope outcomes.
|
|
223
247
|
VERDICT_APPROVE = "approve"
|
|
224
248
|
VERDICT_REQUEST_CHANGES = "request_changes"
|
|
@@ -503,6 +527,32 @@ def parse_commit_readiness(blob: object) -> "tuple[str | None, str, str | None]"
|
|
|
503
527
|
return _commit_classed(match.group(1), match.group(2))
|
|
504
528
|
|
|
505
529
|
|
|
530
|
+
# The contract version the reviewer judged against (issue #155; Studio
|
|
531
|
+
# design `loop-operator-surface.md` §6.4, lanes L10/L11): the reviewer reads
|
|
532
|
+
# the task at round start and records `**Contract:** v<n>` under the reviewed
|
|
533
|
+
# head. The same markdown tolerance as the readiness lines -- an optional
|
|
534
|
+
# bullet, optional bold around the label with the colon inside or outside --
|
|
535
|
+
# but the `v` is REQUIRED: L11 has not shipped the line yet, and without it any
|
|
536
|
+
# prose that opens with `Contract` and a number (`Contract 3 criteria
|
|
537
|
+
# re-checked`) would set the version (PR #156 round 1). Absent (every
|
|
538
|
+
# envelope written before L11 ships) reads as None, never as v1: the event's
|
|
539
|
+
# key is then null, the design's "absent" case, rather than a version nobody
|
|
540
|
+
# recorded.
|
|
541
|
+
_CONTRACT_RE = re.compile(
|
|
542
|
+
r"^[ \t]*(?:[-*+][ \t]+)?(?:\*\*)?[ \t]*Contract[ \t]*:?[ \t]*(?:\*\*)?"
|
|
543
|
+
r"[ \t]*:?[ \t]*(?:\*\*)?[ \t]*v(\d{1,9})\b",
|
|
544
|
+
re.MULTILINE,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def parse_contract_version(blob: object) -> "int | None":
|
|
549
|
+
"""The `Contract: v<n>` an envelope records, as an int, or None."""
|
|
550
|
+
if not isinstance(blob, str):
|
|
551
|
+
return None
|
|
552
|
+
match = _CONTRACT_RE.search(blob)
|
|
553
|
+
return None if match is None else int(match.group(1))
|
|
554
|
+
|
|
555
|
+
|
|
506
556
|
@dataclass(frozen=True)
|
|
507
557
|
class Task:
|
|
508
558
|
ref: str # TASK-<taskNumber>
|
|
@@ -805,6 +855,9 @@ class VerdictEnvelope:
|
|
|
805
855
|
envelope's `Commit-Readiness` line (issue #148), read off the same
|
|
806
856
|
evidence item; only a PLAN PR's native post reads them, and a code PR's
|
|
807
857
|
envelope simply has none.
|
|
858
|
+
|
|
859
|
+
`contract_version` is the envelope's `Contract: v<n>` (issue #155), or
|
|
860
|
+
None when it records none -- read for the `round.verdict` event only.
|
|
808
861
|
"""
|
|
809
862
|
|
|
810
863
|
verdict: str
|
|
@@ -812,6 +865,7 @@ class VerdictEnvelope:
|
|
|
812
865
|
readiness_reason: str = ""
|
|
813
866
|
commit_readiness: "str | None" = None
|
|
814
867
|
commit_readiness_reason: str = ""
|
|
868
|
+
contract_version: "int | None" = None
|
|
815
869
|
|
|
816
870
|
|
|
817
871
|
@dataclass(frozen=True)
|
|
@@ -830,11 +884,16 @@ class TaskDetail:
|
|
|
830
884
|
`verdict` is None when no envelope on the task parses -- the normal round-1
|
|
831
885
|
case, indistinguishable here from "no verdict of record yet", which is what
|
|
832
886
|
the caller wants it to mean anyway.
|
|
887
|
+
|
|
888
|
+
`envelope` is that same newest envelope whole (issue #155): the
|
|
889
|
+
`round.verdict` event of a session-closed round reads its readiness and
|
|
890
|
+
contract version off the read that already knew the verdict word.
|
|
833
891
|
"""
|
|
834
892
|
|
|
835
893
|
task: Task
|
|
836
894
|
verdicts: int
|
|
837
895
|
verdict: "str | None"
|
|
896
|
+
envelope: "VerdictEnvelope | None" = None
|
|
838
897
|
|
|
839
898
|
|
|
840
899
|
class Alissa:
|
|
@@ -879,6 +938,9 @@ class Alissa:
|
|
|
879
938
|
# One diagnostic per client for a status filter that could not be sent
|
|
880
939
|
# (see `_warn_status_filter_dropped`); this path runs every poll pass.
|
|
881
940
|
self._status_filter_warned = False
|
|
941
|
+
# The spawn-stamp probe's answer, memoized like `_task_list_flags`;
|
|
942
|
+
# None = not probed yet (or the last probe could not run).
|
|
943
|
+
self._spawn_stamps_accepted: "bool | None" = None
|
|
882
944
|
|
|
883
945
|
@property
|
|
884
946
|
def task_list_bow_id(self) -> "str | None":
|
|
@@ -1155,10 +1217,12 @@ class Alissa:
|
|
|
1155
1217
|
task = _task_from_row(data)
|
|
1156
1218
|
if task is None:
|
|
1157
1219
|
return None
|
|
1220
|
+
envelope = self._newest_envelope(data)
|
|
1158
1221
|
return TaskDetail(
|
|
1159
1222
|
task=task,
|
|
1160
1223
|
verdicts=self._count_verdicts(data),
|
|
1161
|
-
verdict=
|
|
1224
|
+
verdict=None if envelope is None else envelope.verdict,
|
|
1225
|
+
envelope=envelope,
|
|
1162
1226
|
)
|
|
1163
1227
|
except Exception: # pragma: no cover - defence in depth
|
|
1164
1228
|
log.exception("could not parse task payload for %s", ref)
|
|
@@ -1321,6 +1385,9 @@ class Alissa:
|
|
|
1321
1385
|
commit, commit_reason, _ = parse_commit_readiness(content)
|
|
1322
1386
|
if commit is None:
|
|
1323
1387
|
commit, commit_reason, _ = parse_commit_readiness(title)
|
|
1388
|
+
contract = parse_contract_version(content)
|
|
1389
|
+
if contract is None:
|
|
1390
|
+
contract = parse_contract_version(title)
|
|
1324
1391
|
found.append(
|
|
1325
1392
|
(Alissa._created_key(item.get("createdAt")),
|
|
1326
1393
|
index,
|
|
@@ -1330,6 +1397,7 @@ class Alissa:
|
|
|
1330
1397
|
readiness_reason=reason,
|
|
1331
1398
|
commit_readiness=commit,
|
|
1332
1399
|
commit_readiness_reason=commit_reason,
|
|
1400
|
+
contract_version=contract,
|
|
1333
1401
|
))
|
|
1334
1402
|
)
|
|
1335
1403
|
break
|
|
@@ -1385,6 +1453,37 @@ class Alissa:
|
|
|
1385
1453
|
break
|
|
1386
1454
|
return count
|
|
1387
1455
|
|
|
1456
|
+
# -- the `alissa tmux queue add` spawn-stamp probe -----------------------
|
|
1457
|
+
|
|
1458
|
+
def accepts_spawn_stamps(self) -> bool:
|
|
1459
|
+
"""Whether the installed `queue add` takes `--task` AND `--repo`.
|
|
1460
|
+
|
|
1461
|
+
Read off the CLI's own `--help`, for the reasons `probe_task_list`
|
|
1462
|
+
gives. An answer, yes or no, is memoized for the process; a probe that
|
|
1463
|
+
cannot run at all reads as "no" for THIS spawn and is not memoized, so
|
|
1464
|
+
a transient failure never switches the stamps off until a restart.
|
|
1465
|
+
"""
|
|
1466
|
+
if self._spawn_stamps_accepted is not None:
|
|
1467
|
+
return self._spawn_stamps_accepted
|
|
1468
|
+
try:
|
|
1469
|
+
helptext = run(["alissa", "tmux", "queue", "add", "--help"], timeout=20)
|
|
1470
|
+
except CommandError as exc:
|
|
1471
|
+
log.warning(
|
|
1472
|
+
"could not probe `alissa tmux queue add --help` (%s) — this "
|
|
1473
|
+
"reviewer is enqueued without --task/--repo", exc,
|
|
1474
|
+
)
|
|
1475
|
+
return False
|
|
1476
|
+
except Exception: # pragma: no cover - defence in depth
|
|
1477
|
+
log.exception("unexpected failure probing `alissa tmux queue add --help`")
|
|
1478
|
+
return False
|
|
1479
|
+
self._spawn_stamps_accepted = queue_add_accepts_stamps(helptext)
|
|
1480
|
+
if not self._spawn_stamps_accepted:
|
|
1481
|
+
log.info(
|
|
1482
|
+
"the installed alissa CLI does not accept `queue add --task/--repo`; "
|
|
1483
|
+
"reviewer spawns go unstamped"
|
|
1484
|
+
)
|
|
1485
|
+
return self._spawn_stamps_accepted
|
|
1486
|
+
|
|
1388
1487
|
def enqueue_reviewer(
|
|
1389
1488
|
self,
|
|
1390
1489
|
*,
|
|
@@ -1393,8 +1492,18 @@ class Alissa:
|
|
|
1393
1492
|
cwd: Path,
|
|
1394
1493
|
agent: str,
|
|
1395
1494
|
task_ref: str | None,
|
|
1495
|
+
repo: str | None = None,
|
|
1396
1496
|
dry_run: bool = False,
|
|
1397
1497
|
) -> None:
|
|
1498
|
+
"""Enqueue one reviewer session.
|
|
1499
|
+
|
|
1500
|
+
`task_ref` is the ORIGIN task (`TASK-<n>`) the round serves -- not the
|
|
1501
|
+
review task, which the directive names and the ledger records -- and
|
|
1502
|
+
`repo` the PR's `owner/name`, lowercased here. They are passed as
|
|
1503
|
+
`--task` / `--repo` only when the installed CLI accepts both (see
|
|
1504
|
+
`accepts_spawn_stamps`); an older CLI gets neither, so the enqueue
|
|
1505
|
+
never fails for want of a stamp.
|
|
1506
|
+
"""
|
|
1398
1507
|
argv = [
|
|
1399
1508
|
"alissa",
|
|
1400
1509
|
"tmux",
|
|
@@ -1406,14 +1515,21 @@ class Alissa:
|
|
|
1406
1515
|
"--cwd",
|
|
1407
1516
|
str(cwd),
|
|
1408
1517
|
]
|
|
1518
|
+
stamps: "list[str]" = []
|
|
1409
1519
|
if task_ref:
|
|
1410
|
-
|
|
1411
|
-
|
|
1520
|
+
stamps += [QUEUE_ADD_TASK_FLAG, task_ref]
|
|
1521
|
+
if repo:
|
|
1522
|
+
stamps += [QUEUE_ADD_REPO_FLAG, repo.lower()]
|
|
1412
1523
|
|
|
1413
1524
|
if dry_run:
|
|
1414
|
-
|
|
1525
|
+
# Runs nothing, the probe included: the line shows the stamps a
|
|
1526
|
+
# stamp-aware CLI would receive.
|
|
1527
|
+
log.info("[dry-run] would enqueue: %s", " ".join(argv + stamps) + " <directive>")
|
|
1415
1528
|
return
|
|
1416
1529
|
|
|
1530
|
+
if stamps and self.accepts_spawn_stamps():
|
|
1531
|
+
argv += stamps
|
|
1532
|
+
argv.append(directive)
|
|
1417
1533
|
run(argv, timeout=60)
|
|
1418
1534
|
|
|
1419
1535
|
# Reviewers are one-shot per round (CR3): once the session finishes and is
|
|
@@ -100,6 +100,10 @@ from .loop_events import (
|
|
|
100
100
|
auth_rejected_prefix,
|
|
101
101
|
build_emitter,
|
|
102
102
|
credential_identity,
|
|
103
|
+
recorded_round_verdicts,
|
|
104
|
+
round_verdict_kind,
|
|
105
|
+
round_verdict_prefix,
|
|
106
|
+
ROUND_VERDICT_PREFIX,
|
|
103
107
|
)
|
|
104
108
|
from .proc import CommandError
|
|
105
109
|
from .state import State
|
|
@@ -136,6 +140,22 @@ STALLED_DEFER_MULTIPLE = 2
|
|
|
136
140
|
# of the operator noticing nothing.
|
|
137
141
|
VERDICT_POST_GRACE_SECONDS = 5 * 60
|
|
138
142
|
|
|
143
|
+
# The `round.verdict` word for each native post event (issue #155): the
|
|
144
|
+
# verdict of record is what GitHub holds, so an approve the CI gate turned into
|
|
145
|
+
# a REQUEST_CHANGES or a COMMENT reports as that (PR #156 round 1).
|
|
146
|
+
_POSTED_VERDICT = {
|
|
147
|
+
EVENT_APPROVE: VERDICT_APPROVE,
|
|
148
|
+
EVENT_REQUEST_CHANGES: VERDICT_REQUEST_CHANGES,
|
|
149
|
+
EVENT_COMMENT: "comment",
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# How far back the round-verdict sweep looks in the spawn ledger
|
|
153
|
+
# (`sweep_round_verdicts`). A session-closed round is recorded within a poll
|
|
154
|
+
# or two of its envelope landing; the window bounds what a round whose
|
|
155
|
+
# verdict NEVER lands (a PR closed mid-round, a session that died with the
|
|
156
|
+
# request already withdrawn) costs: one task read per pass, for a day.
|
|
157
|
+
ROUND_VERDICT_SWEEP_WINDOW_SECONDS = 24 * 60 * 60
|
|
158
|
+
|
|
139
159
|
# Failed post attempts before the daemon stops treating it as transient and
|
|
140
160
|
# pages a human. It keeps retrying afterwards -- the round stays OPEN either
|
|
141
161
|
# way, which is the invariant -- but a permanent failure (a revoked token, a
|
|
@@ -1215,6 +1235,44 @@ def plan_id_of(pr: PullRequest) -> "str | None":
|
|
|
1215
1235
|
return match.group(1) if match else None
|
|
1216
1236
|
|
|
1217
1237
|
|
|
1238
|
+
# The ORIGIN task a reviewer spawn is stamped with (`--task`, issue #153; the
|
|
1239
|
+
# Studio cost meter's lane L4). First from the review task's CR2 title --
|
|
1240
|
+
# `Review PR <org>/<repo>#<n> (TASK-<origin>)` -- then from the PR body, the
|
|
1241
|
+
# way devloop's `pr_origin_task_ref` reads it (issue #149 there): the
|
|
1242
|
+
# `Alissa-Task:` trailer, then the worker's own `Origin task:` / `Origin Alissa
|
|
1243
|
+
# task:` line (both spellings are on live bodies). There is deliberately NO
|
|
1244
|
+
# `Implementation task:` fallback -- the stamp claims the session serves the
|
|
1245
|
+
# origin, and a downstream task is not one. A plan's review title carries a
|
|
1246
|
+
# plan id, not a task, so a plan PR normally resolves to None.
|
|
1247
|
+
_REVIEW_TITLE_ORIGIN_RE = re.compile(r"\(\s*TASK-(\d+)\s*\)", re.IGNORECASE)
|
|
1248
|
+
_BODY_ORIGIN_TASK_RES = tuple(
|
|
1249
|
+
re.compile(rf"{key}\**\s*:\**\s*`?TASK-(\d+)", re.IGNORECASE)
|
|
1250
|
+
for key in (r"Alissa-Task", r"Origin(?:\s+Alissa)?\s+task")
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def pr_origin_task_ref(body: str) -> "str | None":
|
|
1255
|
+
"""`TASK-<n>` for the origin task a PR body names, or None."""
|
|
1256
|
+
text = body or ""
|
|
1257
|
+
for pattern in _BODY_ORIGIN_TASK_RES:
|
|
1258
|
+
match = pattern.search(text)
|
|
1259
|
+
if match:
|
|
1260
|
+
return f"TASK-{match.group(1)}"
|
|
1261
|
+
return None
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def review_origin_task_ref(task: "Task | None", pr: PullRequest) -> "str | None":
|
|
1265
|
+
"""The origin `TASK-<n>` a reviewer round serves, or None -- in which case
|
|
1266
|
+
the spawn is stamped with `--repo` alone. The review task's title wins
|
|
1267
|
+
over the PR body: it is the one the CR2 convention writes, and a round
|
|
1268
|
+
with no review task yet (spawn_anyway) still has the body to go on."""
|
|
1269
|
+
if task is not None:
|
|
1270
|
+
match = _REVIEW_TITLE_ORIGIN_RE.search(task.title or "")
|
|
1271
|
+
if match:
|
|
1272
|
+
return f"TASK-{match.group(1)}"
|
|
1273
|
+
return pr_origin_task_ref(pr.body)
|
|
1274
|
+
|
|
1275
|
+
|
|
1218
1276
|
def plan_review_task_title(pr: PullRequest) -> str:
|
|
1219
1277
|
"""The CR2 title of a plan PR's review task (design §6.1)."""
|
|
1220
1278
|
return f"Review plan {pr.slug} ({plan_id_of(pr) or '<plan id>'})"
|
|
@@ -2212,6 +2270,10 @@ class ResolvedTask:
|
|
|
2212
2270
|
verdicts: int = 0
|
|
2213
2271
|
verdict: "str | None" = None
|
|
2214
2272
|
verdict_read: bool = False
|
|
2273
|
+
# The newest envelope whole, when the resolution read a task detail
|
|
2274
|
+
# (issue #155): the session-closed round's `round.verdict` reads its
|
|
2275
|
+
# readiness and contract version off it. None on the bare-count fallback.
|
|
2276
|
+
envelope: "VerdictEnvelope | None" = None
|
|
2215
2277
|
|
|
2216
2278
|
@classmethod
|
|
2217
2279
|
def from_detail(cls, detail: TaskDetail) -> "ResolvedTask":
|
|
@@ -2226,6 +2288,7 @@ class ResolvedTask:
|
|
|
2226
2288
|
verdicts=detail.verdicts,
|
|
2227
2289
|
verdict=detail.verdict,
|
|
2228
2290
|
verdict_read=True,
|
|
2291
|
+
envelope=detail.envelope,
|
|
2229
2292
|
)
|
|
2230
2293
|
|
|
2231
2294
|
def newest_verdict(self, alissa: Alissa) -> "str | None":
|
|
@@ -2950,6 +3013,12 @@ class ReviewWatcher:
|
|
|
2950
3013
|
# decides nothing -- it reports; and it must run on the pass that
|
|
2951
3014
|
# converges, since a converged PR leaves the search set.
|
|
2952
3015
|
self._observe_session_readiness(pr, my_reviews, completed)
|
|
3016
|
+
# Same placement, same reason (issue #155): a session-closed round's
|
|
3017
|
+
# verdict of record is recorded for `round.verdict` on the pass that
|
|
3018
|
+
# sees it -- including the pass that converges.
|
|
3019
|
+
self._observe_round_verdict(
|
|
3020
|
+
pr, resolved, my_reviews, completed, task is not None and owed <= native,
|
|
3021
|
+
)
|
|
2953
3022
|
|
|
2954
3023
|
if task is not None and owed > native:
|
|
2955
3024
|
# Terminal for this pass either way. On a landed post the review
|
|
@@ -4251,6 +4320,21 @@ class ReviewWatcher:
|
|
|
4251
4320
|
)
|
|
4252
4321
|
class_term = readiness_class_term(posted_value, posted_class)
|
|
4253
4322
|
readiness_name = "commit-readiness" if plan else "readiness"
|
|
4323
|
+
# The verdict of record, for the `round.verdict` event (issue #155):
|
|
4324
|
+
# what the posted review CARRIES -- the readiness read back off the
|
|
4325
|
+
# emitted trailer, so an approve whose envelope had no line reads
|
|
4326
|
+
# `operator` with the unclassed marker, exactly what the merge edge
|
|
4327
|
+
# will see -- and nothing on a degraded or request_changes post. The
|
|
4328
|
+
# verdict word is the POSTED event's, not the envelope's: an approve
|
|
4329
|
+
# the CI gate downgraded is a `request_changes` or `comment` on
|
|
4330
|
+
# GitHub, and an event saying `approve` would count an approval the
|
|
4331
|
+
# merge edge never acts on (PR #156 round 1).
|
|
4332
|
+
self._record_round_verdict(
|
|
4333
|
+
pr, round_, judged, _POSTED_VERDICT.get(event, verdict),
|
|
4334
|
+
posted_value if trailer else None, posted_class,
|
|
4335
|
+
envelope.contract_version if envelope is not None else None,
|
|
4336
|
+
task.ref,
|
|
4337
|
+
)
|
|
4254
4338
|
readiness_note = (
|
|
4255
4339
|
f" {readiness_name}={readiness_value}{class_term}" if trailer else ""
|
|
4256
4340
|
)
|
|
@@ -4937,6 +5021,208 @@ class ReviewWatcher:
|
|
|
4937
5021
|
deferred=True,
|
|
4938
5022
|
)
|
|
4939
5023
|
|
|
5024
|
+
def _record_round_verdict(
|
|
5025
|
+
self,
|
|
5026
|
+
pr: PullRequest,
|
|
5027
|
+
round_: int,
|
|
5028
|
+
head: str,
|
|
5029
|
+
verdict: "str | None",
|
|
5030
|
+
readiness: "str | None",
|
|
5031
|
+
klass: "str | None",
|
|
5032
|
+
contract_version: "int | None",
|
|
5033
|
+
task_ref: "str | None",
|
|
5034
|
+
) -> bool:
|
|
5035
|
+
"""Record round `round_`'s verdict of record ONCE, as the ledger row
|
|
5036
|
+
the `round.verdict` loop event derives from (issue #155; see
|
|
5037
|
+
loop_events.round_verdict_kind).
|
|
5038
|
+
|
|
5039
|
+
Once per (PR, round): the first record wins, whichever path wrote it
|
|
5040
|
+
-- the native post records at post time, a session-closed round on
|
|
5041
|
+
the first pass that sees it. Never under `--dry-run`, which shares
|
|
5042
|
+
the production ledger and must leave no durable row behind (see
|
|
5043
|
+
`_warn_identity_drift`). True when THIS call wrote the row.
|
|
5044
|
+
"""
|
|
5045
|
+
if self.config.dry_run:
|
|
5046
|
+
return False
|
|
5047
|
+
prefix = round_verdict_prefix(round_)
|
|
5048
|
+
if any(
|
|
5049
|
+
row["repo"] == pr.full_name and int(row["number"]) == pr.number
|
|
5050
|
+
for row in self.state.read_pings(kind_prefix=prefix)
|
|
5051
|
+
):
|
|
5052
|
+
return False
|
|
5053
|
+
self.state.record_ping(
|
|
5054
|
+
pr.full_name, pr.number,
|
|
5055
|
+
round_verdict_kind(
|
|
5056
|
+
round_, head, verdict, readiness, klass, contract_version, task_ref,
|
|
5057
|
+
),
|
|
5058
|
+
)
|
|
5059
|
+
return True
|
|
5060
|
+
|
|
5061
|
+
def _observe_round_verdict(
|
|
5062
|
+
self,
|
|
5063
|
+
pr: PullRequest,
|
|
5064
|
+
resolved: "ResolvedTask",
|
|
5065
|
+
my_reviews: list[Review],
|
|
5066
|
+
round_: int,
|
|
5067
|
+
closed_by_session: bool,
|
|
5068
|
+
) -> bool:
|
|
5069
|
+
"""Record a SESSION-closed round's verdict of record (issue #155).
|
|
5070
|
+
True when this call recorded it.
|
|
5071
|
+
|
|
5072
|
+
The round is `round_` (the envelopes counted on the review task) and
|
|
5073
|
+
it is the session's when its own review exists on GitHub
|
|
5074
|
+
(`closed_by_session`: the native count has caught up with the
|
|
5075
|
+
envelopes) and the daemon neither posted nor abandoned a fallback for
|
|
5076
|
+
it. The verdict, the readiness and the contract version come from the
|
|
5077
|
+
envelope -- the verdict of record (D3) -- with the readiness mapped
|
|
5078
|
+
exactly as the native post maps it (readiness_trailer, read back with
|
|
5079
|
+
the trailer grammar): an approve whose envelope names none is
|
|
5080
|
+
`operator`, unclassed. A request_changes carries no readiness. The
|
|
5081
|
+
head is the session's own newest review's commit, else the head the
|
|
5082
|
+
round was queued against (`_round_review_head`).
|
|
5083
|
+
|
|
5084
|
+
Reached from two places, because a session's own review consumes the
|
|
5085
|
+
review request and so drops its PR out of the search that feeds
|
|
5086
|
+
evaluate() at exactly the moment its round closes: evaluate() for a
|
|
5087
|
+
PR still in the search, and `sweep_round_verdicts` -- search-
|
|
5088
|
+
independent, off the spawn ledger -- for every other one (PR #156
|
|
5089
|
+
round 1). Once per round either way (`_record_round_verdict`).
|
|
5090
|
+
|
|
5091
|
+
Observation only: nothing is posted or narrated, and a resolution
|
|
5092
|
+
that did not read the envelope (the bare-count fallback) records
|
|
5093
|
+
nothing this pass -- the next pass reads it on the cached path.
|
|
5094
|
+
"""
|
|
5095
|
+
envelope = resolved.envelope
|
|
5096
|
+
if (
|
|
5097
|
+
not closed_by_session
|
|
5098
|
+
or resolved.task is None
|
|
5099
|
+
or envelope is None
|
|
5100
|
+
or round_ < 1
|
|
5101
|
+
or envelope.verdict not in (VERDICT_APPROVE, VERDICT_REQUEST_CHANGES)
|
|
5102
|
+
):
|
|
5103
|
+
return False
|
|
5104
|
+
row = self.state.get_verdict_post(pr.full_name, pr.number, round_)
|
|
5105
|
+
if row is not None and (row["posted_at"] or row["abandoned_at"]):
|
|
5106
|
+
return False # the native path's round: recorded there, or released
|
|
5107
|
+
readiness = klass = None
|
|
5108
|
+
if envelope.verdict == VERDICT_APPROVE:
|
|
5109
|
+
if is_plan_pr(self.config, pr):
|
|
5110
|
+
readiness, _, klass = parse_commit_trailer(commit_readiness_trailer(envelope))
|
|
5111
|
+
else:
|
|
5112
|
+
readiness, _, klass = parse_trailer(readiness_trailer(envelope))
|
|
5113
|
+
head = self._round_review_head(pr, my_reviews, round_)
|
|
5114
|
+
return self._record_round_verdict(
|
|
5115
|
+
pr, round_, head, envelope.verdict, readiness, klass,
|
|
5116
|
+
envelope.contract_version, resolved.task.ref,
|
|
5117
|
+
)
|
|
5118
|
+
|
|
5119
|
+
def _round_review_head(
|
|
5120
|
+
self, pr: PullRequest, my_reviews: list[Review], round_: int
|
|
5121
|
+
) -> str:
|
|
5122
|
+
"""The commit round `round_`'s session review judged.
|
|
5123
|
+
|
|
5124
|
+
NOT simply the newest review (PR #156 round 1): on the first pass after
|
|
5125
|
+
an upgrade, and on any pass where round k+1's session has submitted its
|
|
5126
|
+
review but not yet written its envelope, the envelope count still says
|
|
5127
|
+
k while the newest review is k+1's -- and round k would be keyed on
|
|
5128
|
+
k+1's commit. Nor "the newest review on the queued head": a round
|
|
5129
|
+
re-requested without a push shares its queued head with the round
|
|
5130
|
+
before it, whose review is then the match.
|
|
5131
|
+
|
|
5132
|
+
So it is the review `countable_rounds` counts as this round's -- the
|
|
5133
|
+
first one whose prefix of the list reaches it -- discounting the
|
|
5134
|
+
abandoned rounds, which hold an envelope and no review. When no prefix
|
|
5135
|
+
reaches it (the reviews list lags), the head the round was queued
|
|
5136
|
+
against (`_judged_head`).
|
|
5137
|
+
"""
|
|
5138
|
+
target = round_ - self.state.abandoned_rounds(pr.full_name, pr.number)
|
|
5139
|
+
for i, rev in enumerate(my_reviews if target >= 1 else []):
|
|
5140
|
+
if countable_rounds(my_reviews[:i + 1]) >= target:
|
|
5141
|
+
if rev.commit_id:
|
|
5142
|
+
return rev.commit_id
|
|
5143
|
+
break
|
|
5144
|
+
return self._judged_head(pr, round_)
|
|
5145
|
+
|
|
5146
|
+
def sweep_round_verdicts(self, searched: "set[tuple[str, int]]") -> int:
|
|
5147
|
+
"""Record the session-closed rounds the search can no longer see.
|
|
5148
|
+
Runs every poll, after the per-PR evaluate loop. Returns the number of
|
|
5149
|
+
rounds recorded this pass.
|
|
5150
|
+
|
|
5151
|
+
evaluate() is fed by the review-requested:@me search, and a reviewer
|
|
5152
|
+
session's own review CLEARS that request -- so a round the session
|
|
5153
|
+
closed leaves the search at the moment its verdict lands. Recording it
|
|
5154
|
+
only from evaluate() deferred round k to the re-request of round k+1
|
|
5155
|
+
and never recorded a terminal round at all: the final approve, a
|
|
5156
|
+
request_changes nobody re-requests, a cap-out (PR #156 round 1, the
|
|
5157
|
+
same trap the reap sweep's docstring names). This sweep is search-
|
|
5158
|
+
independent for the same reason the reap sweep is: it starts from the
|
|
5159
|
+
spawn ledger, which cannot lose a round.
|
|
5160
|
+
|
|
5161
|
+
Candidates: the NEWEST spawn row of each PR, spawned inside
|
|
5162
|
+
ROUND_VERDICT_SWEEP_WINDOW_SECONDS, whose round has no `round-verdict:`
|
|
5163
|
+
row yet, and whose PR this pass's search did not return (evaluate()
|
|
5164
|
+
already looked at those). Older rounds of a PR were recorded by the
|
|
5165
|
+
evaluate() pass that spawned their successor, which the search fed.
|
|
5166
|
+
|
|
5167
|
+
Cost, per candidate per pass: one task read by the ledger's task ref --
|
|
5168
|
+
the reap sweep's choice, for its reason: find_review_task's open-status
|
|
5169
|
+
filter loses a review task validated right after the final approve,
|
|
5170
|
+
which is precisely the round this sweep exists for. Only when that read
|
|
5171
|
+
shows the round's envelope landed does the sweep fetch the PR and its
|
|
5172
|
+
reviews (two calls), and then the round is recorded and stops being a
|
|
5173
|
+
candidate. A spawn row with no task ref (a round 1 recorded before the
|
|
5174
|
+
review task existed) falls back to the cached PR -> task mapping, and
|
|
5175
|
+
is skipped when there is none.
|
|
5176
|
+
|
|
5177
|
+
Not gated on `watches`: the round was spawned by this daemon, and a
|
|
5178
|
+
repo dropping out of the allowlist after its final approve must not
|
|
5179
|
+
lose the verdict. Never in `--dry-run` (nothing would be recorded).
|
|
5180
|
+
Best-effort: a failed read skips the candidate until the next pass.
|
|
5181
|
+
"""
|
|
5182
|
+
if self.config.dry_run:
|
|
5183
|
+
return 0
|
|
5184
|
+
since = time.time() - ROUND_VERDICT_SWEEP_WINDOW_SECONDS
|
|
5185
|
+
newest: dict[tuple[str, int], dict] = {}
|
|
5186
|
+
for row in self.state.read_spawns(since=since):
|
|
5187
|
+
key = (row["repo"], int(row["number"]))
|
|
5188
|
+
if key not in newest or int(row["round"]) > int(newest[key]["round"]):
|
|
5189
|
+
newest[key] = row
|
|
5190
|
+
recorded = recorded_round_verdicts(
|
|
5191
|
+
self.state.read_pings(kind_prefix=ROUND_VERDICT_PREFIX)
|
|
5192
|
+
)
|
|
5193
|
+
count = 0
|
|
5194
|
+
for (full_name, number), row in sorted(newest.items()):
|
|
5195
|
+
round_ = int(row["round"])
|
|
5196
|
+
if (full_name, number) in searched or (full_name, number, round_) in recorded:
|
|
5197
|
+
continue
|
|
5198
|
+
ref = row["task_ref"] or self.state.review_task(full_name, number)
|
|
5199
|
+
if not ref or "/" not in full_name:
|
|
5200
|
+
continue
|
|
5201
|
+
owner, repo = full_name.split("/", 1)
|
|
5202
|
+
try:
|
|
5203
|
+
detail = self.alissa.get_task(ref)
|
|
5204
|
+
if detail is None or detail.verdicts < round_:
|
|
5205
|
+
continue # unreadable, or the round's envelope has not landed
|
|
5206
|
+
pr = self.github.pull_request(owner, repo, number)
|
|
5207
|
+
my_reviews = self.github.my_reviews(owner, repo, number)
|
|
5208
|
+
except RateLimited:
|
|
5209
|
+
raise
|
|
5210
|
+
except CommandError as exc:
|
|
5211
|
+
log.debug("round-verdict sweep: %s#%d skipped: %s", full_name, number, exc)
|
|
5212
|
+
continue
|
|
5213
|
+
resolved = ResolvedTask.from_detail(detail)
|
|
5214
|
+
owed = resolved.verdicts - self.state.abandoned_rounds(full_name, number)
|
|
5215
|
+
if self._observe_round_verdict(
|
|
5216
|
+
pr, resolved, my_reviews, resolved.verdicts,
|
|
5217
|
+
owed <= countable_rounds(my_reviews),
|
|
5218
|
+
):
|
|
5219
|
+
log.info(
|
|
5220
|
+
"%s round %d: session-closed verdict recorded off-search",
|
|
5221
|
+
pr.slug, resolved.verdicts,
|
|
5222
|
+
)
|
|
5223
|
+
count += 1
|
|
5224
|
+
return count
|
|
5225
|
+
|
|
4940
5226
|
def _observe_session_readiness(
|
|
4941
5227
|
self, pr: PullRequest, my_reviews: list[Review], round_: int
|
|
4942
5228
|
) -> None:
|
|
@@ -5851,7 +6137,8 @@ class ReviewWatcher:
|
|
|
5851
6137
|
directive=directive,
|
|
5852
6138
|
cwd=hub,
|
|
5853
6139
|
agent=self.config.agent_profile,
|
|
5854
|
-
task_ref=task
|
|
6140
|
+
task_ref=review_origin_task_ref(task, pr),
|
|
6141
|
+
repo=pr.full_name,
|
|
5855
6142
|
dry_run=self.config.dry_run,
|
|
5856
6143
|
)
|
|
5857
6144
|
|
|
@@ -7225,6 +7512,19 @@ class ReviewWatcher:
|
|
|
7225
7512
|
|
|
7226
7513
|
self._note_deferrals(results)
|
|
7227
7514
|
|
|
7515
|
+
# AFTER the evaluate loop (which records the rounds of every PR the
|
|
7516
|
+
# search returned) and BEFORE the loop-events push, so a round the
|
|
7517
|
+
# sweep records this pass is emitted this pass. Search-independent on
|
|
7518
|
+
# purpose: see sweep_round_verdicts. Telemetry, so never fatal to the
|
|
7519
|
+
# pass -- the same promise _emit_loop_events keeps -- except for the
|
|
7520
|
+
# rate limit, whose backoff belongs to run_forever.
|
|
7521
|
+
try:
|
|
7522
|
+
self.sweep_round_verdicts(live_keys)
|
|
7523
|
+
except RateLimited:
|
|
7524
|
+
raise
|
|
7525
|
+
except Exception:
|
|
7526
|
+
log.warning("round-verdict sweep failed; retried next pass", exc_info=True)
|
|
7527
|
+
|
|
7228
7528
|
# Persist one poll_snapshots row per pass, built entirely from the
|
|
7229
7529
|
# Decision list already in hand plus the reap count -- no new GitHub
|
|
7230
7530
|
# calls. Written in dry-run too: a snapshot OBSERVES the pass, it is
|
|
@@ -76,6 +76,21 @@ round) -- `round.spawned`, `round.verdict`, `round.abandoned`,
|
|
|
76
76
|
`round.capped` and both gates' `checks.held` -- carries `data.kind: "plan"`.
|
|
77
77
|
A code round carries no `kind` at all, so every pre-existing payload is
|
|
78
78
|
unchanged byte for byte.
|
|
79
|
+
|
|
80
|
+
``round.verdict`` is posted for EVERY verdict of record (issue #155; Studio
|
|
81
|
+
design `loop-operator-surface.md` §1.3, §6.4, lane L10), not only for the
|
|
82
|
+
rounds the daemon closed with its native fallback post. The fact is again a
|
|
83
|
+
ledger ROW: when a round's verdict of record exists -- the daemon's own
|
|
84
|
+
native post landed, or the session's own review closed the round and the
|
|
85
|
+
review task's envelope says what it decided -- the watcher records ONE
|
|
86
|
+
`round-verdict:` ping for that (PR, round) (`round_verdict_kind`), carrying
|
|
87
|
+
the verdict word, the head, the readiness the approve carries and its class,
|
|
88
|
+
the envelope's `Contract: v<n>` and the review task's ref. Every
|
|
89
|
+
`round.verdict` has the same six data keys -- `verdict`, `headSha`,
|
|
90
|
+
`readiness`, `readinessClass`, `contractVersion`, `taskRef` -- null when
|
|
91
|
+
the ledger does not know one, and is keyed on `(repo, pr, round, head)`, so a
|
|
92
|
+
native post's `verdict_posts` row and its ping derive ONE event (the row's,
|
|
93
|
+
with the ping folded in), never two.
|
|
79
94
|
"""
|
|
80
95
|
|
|
81
96
|
from __future__ import annotations
|
|
@@ -133,6 +148,30 @@ ROUND_EVENT_KINDS = frozenset({
|
|
|
133
148
|
"checks.held",
|
|
134
149
|
})
|
|
135
150
|
|
|
151
|
+
# The verdict-of-record row (issue #155): one per (PR, round),
|
|
152
|
+
# `round-verdict:<round>@<head>:<verdict>:<readiness>:<class>:<contract>:<taskRef>`.
|
|
153
|
+
# The round leads so "already recorded for this round" is one prefix read
|
|
154
|
+
# (`round_verdict_prefix`); every later field is drawn from a closed
|
|
155
|
+
# vocabulary or a ref/sha the daemon itself holds, and is empty when unknown.
|
|
156
|
+
# `class` is the READINESS_CLASSES token, or ROUND_VERDICT_UNCLASSED on an
|
|
157
|
+
# operator judgment that names none -- the marker the merge policy holds on.
|
|
158
|
+
ROUND_VERDICT_PREFIX = "round-verdict:"
|
|
159
|
+
_ROUND_VERDICT_RE = re.compile(
|
|
160
|
+
r"^round-verdict:(?P<round>\d+)@(?P<head>[^:@]*):(?P<verdict>[a-z_]*):"
|
|
161
|
+
r"(?P<readiness>[a-z]*):(?P<klass>[a-z-]*):(?P<contract>\d*):"
|
|
162
|
+
r"(?P<task>[A-Za-z0-9_-]*)$"
|
|
163
|
+
)
|
|
164
|
+
ROUND_VERDICT_UNCLASSED = "unclassed"
|
|
165
|
+
# Each field's own vocabulary, the same character classes the regex above
|
|
166
|
+
# reads back -- so a value the writer accepts is one the reader parses.
|
|
167
|
+
_ROUND_VERDICT_FIELDS = {
|
|
168
|
+
"head": re.compile(r"^[^:@]*$"),
|
|
169
|
+
"verdict": re.compile(r"^[a-z_]*$"),
|
|
170
|
+
"klass": re.compile(r"^[a-z-]*$"),
|
|
171
|
+
"contract": re.compile(r"^\d*$"),
|
|
172
|
+
"task": re.compile(r"^[A-Za-z0-9_-]*$"),
|
|
173
|
+
}
|
|
174
|
+
|
|
136
175
|
# The `auth.rejected` row (issue #146): one per SESSION,
|
|
137
176
|
# `auth-rejected:<session>#<round>:<source>:<matched>:<envName>:<suffix>`.
|
|
138
177
|
# The session leads so "already recorded for this session" is one prefix read
|
|
@@ -216,6 +255,91 @@ def auth_rejected_key(session: str) -> str:
|
|
|
216
255
|
return f"{SEAT}:auth.rejected:{session}"
|
|
217
256
|
|
|
218
257
|
|
|
258
|
+
def round_verdict_prefix(round_: int) -> str:
|
|
259
|
+
"""The prefix every `round-verdict:` row of ONE round starts with -- the
|
|
260
|
+
watcher's "already recorded" read, scoped to the PR by the row's own
|
|
261
|
+
(repo, number) columns."""
|
|
262
|
+
return f"{ROUND_VERDICT_PREFIX}{int(round_)}@"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def round_verdict_kind(
|
|
266
|
+
round_: int,
|
|
267
|
+
head: "str | None",
|
|
268
|
+
verdict: "str | None",
|
|
269
|
+
readiness: "str | None",
|
|
270
|
+
klass: "str | None",
|
|
271
|
+
contract_version: "int | None",
|
|
272
|
+
task_ref: "str | None",
|
|
273
|
+
) -> str:
|
|
274
|
+
"""The ledger kind of ONE verdict of record (see ROUND_VERDICT_PREFIX).
|
|
275
|
+
A field outside its vocabulary is written empty -- the row is the
|
|
276
|
+
event's source and its grammar is closed -- and a class on anything but
|
|
277
|
+
an `operator` readiness is dropped (`auto` never carries one)."""
|
|
278
|
+
def field(value: object, name: str) -> str:
|
|
279
|
+
text = "" if value is None else str(value)
|
|
280
|
+
return text if _ROUND_VERDICT_FIELDS[name].fullmatch(text) else ""
|
|
281
|
+
|
|
282
|
+
readiness_text = readiness if readiness in ("auto", "operator") else ""
|
|
283
|
+
klass_text = (
|
|
284
|
+
field(klass or ROUND_VERDICT_UNCLASSED, "klass")
|
|
285
|
+
if readiness_text == "operator" else ""
|
|
286
|
+
)
|
|
287
|
+
contract = "" if contract_version is None else field(int(contract_version), "contract")
|
|
288
|
+
return (
|
|
289
|
+
f"{round_verdict_prefix(round_)}{field(head, 'head')}:"
|
|
290
|
+
f"{field(verdict, 'verdict')}:{readiness_text}:{klass_text}:"
|
|
291
|
+
f"{contract}:{field(task_ref, 'task')}"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _round_verdicts(
|
|
296
|
+
pings: "list[dict]",
|
|
297
|
+
) -> "dict[tuple[str, int, int], tuple[int, dict]]":
|
|
298
|
+
"""Every parseable `round-verdict:` row, by (repo, PR, round), as
|
|
299
|
+
(pinged_at, the parsed fields). One row per round by construction (the
|
|
300
|
+
watcher checks the prefix first); should two ever exist, the OLDEST --
|
|
301
|
+
the first record of the round -- wins."""
|
|
302
|
+
out: "dict[tuple[str, int, int], tuple[int, dict]]" = {}
|
|
303
|
+
for row in pings:
|
|
304
|
+
match = _ROUND_VERDICT_RE.match(str(row["kind"]))
|
|
305
|
+
if match is None:
|
|
306
|
+
continue
|
|
307
|
+
key = (row["repo"], int(row["number"]), int(match.group("round")))
|
|
308
|
+
at = int(row["pinged_at"])
|
|
309
|
+
if key in out and out[key][0] <= at:
|
|
310
|
+
continue
|
|
311
|
+
out[key] = (at, match.groupdict())
|
|
312
|
+
return out
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def recorded_round_verdicts(pings: "list[dict]") -> "set[tuple[str, int, int]]":
|
|
316
|
+
"""The (repo, PR, round) of every parseable `round-verdict:` row -- the
|
|
317
|
+
round-verdict sweep's "already recorded" read, one ledger read per pass."""
|
|
318
|
+
return set(_round_verdicts(pings))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _verdict_data(
|
|
322
|
+
verdict: "str | None", head: str, fields: "dict | None"
|
|
323
|
+
) -> "dict[str, Any]":
|
|
324
|
+
"""The six keys every `round.verdict` carries (issue #155), null where
|
|
325
|
+
the ledger does not know one. `fields` is the round's parsed
|
|
326
|
+
`round-verdict:` row, or None (a `verdict_posts` row that predates it)."""
|
|
327
|
+
fields = fields or {}
|
|
328
|
+
contract = fields.get("contract") or ""
|
|
329
|
+
# The ping's word first: it is what the posted review carries, where the
|
|
330
|
+
# `verdict_posts` row stores the envelope's (an approve the CI gate
|
|
331
|
+
# downgraded is not an approve on GitHub -- PR #156 round 1). The row's
|
|
332
|
+
# word is the fallback for a row that predates the ping.
|
|
333
|
+
return {
|
|
334
|
+
"verdict": fields.get("verdict") or verdict or None,
|
|
335
|
+
"headSha": head,
|
|
336
|
+
"readiness": fields.get("readiness") or None,
|
|
337
|
+
"readinessClass": fields.get("klass") or None,
|
|
338
|
+
"contractVersion": int(contract) if contract else None,
|
|
339
|
+
"taskRef": fields.get("task") or None,
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
219
343
|
def _ms(seconds: "int | float") -> int:
|
|
220
344
|
"""A ledger stamp (epoch seconds) as the API's epoch-ms `at`."""
|
|
221
345
|
return int(seconds) * 1000
|
|
@@ -280,31 +404,47 @@ def _spawn_events(rows: "list[dict]") -> "list[tuple[int, dict]]":
|
|
|
280
404
|
return out
|
|
281
405
|
|
|
282
406
|
|
|
283
|
-
def _verdict_events(
|
|
284
|
-
""
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
407
|
+
def _verdict_events(
|
|
408
|
+
rows: "list[dict]", pings: "list[dict] | None" = None
|
|
409
|
+
) -> "list[tuple[int, dict]]":
|
|
410
|
+
"""`round.verdict` for every verdict of record, `round.abandoned` for
|
|
411
|
+
abandoned native posts.
|
|
412
|
+
|
|
413
|
+
Two sources, one event per (repo, PR, round, head) (issue #155):
|
|
414
|
+
|
|
415
|
+
* a POSTED `verdict_posts` row -- the daemon's native fallback closed the
|
|
416
|
+
round -- derives the event at `posted_at`, with the round's
|
|
417
|
+
`round-verdict:` row folded in for readiness, class, contract and task
|
|
418
|
+
(a row that predates that ping carries them null) plus the fallback's
|
|
419
|
+
own `reviewUrl`, `attempts` and `checksHeldMs`;
|
|
420
|
+
* a `round-verdict:` row with no posted `verdict_posts` row for its round
|
|
421
|
+
-- the SESSION's own review closed it -- derives the event at the ping's
|
|
422
|
+
stamp. A round whose native post was ABANDONED derives no verdict from
|
|
423
|
+
its ping: the round was released, not judged.
|
|
424
|
+
|
|
425
|
+
A `verdict_posts` row with neither `posted_at` nor `abandoned_at` is an
|
|
426
|
+
OPEN obligation -- the round's native verdict has not landed -- and emits
|
|
427
|
+
nothing until it closes one way or the other. `data.verdict` is null
|
|
428
|
+
(never invented) on rows that predate the ledger's verdict column.
|
|
429
|
+
|
|
430
|
+
COVERAGE BOUND: the session-closed rounds are recorded from the pass that
|
|
431
|
+
first sees them, so rounds closed before the daemon ran a version with
|
|
432
|
+
the `round-verdict:` ping (0.31.10) are not backfilled -- the ledger
|
|
433
|
+
never recorded them."""
|
|
434
|
+
verdicts = _round_verdicts(pings or [])
|
|
299
435
|
out = []
|
|
436
|
+
closed: "set[tuple[str, int, int]]" = set()
|
|
300
437
|
for row in rows:
|
|
301
438
|
repo, number = row["repo"], int(row["number"])
|
|
302
439
|
round_, head = int(row["round"]), row["head_sha"] or ""
|
|
440
|
+
if row.get("posted_at") or row.get("abandoned_at"):
|
|
441
|
+
closed.add((repo, number, round_))
|
|
303
442
|
if row.get("posted_at"):
|
|
304
443
|
posted = int(row["posted_at"])
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
444
|
+
pinged = verdicts.get((repo, number, round_))
|
|
445
|
+
data: dict[str, Any] = _verdict_data(
|
|
446
|
+
row.get("verdict"), head, None if pinged is None else pinged[1]
|
|
447
|
+
)
|
|
308
448
|
if row.get("review_url"):
|
|
309
449
|
data["reviewUrl"] = row["review_url"]
|
|
310
450
|
data["attempts"] = int(row.get("attempts") or 0)
|
|
@@ -331,6 +471,19 @@ def _verdict_events(rows: "list[dict]") -> "list[tuple[int, dict]]":
|
|
|
331
471
|
reason=row.get("last_error") or None,
|
|
332
472
|
data={"headSha": head},
|
|
333
473
|
))
|
|
474
|
+
for (repo, number, round_), (at, fields) in verdicts.items():
|
|
475
|
+
if (repo, number, round_) in closed:
|
|
476
|
+
continue
|
|
477
|
+
head = fields["head"]
|
|
478
|
+
out.append(_event(
|
|
479
|
+
"round.verdict",
|
|
480
|
+
at,
|
|
481
|
+
f"revloop:round.verdict:{repo}:{number}:{round_}:{head}",
|
|
482
|
+
repo=repo,
|
|
483
|
+
pr=number,
|
|
484
|
+
round_=round_,
|
|
485
|
+
data=_verdict_data(None, head, fields),
|
|
486
|
+
))
|
|
334
487
|
return out
|
|
335
488
|
|
|
336
489
|
|
|
@@ -385,7 +538,9 @@ def _ping_events(
|
|
|
385
538
|
`kind` is free-form text carrying the episode identity, so it is parsed
|
|
386
539
|
by prefix; kinds this module does not report (`activity-deferred:`,
|
|
387
540
|
`capout:`, `checks-hold:`, `verdict-post-failed:` — each the dedupe of a
|
|
388
|
-
GitHub-side comment, not a fact of its own) derive nothing
|
|
541
|
+
GitHub-side comment, not a fact of its own) derive nothing here, and
|
|
542
|
+
`round-verdict:` rows derive through `_verdict_events`, which joins them
|
|
543
|
+
with the native posts of the same round.
|
|
389
544
|
|
|
390
545
|
A stability event's payload is split by provenance, deliberately
|
|
391
546
|
(PR #113 round 1, minor). `data.headSha` and `data.grantsSeen` come from
|
|
@@ -609,9 +764,9 @@ def derive_events(state: State, *, since: int = 0) -> "list[dict]":
|
|
|
609
764
|
|
|
610
765
|
stamped: "list[tuple[int, dict]]" = []
|
|
611
766
|
stamped += _spawn_events(spawns)
|
|
612
|
-
stamped += _verdict_events(state.read_verdict_posts())
|
|
613
|
-
stamped += _capped_events(state.read_escalations(), spawns)
|
|
614
767
|
pings = state.read_pings()
|
|
768
|
+
stamped += _verdict_events(state.read_verdict_posts(), pings)
|
|
769
|
+
stamped += _capped_events(state.read_escalations(), spawns)
|
|
615
770
|
stamped += _ping_events(pings, notices)
|
|
616
771
|
stamped += _spawn_hold_events(state.read_spawn_checks_holds())
|
|
617
772
|
stamped += _grant_events(state.read_grants())
|
|
@@ -1895,9 +1895,14 @@ class State:
|
|
|
1895
1895
|
limit: int | None = None,
|
|
1896
1896
|
*,
|
|
1897
1897
|
sessions: "Iterable[str] | None" = None,
|
|
1898
|
+
since: "float | None" = None,
|
|
1898
1899
|
) -> list[dict]:
|
|
1899
1900
|
"""Spawn rows (one per enqueued reviewer round), newest first.
|
|
1900
1901
|
|
|
1902
|
+
`since` keeps only rows spawned at or after that epoch second -- the
|
|
1903
|
+
round-verdict sweep's window (loop.sweep_round_verdicts), which wants
|
|
1904
|
+
the recent rounds and not the whole table every poll.
|
|
1905
|
+
|
|
1901
1906
|
`sessions` restricts the read to those session names. That is the
|
|
1902
1907
|
bound the console's session->round pairing wants, and a recency bound
|
|
1903
1908
|
(row count or time window) is NOT: a stale round whose session is
|
|
@@ -1913,12 +1918,18 @@ class State:
|
|
|
1913
1918
|
"FROM spawns"
|
|
1914
1919
|
)
|
|
1915
1920
|
params: tuple = ()
|
|
1921
|
+
where: list[str] = []
|
|
1916
1922
|
if sessions is not None:
|
|
1917
1923
|
names = tuple(sessions)
|
|
1918
1924
|
if not names:
|
|
1919
1925
|
return []
|
|
1920
|
-
|
|
1926
|
+
where.append("session IN (%s)" % ",".join("?" * len(names)))
|
|
1921
1927
|
params = names
|
|
1928
|
+
if since is not None:
|
|
1929
|
+
where.append("spawned_at >= ?")
|
|
1930
|
+
params += (int(since),)
|
|
1931
|
+
if where:
|
|
1932
|
+
sql += " WHERE " + " AND ".join(where)
|
|
1922
1933
|
sql += " ORDER BY spawned_at DESC, number DESC, round DESC"
|
|
1923
1934
|
return self._read_rows(sql, limit, params)
|
|
1924
1935
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.31.10
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.31.8
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|