alissa-tools-github-revloop 0.31.16__tar.gz → 0.31.18__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 (40) hide show
  1. {alissa_tools_github_revloop-0.31.16/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.18}/PKG-INFO +13 -1
  2. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/README.md +12 -0
  3. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/config.py +25 -0
  4. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/ghclient.py +58 -0
  5. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/loop.py +197 -34
  6. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/parking.py +3 -1
  7. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/state.py +6 -3
  8. alissa_tools_github_revloop-0.31.18/src/main/alissa/tools/github/revloop/version +1 -0
  9. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/webui/server.py +3 -1
  10. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +13 -1
  11. alissa_tools_github_revloop-0.31.16/src/main/alissa/tools/github/revloop/version +0 -1
  12. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/LICENSE +0 -0
  13. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/MANIFEST.in +0 -0
  14. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/NOTICE +0 -0
  15. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/requirements.txt +0 -0
  16. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/setup.cfg +0 -0
  17. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/setup.py +0 -0
  18. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  19. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  20. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  21. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  22. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  23. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  24. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/janitor.py +0 -0
  25. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  26. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  27. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
  28. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  29. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  30. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/version.py +0 -0
  31. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  32. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  33. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  34. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  35. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  36. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  37. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  38. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  39. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  40. {alissa_tools_github_revloop-0.31.16 → alissa_tools_github_revloop-0.31.18}/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.16
3
+ Version: 0.31.18
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -46,6 +46,18 @@ The rule: a distribution owns a leaf package and declares only that subtree
46
46
  `__init__.py` at any namespace level would claim it for one distribution and
47
47
  shadow the others.
48
48
 
49
+ ## Discovery PRs are not reviewed
50
+
51
+ A PR in a plans repository (`plan_repos`, `ALISSA_REVIEW_PLAN_REPOS`) whose
52
+ body carries `<!-- alissa-discovery v1 -->` on a line of its own and whose
53
+ every changed path is under `knowledge-base/` gets **no review round** — no
54
+ reviewer session, no review task — and one activity-comment line per head
55
+ saying why. A marked PR that touches anything else (or whose file list cannot
56
+ be read in full) is reviewed under the code directive, unchanged. The fleet
57
+ flag `discovery_review` (`ALISSA_REVIEW_DISCOVERY_REVIEW`, default off) turns
58
+ the skip off: every discovery PR is then reviewed like any other. See
59
+ *Discovery PRs* in the repository README.
60
+
49
61
  ## Install
50
62
 
51
63
  ```sh
@@ -24,6 +24,18 @@ The rule: a distribution owns a leaf package and declares only that subtree
24
24
  `__init__.py` at any namespace level would claim it for one distribution and
25
25
  shadow the others.
26
26
 
27
+ ## Discovery PRs are not reviewed
28
+
29
+ A PR in a plans repository (`plan_repos`, `ALISSA_REVIEW_PLAN_REPOS`) whose
30
+ body carries `<!-- alissa-discovery v1 -->` on a line of its own and whose
31
+ every changed path is under `knowledge-base/` gets **no review round** — no
32
+ reviewer session, no review task — and one activity-comment line per head
33
+ saying why. A marked PR that touches anything else (or whose file list cannot
34
+ be read in full) is reviewed under the code directive, unchanged. The fleet
35
+ flag `discovery_review` (`ALISSA_REVIEW_DISCOVERY_REVIEW`, default off) turns
36
+ the skip off: every discovery PR is then reviewed like any other. See
37
+ *Discovery PRs* in the repository README.
38
+
27
39
  ## Install
28
40
 
29
41
  ```sh
