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.
Files changed (38) hide show
  1. {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
  2. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/alissa.py +120 -4
  3. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/loop.py +301 -1
  4. {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
  5. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/state.py +12 -1
  6. alissa_tools_github_revloop-0.31.10/src/main/alissa/tools/github/revloop/version +1 -0
  7. {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
  8. alissa_tools_github_revloop-0.31.8/src/main/alissa/tools/github/revloop/version +0 -1
  9. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/LICENSE +0 -0
  10. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/MANIFEST.in +0 -0
  11. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/NOTICE +0 -0
  12. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/README.md +0 -0
  13. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/requirements.txt +0 -0
  14. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/setup.cfg +0 -0
  15. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/setup.py +0 -0
  16. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  17. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  18. {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
  19. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  20. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/config.py +0 -0
  21. {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
  22. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  23. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  24. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
  25. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  26. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  27. {alissa_tools_github_revloop-0.31.8 → alissa_tools_github_revloop-0.31.10}/src/main/alissa/tools/github/revloop/version.py +0 -0
  28. {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
  29. {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
  30. {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
  31. {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
  32. {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
  33. {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
  34. {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
  35. {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
  36. {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
  37. {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
  38. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.8
3
+ Version: 0.31.10
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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=self._newest_verdict(data),
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
- argv += ["--task", task_ref]
1411
- argv.append(directive)
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
- log.info("[dry-run] would enqueue: %s", " ".join(argv[:-1]) + " <directive>")
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.ref if task else None,
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(rows: "list[dict]") -> "list[tuple[int, dict]]":
284
- """`round.verdict` for posted rows, `round.abandoned` for abandoned ones.
285
-
286
- A row with neither `posted_at` nor `abandoned_at` is an OPEN obligation —
287
- the round's native verdict has not landed — and emits nothing until it
288
- closes one way or the other. `data.verdict` is omitted (never invented)
289
- on rows that predate the ledger's verdict column.
290
-
291
- COVERAGE BOUND (PR #113 round 2, major): `verdict_posts` is the
292
- per-round OBLIGATION record, not the per-round record — a row exists
293
- only when the reviewer session defaulted and the daemon posted the
294
- native fallback verdict itself. On a fleet whose sessions post every
295
- review, these two kinds are expected to be EMPTY; issue #112 maps them
296
- to this table and puts the round bookkeeping a session-covering source
297
- needs out of scope, so the widening is TASK-1086576582, and the README's
298
- telemetry table states the bound where an operator will read it."""
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
- data: dict[str, Any] = {"headSha": head}
306
- if row.get("verdict"):
307
- data["verdict"] = row["verdict"]
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
- sql += " WHERE session IN (%s)" % ",".join("?" * len(names))
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.8
3
+ Version: 0.31.10
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera