alissa-tools-github-revloop 0.31.9__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.9/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.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/alissa.py +42 -1
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/loop.py +261 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/loop_events.py +177 -22
- {alissa_tools_github_revloop-0.31.9 → 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.9 → 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.9/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/README.md +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/setup.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/config.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/trust.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.31.9 → 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.9 → 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.9 → 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.9 → alissa_tools_github_revloop-0.31.10}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -527,6 +527,32 @@ def parse_commit_readiness(blob: object) -> "tuple[str | None, str, str | None]"
|
|
|
527
527
|
return _commit_classed(match.group(1), match.group(2))
|
|
528
528
|
|
|
529
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
|
+
|
|
530
556
|
@dataclass(frozen=True)
|
|
531
557
|
class Task:
|
|
532
558
|
ref: str # TASK-<taskNumber>
|
|
@@ -829,6 +855,9 @@ class VerdictEnvelope:
|
|
|
829
855
|
envelope's `Commit-Readiness` line (issue #148), read off the same
|
|
830
856
|
evidence item; only a PLAN PR's native post reads them, and a code PR's
|
|
831
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.
|
|
832
861
|
"""
|
|
833
862
|
|
|
834
863
|
verdict: str
|
|
@@ -836,6 +865,7 @@ class VerdictEnvelope:
|
|
|
836
865
|
readiness_reason: str = ""
|
|
837
866
|
commit_readiness: "str | None" = None
|
|
838
867
|
commit_readiness_reason: str = ""
|
|
868
|
+
contract_version: "int | None" = None
|
|
839
869
|
|
|
840
870
|
|
|
841
871
|
@dataclass(frozen=True)
|
|
@@ -854,11 +884,16 @@ class TaskDetail:
|
|
|
854
884
|
`verdict` is None when no envelope on the task parses -- the normal round-1
|
|
855
885
|
case, indistinguishable here from "no verdict of record yet", which is what
|
|
856
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.
|
|
857
891
|
"""
|
|
858
892
|
|
|
859
893
|
task: Task
|
|
860
894
|
verdicts: int
|
|
861
895
|
verdict: "str | None"
|
|
896
|
+
envelope: "VerdictEnvelope | None" = None
|
|
862
897
|
|
|
863
898
|
|
|
864
899
|
class Alissa:
|
|
@@ -1182,10 +1217,12 @@ class Alissa:
|
|
|
1182
1217
|
task = _task_from_row(data)
|
|
1183
1218
|
if task is None:
|
|
1184
1219
|
return None
|
|
1220
|
+
envelope = self._newest_envelope(data)
|
|
1185
1221
|
return TaskDetail(
|
|
1186
1222
|
task=task,
|
|
1187
1223
|
verdicts=self._count_verdicts(data),
|
|
1188
|
-
verdict=
|
|
1224
|
+
verdict=None if envelope is None else envelope.verdict,
|
|
1225
|
+
envelope=envelope,
|
|
1189
1226
|
)
|
|
1190
1227
|
except Exception: # pragma: no cover - defence in depth
|
|
1191
1228
|
log.exception("could not parse task payload for %s", ref)
|
|
@@ -1348,6 +1385,9 @@ class Alissa:
|
|
|
1348
1385
|
commit, commit_reason, _ = parse_commit_readiness(content)
|
|
1349
1386
|
if commit is None:
|
|
1350
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)
|
|
1351
1391
|
found.append(
|
|
1352
1392
|
(Alissa._created_key(item.get("createdAt")),
|
|
1353
1393
|
index,
|
|
@@ -1357,6 +1397,7 @@ class Alissa:
|
|
|
1357
1397
|
readiness_reason=reason,
|
|
1358
1398
|
commit_readiness=commit,
|
|
1359
1399
|
commit_readiness_reason=commit_reason,
|
|
1400
|
+
contract_version=contract,
|
|
1360
1401
|
))
|
|
1361
1402
|
)
|
|
1362
1403
|
break
|
|
@@ -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
|
|
@@ -2250,6 +2270,10 @@ class ResolvedTask:
|
|
|
2250
2270
|
verdicts: int = 0
|
|
2251
2271
|
verdict: "str | None" = None
|
|
2252
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
|
|
2253
2277
|
|
|
2254
2278
|
@classmethod
|
|
2255
2279
|
def from_detail(cls, detail: TaskDetail) -> "ResolvedTask":
|
|
@@ -2264,6 +2288,7 @@ class ResolvedTask:
|
|
|
2264
2288
|
verdicts=detail.verdicts,
|
|
2265
2289
|
verdict=detail.verdict,
|
|
2266
2290
|
verdict_read=True,
|
|
2291
|
+
envelope=detail.envelope,
|
|
2267
2292
|
)
|
|
2268
2293
|
|
|
2269
2294
|
def newest_verdict(self, alissa: Alissa) -> "str | None":
|
|
@@ -2988,6 +3013,12 @@ class ReviewWatcher:
|
|
|
2988
3013
|
# decides nothing -- it reports; and it must run on the pass that
|
|
2989
3014
|
# converges, since a converged PR leaves the search set.
|
|
2990
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
|
+
)
|
|
2991
3022
|
|
|
2992
3023
|
if task is not None and owed > native:
|
|
2993
3024
|
# Terminal for this pass either way. On a landed post the review
|
|
@@ -4289,6 +4320,21 @@ class ReviewWatcher:
|
|
|
4289
4320
|
)
|
|
4290
4321
|
class_term = readiness_class_term(posted_value, posted_class)
|
|
4291
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
|
+
)
|
|
4292
4338
|
readiness_note = (
|
|
4293
4339
|
f" {readiness_name}={readiness_value}{class_term}" if trailer else ""
|
|
4294
4340
|
)
|
|
@@ -4975,6 +5021,208 @@ class ReviewWatcher:
|
|
|
4975
5021
|
deferred=True,
|
|
4976
5022
|
)
|
|
4977
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
|
+
|
|
4978
5226
|
def _observe_session_readiness(
|
|
4979
5227
|
self, pr: PullRequest, my_reviews: list[Review], round_: int
|
|
4980
5228
|
) -> None:
|
|
@@ -7264,6 +7512,19 @@ class ReviewWatcher:
|
|
|
7264
7512
|
|
|
7265
7513
|
self._note_deferrals(results)
|
|
7266
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
|
+
|
|
7267
7528
|
# Persist one poll_snapshots row per pass, built entirely from the
|
|
7268
7529
|
# Decision list already in hand plus the reap count -- no new GitHub
|
|
7269
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.9
|
|
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
|