@@ -183,6 +183,7 @@ CONFIG_KEYS = (
183
183
  "authors",
184
184
  "plan_repos",
185
185
  "plan_authors",
186
+ "discovery_review",
186
187
  "operators",
187
188
  "agent_profile",
188
189
  "reviewer_login",
@@ -268,6 +269,15 @@ JANITOR_WARN_PCT_ENV = "ALISSA_REVIEW_JANITOR_WARN_PCT"
268
269
  PLAN_REPOS_ENV = "ALISSA_REVIEW_PLAN_REPOS"
269
270
  PLAN_AUTHORS_ENV = "ALISSA_REVIEW_PLAN_AUTHORS"
270
271
  PLAN_REPO_OWNER_PLACEHOLDER = "<owner>"
272
+
273
+ # The discovery fleet flag (issue #171; build-discovery design §3.3, DS-7).
274
+ # OFF by default: a DISCOVERY PR -- a plans repository, the discovery marker,
275
+ # every changed path under `knowledge-base/` (loop.discovery_skip_reason) --
276
+ # gets no review round at all, because its defence is the knowledge-base lint
277
+ # of record, staleness, the plan refuter and the user's acceptance. ON, every
278
+ # discovery PR is reviewed under the code directive like any other PR. Same
279
+ # env > file > default precedence and boolean spellings as the janitor's rail.
280
+ DISCOVERY_REVIEW_ENV = "ALISSA_REVIEW_DISCOVERY_REVIEW"
271
281
  DEFAULT_PLAN_REPO_NAME = "alissa-dark-factory-plans"
272
282
  DEFAULT_PLAN_REPOS: "tuple[str, ...]" = (
273
283
  f"{PLAN_REPO_OWNER_PLACEHOLDER}/{DEFAULT_PLAN_REPO_NAME}",
@@ -896,6 +906,10 @@ class Config:
896
906
  plan_repos: tuple[str, ...] = DEFAULT_PLAN_REPOS
897
907
  plan_authors: tuple[str, ...] = ()
898
908
 
909
+ # Review discovery PRs after all (issue #171); see DISCOVERY_REVIEW_ENV.
910
+ # False -- the default -- skips them.
911
+ discovery_review: bool = False
912
+
899
913
  # GitHub logins whose re-entry ack may raise a capped PR's effective cap
900
914
  # (loop.parse_reentry_ack). Empty -- the default -- means NO ack is ever
901
915
  # honoured: the lever fails closed, because anyone who can comment on a PR
@@ -1267,6 +1281,16 @@ class Config:
1267
1281
  env_plan_authors = env_plan_list(PLAN_AUTHORS_ENV, environ)
1268
1282
  if env_plan_authors is not None:
1269
1283
  raw["plan_authors"] = env_plan_authors
1284
+ # The discovery flag (issue #171), same layer, same reason.
1285
+ env_discovery = _env_bool(DISCOVERY_REVIEW_ENV, environ)
1286
+ if env_discovery is not None:
1287
+ raw["discovery_review"] = env_discovery
1288
+ discovery_review = raw.get("discovery_review", cls.discovery_review)
1289
+ if not isinstance(discovery_review, bool):
1290
+ # A JSON string "false" is truthy; refused rather than read as on.
1291
+ raise ValueError(
1292
+ f"discovery_review must be true or false, got {discovery_review!r}"
1293
+ )
1270
1294
 
1271
1295
  source = raw.get("repos_source", cls.repos_source)
1272
1296
  if source not in _REPOS_SOURCES:
@@ -1552,6 +1576,7 @@ class Config:
1552
1576
  authors=authors,
1553
1577
  plan_repos=plan_repos,
1554
1578
  plan_authors=plan_authors,
1579
+ discovery_review=discovery_review,
1555
1580
  operators=operators,
1556
1581
  agent_profile=raw.get("agent_profile", "claude"),
1557
1582
  reviewer_login=raw.get("reviewer_login"),
@@ -93,6 +93,12 @@ COMPARE_COMMITS_PER_PAGE = 1
93
93
  # gone?" probe would answer yes on every large PR and abandon real verdicts.
94
94
  PR_COMMIT_CAP = 250
95
95
 
96
+ # GitHub's OWN cap on the pull-request files endpoint: it "lists a maximum of
97
+ # 3000 files" (30 pages of 100). Past it the listing is silently short, so a
98
+ # PR at the cap cannot prove that EVERY changed path is under some prefix --
99
+ # which is the one question `pull_request_files` is asked (issue #171).
100
+ PR_FILES_CAP = 3000
101
+
96
102
  # Environment variables `gh` reads a token from. Both are cleared before an
97
103
  # explicitly-routed call, so an inherited implementer credential cannot win by
98
104
  # being the one variable we did not think to overwrite.
@@ -783,6 +789,58 @@ class GitHub:
783
789
  return out
784
790
  page += 1
785
791
 
792
+ def pull_request_files(self, owner: str, repo: str, number: int) -> list[str]:
793
+ """Every path the PR changes, paged to the end.
794
+
795
+ Read on ONE path: the discovery skip (issue #171), which asks whether
796
+ EVERY changed path is under `knowledge-base/`. That is an absence
797
+ question again -- "nothing outside the prefix" -- so a listing at
798
+ GitHub's PR_FILES_CAP is REFUSED (`TruncatedListing`) rather than
799
+ answered short, and the caller reviews the PR instead of skipping it.
800
+
801
+ A RENAME contributes BOTH names, for the reason `compare_files` gives:
802
+ `src/x.py` moved to `knowledge-base/x.py` changed a path outside the
803
+ prefix, and reading only the destination would make it look confined.
804
+ """
805
+ out: list[str] = []
806
+ seen: set[str] = set()
807
+ listed = 0
808
+ page = 1
809
+ while True:
810
+ data = (
811
+ self._api(
812
+ "-X",
813
+ "GET",
814
+ f"repos/{owner}/{repo}/pulls/{number}/files",
815
+ "-f",
816
+ f"per_page={PER_PAGE}",
817
+ "-f",
818
+ f"page={page}",
819
+ )
820
+ or []
821
+ )
822
+ listed += len(data)
823
+ for entry in data:
824
+ for key in ("filename", "previous_filename"):
825
+ name = str(entry.get(key) or "")
826
+ if name and name not in seen:
827
+ seen.add(name)
828
+ out.append(name)
829
+ if listed >= PR_FILES_CAP:
830
+ log.warning(
831
+ "%s/%s#%d reached GitHub's %d-file listing cap — the file "
832
+ "list is not complete, so 'nothing outside the prefix' "
833
+ "cannot be read from it",
834
+ owner, repo, number, PR_FILES_CAP,
835
+ )
836
+ raise TruncatedListing(
837
+ f"{owner}/{repo}#{number} reached GitHub's {PR_FILES_CAP}-file "
838
+ f"listing cap; absence cannot be proven from it"
839
+ )
840
+ if len(data) < PER_PAGE:
841
+ return out
842
+ page += 1
843
+
786
844
  # -- CI check rollup ---------------------------------------------------
787
845
 
788
846
  def check_rollup(self, owner: str, repo: str, sha: str) -> CheckRollup:
@@ -1246,6 +1246,51 @@ def plan_id_of(pr: PullRequest) -> "str | None":
1246
1246
  return match.group(1) if match else None
1247
1247
 
1248
1248
 
1249
+ # -- the discovery skip (issue #171; build-discovery design §3.2, §3.3, DS-7) --
1250
+ #
1251
+ # A DISCOVERY PR is a discovery task's output: findings written into the
1252
+ # plans repository's `knowledge-base/`. Its defence is the knowledge-base lint
1253
+ # of record, staleness, the plan refuter and the user's acceptance (§3.3), so a
1254
+ # judgment review per discovery task would only double the subscription cost
1255
+ # -- revloop starts NO round on it, which is what leaves it without a review
1256
+ # task (the reviewer session is what creates one, CR2).
1257
+ #
1258
+ # All THREE signals, or it is not a discovery PR: the repository is in
1259
+ # `plan_repos`, the body carries the discovery marker on a line of its own,
1260
+ # and EVERY changed path is under `knowledge-base/` (the PR's files, paged).
1261
+ # A marked PR that touches anything else -- or whose files cannot be read in
1262
+ # full -- is reviewed under the CODE directive, unchanged: the safe reading.
1263
+ # The fleet flag `discovery_review` (config.DISCOVERY_REVIEW_ENV) turns the
1264
+ # skip off, and every discovery PR is then reviewed like any other.
1265
+ DISCOVERY_MARKER = "<!-- alissa-discovery v1 -->"
1266
+ DISCOVERY_PREFIX = "knowledge-base/"
1267
+
1268
+
1269
+ def discovery_marked(config: Config, pr: PullRequest) -> bool:
1270
+ """The two signals readable without a GitHub call: the plans repository
1271
+ and the marker on a line of its own (the plan marker's rule)."""
1272
+ if not config.is_plan_repo(pr.full_name):
1273
+ return False
1274
+ body = pr.body.replace("\r\n", "\n")
1275
+ return any(line.strip() == DISCOVERY_MARKER for line in body.split("\n"))
1276
+
1277
+
1278
+ def outside_discovery_prefix(paths: "list[str]") -> "list[str]":
1279
+ """The changed paths NOT under `knowledge-base/`. An empty diff answers
1280
+ with a placeholder rather than nothing: "every path is confined" must not
1281
+ hold vacuously for a PR that changes no path at all."""
1282
+ if not paths:
1283
+ return ["(no changed files)"]
1284
+ return [p for p in paths if not p.startswith(DISCOVERY_PREFIX)]
1285
+
1286
+
1287
+ def discovery_skip_kind(head_sha: str) -> str:
1288
+ """The ping-ledger kind that dedupes the skip's activity line: one line
1289
+ per head, since the review request stands and the skip is re-decided on
1290
+ every poll."""
1291
+ return f"activity-discovery-skip:{head_sha}"
1292
+
1293
+
1249
1294
  # The ORIGIN task a reviewer spawn is stamped with (`--task`, issue #153; the
1250
1295
  # Studio cost meter's lane L4). First from the review task's CR2 title --
1251
1296
  # `Review PR <org>/<repo>#<n> (TASK-<origin>)` -- then from the PR body, the
@@ -2640,6 +2685,12 @@ class ReviewWatcher:
2640
2685
  # hold a place forever.
2641
2686
  self._waiting: dict[tuple[str, int], Waiting] = {}
2642
2687
  self._wait_seq = 0
2688
+ # The discovery skip's file reads (issue #171), keyed by (repo, PR,
2689
+ # head): the changed paths outside `knowledge-base/` at that head. A
2690
+ # skipped PR keeps its review request, so it is re-decided every poll,
2691
+ # and a head's file list never changes -- one paged read per head, not
2692
+ # one per poll. Only successful reads are kept; a failed one retries.
2693
+ self._discovery_outside: "dict[tuple[str, int, str], list[str]]" = {}
2643
2694
  # Consecutive passes that deferred at least one round -- the summary
2644
2695
  # line's streak limiter, and ONLY that. It never escalates: a queue
2645
2696
  # that keeps draining is the gate working, and a wave permanently
@@ -3110,6 +3161,17 @@ class ReviewWatcher:
3110
3161
  f"PR author {pr.author} is not in the authors allowlist",
3111
3162
  )
3112
3163
 
3164
+ # THE DISCOVERY SKIP (issue #171) gates starting a round exactly as the
3165
+ # authors filter does, and sits beside it for the same reasons: below
3166
+ # every branch that FINISHES a round already started (a discovery PR
3167
+ # reviewed while `discovery_review` was on still gets its verdict of
3168
+ # record), above every one that numbers, records or starts one. Below
3169
+ # the authors filter because it is the only one of the two that can
3170
+ # cost a GitHub call.
3171
+ skipped = self._discovery_skip(pr)
3172
+ if skipped is not None:
3173
+ return skipped
3174
+
3113
3175
  # The effective cap is the configured one plus every re-entry an
3114
3176
  # operator has explicitly acked on THIS PR (issue #42). The sum is a
3115
3177
  # local read, so it costs nothing on the common path; the GitHub scan
@@ -3254,6 +3316,74 @@ class ReviewWatcher:
3254
3316
  stability=stability.notice,
3255
3317
  )
3256
3318
 
3319
+ # -- the discovery skip (issue #171) -----------------------------------
3320
+
3321
+ def _discovery_skip(self, pr: PullRequest) -> Decision | None:
3322
+ """A SKIPPED decision for a discovery PR, or None to review it.
3323
+
3324
+ None -- review under the code directive, the safe reading -- whenever
3325
+ any of the three signals fails, the files cannot be read in full, or
3326
+ the fleet flag `discovery_review` is on. The two cheap signals are read
3327
+ first, so a PR that is not marked never costs a files read.
3328
+
3329
+ A skip appends ONE activity line per head (ledger-deduped, recorded
3330
+ only once the append lands, so a dry run records nothing) and logs at
3331
+ INFO only then; the re-decisions on later polls are DEBUG.
3332
+ """
3333
+ if not discovery_marked(self.config, pr):
3334
+ return None
3335
+ if self.config.discovery_review:
3336
+ log.debug(
3337
+ "%s carries the discovery marker but discovery_review is on — "
3338
+ "reviewing under the code directive",
3339
+ pr.slug,
3340
+ )
3341
+ return None
3342
+
3343
+ key = (pr.full_name, pr.number, pr.head_sha)
3344
+ outside = self._discovery_outside.get(key)
3345
+ if outside is None:
3346
+ try:
3347
+ paths = self.github.pull_request_files(pr.owner, pr.repo, pr.number)
3348
+ except RateLimited:
3349
+ # Throttling is not a fact about the diff (the stability
3350
+ # guard's rule): raised on, so the poll backs off.
3351
+ raise
3352
+ except (CommandError, TruncatedListing) as exc:
3353
+ log.warning(
3354
+ "%s carries the discovery marker but its changed files "
3355
+ "cannot be read in full — reviewing under the code "
3356
+ "directive: %s",
3357
+ pr.slug, exc,
3358
+ )
3359
+ return None
3360
+ outside = outside_discovery_prefix(paths)
3361
+ self._discovery_outside[key] = outside
3362
+
3363
+ if outside:
3364
+ log.info(
3365
+ "%s carries the discovery marker but changes %d path(s) outside "
3366
+ "%s (first: %s) — reviewing under the code directive",
3367
+ pr.slug, len(outside), DISCOVERY_PREFIX, outside[0],
3368
+ )
3369
+ return None
3370
+
3371
+ reason = (
3372
+ f"discovery PR — plans repository, `{DISCOVERY_MARKER}` marker, "
3373
+ f"every changed path under `{DISCOVERY_PREFIX}`; no review round "
3374
+ f"(discovery_review is off)"
3375
+ )
3376
+ kind = discovery_skip_kind(pr.head_sha)
3377
+ if not self.state.pinged(pr.full_name, pr.number, kind):
3378
+ log.info("%s at %s: skipping review — %s", pr.slug, pr.head_sha[:8], reason)
3379
+ if self._append_activity(
3380
+ pr, f"- {_now()} — skipped at `{pr.head_sha[:8]}` — {reason}"
3381
+ ):
3382
+ self.state.record_ping(pr.full_name, pr.number, kind)
3383
+ else:
3384
+ log.debug("%s: %s", pr.slug, reason)
3385
+ return Decision(Action.SKIPPED, reason)
3386
+
3257
3387
  # -- the spawn gate ----------------------------------------------------
3258
3388
 
3259
3389
  def _gate_spawn(self, pr: PullRequest, round_: int) -> Decision | None:
@@ -6172,20 +6302,49 @@ class ReviewWatcher:
6172
6302
  #157): claim the spawn of this round's session against the drain
6173
6303
  flag, run `spawn` under the claim, and drop the claim however it ends.
6174
6304
 
6175
- The gate sits ABOVE every side effect of the spawn -- the hub add,
6176
- the `alissa tmux queue add`, the census bump, the ledger row, the
6177
- activity line -- so a drained seat touches nothing, and the round
6178
- stays owed: a QUEUED `drained` decision writes no spawn row, burns no
6179
- round number and spends no attempt, exactly like the slot gate. The
6180
- claim and the flag are read and written in one immediate transaction
6181
- on each side (`State.claim_spawn` / `State.set_drain`): a drain that
6305
+ The gate sits ABOVE every side effect of the spawn but one -- the
6306
+ `alissa tmux queue add`, the census bump, the ledger row and the
6307
+ activity line all come after it -- and a round it stands down stays
6308
+ owed: a QUEUED `drained` decision writes no spawn row, burns no round
6309
+ number and spends no attempt, exactly like the slot gate. The claim
6310
+ and the flag are read and written in one immediate transaction on
6311
+ each side (`State.claim_spawn` / `State.set_drain`): a drain that
6182
6312
  lands while this spawn is between its gate and its ledger write sees
6183
- the claim and refuses itself, so a park can never race a spawn. A
6184
- ledger that cannot answer stands the spawn down too -- a spawn that
6185
- cannot prove the seat is not drained does not happen.
6313
+ the claim and refuses itself. A ledger that cannot answer stands the
6314
+ spawn down too -- a spawn that cannot prove the seat is not drained
6315
+ does not happen.
6316
+
6317
+ The claim only counts for `parking.SPAWN_CLAIM_TTL`, and the hub add
6318
+ (`_ensure_hub`, a clone on a PR's first round) can outlast it. So
6319
+ `_spawn_claimed` re-takes the claim (`_claim_spawn` again, issue
6320
+ #173) between the hub add and the enqueue, which refreshes it and
6321
+ re-reads the flag: a drain that held while the hub was provisioned
6322
+ stands the round down there, the same QUEUED `drained` way. That late
6323
+ re-check is the one stand-down that happens AFTER a side effect --
6324
+ the hub is provisioned and stays -- which is harmless, because the
6325
+ hub is a reusable cache the round's next spawn finds in place.
6326
+ Together the two claims mean a park can never race a spawn, whatever
6327
+ the drain's freshness rule allows.
6186
6328
 
6187
6329
  Dry-run READS the flag and claims nothing: it shares the production
6188
6330
  state path, and a dry pass must write no durable row."""
6331
+ refused = self._claim_spawn(pr, round_, name)
6332
+ if refused is not None:
6333
+ return refused
6334
+ try:
6335
+ return spawn()
6336
+ finally:
6337
+ if not self.config.dry_run:
6338
+ self.state.release_spawn(name)
6339
+
6340
+ def _claim_spawn(
6341
+ self, pr: PullRequest, round_: int, name: str, *, late: bool = False,
6342
+ ) -> "Decision | None":
6343
+ """Claim (or, `late`, re-claim) the spawn of `name` against the drain
6344
+ flag: None when the spawn may proceed, else the QUEUED `drained`
6345
+ decision that stands the round down. Dry-run reads the flag and
6346
+ claims nothing. See `_drain_gated`."""
6347
+ where = " after its hub was provisioned" if late else ""
6189
6348
  try:
6190
6349
  if self.config.dry_run:
6191
6350
  drain = self.state.drain()
@@ -6193,36 +6352,32 @@ class ReviewWatcher:
6193
6352
  drain = self.state.claim_spawn(name)
6194
6353
  except sqlite3.Error as exc:
6195
6354
  log.warning(
6196
- "%s round %d: could not claim the spawn against the drain flag "
6197
- "(%s) — standing it down this pass", pr.slug, round_, exc,
6198
- )
6199
- return Decision(
6200
- Action.QUEUED,
6201
- f"round {round_} stood down — the state ledger could not prove "
6202
- f"the seat is not drained ({exc}); retrying next poll",
6203
- round_,
6204
- drained=True,
6205
- )
6206
- if drain is not None:
6207
- since = parking_mod.iso(drain["drained_at"])
6208
- until = parking_mod.iso(drain["expires_at"])
6209
- log.info(
6210
- "%s round %d: not spawning — the seat is drained for parking "
6211
- "since %s (until %s)", pr.slug, round_, since, until,
6355
+ "%s round %d: could not claim the spawn against the drain flag%s "
6356
+ "(%s) — standing it down this pass", pr.slug, round_, where, exc,
6212
6357
  )
6213
6358
  return Decision(
6214
6359
  Action.QUEUED,
6215
- f"round {round_} held — the seat is drained for parking since "
6216
- f"{since} (until {until}: {drain['reason'] or 'no reason given'}); "
6217
- f"retrying once it is undrained",
6360
+ f"round {round_} stood down{where} — the state ledger could not "
6361
+ f"prove the seat is not drained ({exc}); retrying next poll",
6218
6362
  round_,
6219
6363
  drained=True,
6220
6364
  )
6221
- try:
6222
- return spawn()
6223
- finally:
6224
- if not self.config.dry_run:
6225
- self.state.release_spawn(name)
6365
+ if drain is None:
6366
+ return None
6367
+ since = parking_mod.iso(drain["drained_at"])
6368
+ until = parking_mod.iso(drain["expires_at"])
6369
+ log.info(
6370
+ "%s round %d: not spawning%s — the seat is drained for parking "
6371
+ "since %s (until %s)", pr.slug, round_, where, since, until,
6372
+ )
6373
+ return Decision(
6374
+ Action.QUEUED,
6375
+ f"round {round_} held{where} — the seat is drained for parking since "
6376
+ f"{since} (until {until}: {drain['reason'] or 'no reason given'}); "
6377
+ f"retrying once it is undrained",
6378
+ round_,
6379
+ drained=True,
6380
+ )
6226
6381
 
6227
6382
  def _spawn_claimed(
6228
6383
  self,
@@ -6297,6 +6452,14 @@ class ReviewWatcher:
6297
6452
  # provisioned -- unknown, never settled.
6298
6453
  return Decision(Action.SKIPPED, problem, round_, owed=True)
6299
6454
 
6455
+ # The hub add can outlast the gate's claim (a clone on a PR's first
6456
+ # round): re-take it, refreshing it and re-reading the drain flag, so
6457
+ # a drain that landed meanwhile stands the round down here (issue
6458
+ # #173). The claim is still released in `_drain_gated`'s finally.
6459
+ refused = self._claim_spawn(pr, round_, name, late=True)
6460
+ if refused is not None:
6461
+ return refused
6462
+
6300
6463
  self.alissa.enqueue_reviewer(
6301
6464
  session=name,
6302
6465
  directive=directive,
@@ -26,7 +26,9 @@ handshake):
26
26
  reads every null as a refusal.
27
27
  * **The drain.** A flag with a TTL in `state.db` (`State.set_drain`), cleared
28
28
  when the daemon boots. While it holds, the round spawn stands down before
29
- its first side effect (`ReviewWatcher._drain_gated`).
29
+ its first side effect (`ReviewWatcher._drain_gated`), or, when the drain
30
+ lands while the hub is provisioned, after the hub add and before the
31
+ enqueue (the post-hub re-claim).
30
32
  """
31
33
 
32
34
  from __future__ import annotations
@@ -39,6 +39,8 @@ from collections.abc import Callable, Iterable
39
39
  from dataclasses import dataclass
40
40
  from pathlib import Path
41
41
 
42
+ from .parking import SPAWN_CLAIM_TTL
43
+
42
44
  log = logging.getLogger(__name__)
43
45
 
44
46
  # Poll-snapshot retention: the newest N rows are kept, older ones pruned on
@@ -2067,11 +2069,12 @@ class State:
2067
2069
 
2068
2070
  def set_drain(
2069
2071
  self, ttl: int, reason: str, now: "float | None" = None,
2070
- claim_ttl: int = 600,
2072
+ claim_ttl: int = SPAWN_CLAIM_TTL,
2071
2073
  ) -> "tuple[dict, list[dict]]":
2072
2074
  """Raise the drain flag for `ttl` seconds and return it with the spawn
2073
- claims standing at that instant (younger than `claim_ttl` -- an older
2074
- one is a spawn whose process died mid-way, cleared at the next boot).
2075
+ claims standing at that instant (younger than `claim_ttl`, by default
2076
+ `parking.SPAWN_CLAIM_TTL` -- an older one is a spawn whose process
2077
+ died mid-way, cleared at the next boot).
2075
2078
  ONE immediate transaction, so a spawn either claimed before this (and
2076
2079
  is in the list) or claims after it (and sees the flag). A drain that
2077
2080
  is already up is replaced: a re-drain restarts the TTL. Raises
@@ -332,7 +332,9 @@ class App:
332
332
  cleared = self.sources.clear_drain()
333
333
  except sqlite3.Error as exc:
334
334
  self._audit("undrain", {"ok": False, "error": str(exc)})
335
- return 503, {"drained": True, "error": "state_unavailable"}
335
+ # The flag's state is unknown here, and unknown is never a value
336
+ # (D2): the 503 already says the undrain did not land.
337
+ return 503, {"drained": None, "error": "state_unavailable"}
336
338
  self._audit("undrain", {"ok": True, "cleared": cleared})
337
339
  return 200, {"drained": False, "cleared": cleared}
338
340
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.16
3
+ Version: 0.31.18
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -46,6 +46,18 @@ The rule: a distribution owns a leaf package and declares only that subtree
46
46
  `__init__.py` at any namespace level would claim it for one distribution and
47
47
  shadow the others.
48
48
 
49
+ ## Discovery PRs are not reviewed
50
+
51
+ A PR in a plans repository (`plan_repos`, `ALISSA_REVIEW_PLAN_REPOS`) whose
52
+ body carries `<!-- alissa-discovery v1 -->` on a line of its own and whose
53
+ every changed path is under `knowledge-base/` gets **no review round** — no
54
+ reviewer session, no review task — and one activity-comment line per head
55
+ saying why. A marked PR that touches anything else (or whose file list cannot
56
+ be read in full) is reviewed under the code directive, unchanged. The fleet
57
+ flag `discovery_review` (`ALISSA_REVIEW_DISCOVERY_REVIEW`, default off) turns
58
+ the skip off: every discovery PR is then reviewed like any other. See
59
+ *Discovery PRs* in the repository README.
60
+
49
61
  ## Install
50
62
 
51
63
  ```sh