ctrlrelay 0.10.0__py3-none-any.whl

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 (53) hide show
  1. ctrlrelay/__init__.py +33 -0
  2. ctrlrelay/bridge/__init__.py +21 -0
  3. ctrlrelay/bridge/__main__.py +89 -0
  4. ctrlrelay/bridge/protocol.py +75 -0
  5. ctrlrelay/bridge/server.py +740 -0
  6. ctrlrelay/bridge/telegram_handler.py +136 -0
  7. ctrlrelay/cli.py +3138 -0
  8. ctrlrelay/core/__init__.py +54 -0
  9. ctrlrelay/core/audit.py +257 -0
  10. ctrlrelay/core/checkpoint.py +155 -0
  11. ctrlrelay/core/config.py +778 -0
  12. ctrlrelay/core/dispatcher.py +372 -0
  13. ctrlrelay/core/github.py +432 -0
  14. ctrlrelay/core/network.py +277 -0
  15. ctrlrelay/core/obs.py +118 -0
  16. ctrlrelay/core/poller.py +920 -0
  17. ctrlrelay/core/pr_verifier.py +230 -0
  18. ctrlrelay/core/pr_watcher.py +121 -0
  19. ctrlrelay/core/question_expiry.py +193 -0
  20. ctrlrelay/core/scheduler.py +337 -0
  21. ctrlrelay/core/state.py +642 -0
  22. ctrlrelay/core/worktree.py +893 -0
  23. ctrlrelay/dashboard/__init__.py +5 -0
  24. ctrlrelay/dashboard/client.py +159 -0
  25. ctrlrelay/gh_protocol.py +47 -0
  26. ctrlrelay/install.py +203 -0
  27. ctrlrelay/personalization/__init__.py +26 -0
  28. ctrlrelay/personalization/manager.py +1183 -0
  29. ctrlrelay/personalization/paths.py +143 -0
  30. ctrlrelay/pipelines/__init__.py +15 -0
  31. ctrlrelay/pipelines/base.py +50 -0
  32. ctrlrelay/pipelines/dev.py +1367 -0
  33. ctrlrelay/pipelines/post_merge.py +411 -0
  34. ctrlrelay/pipelines/secops.py +1018 -0
  35. ctrlrelay/pipelines/task.py +713 -0
  36. ctrlrelay/setup.py +617 -0
  37. ctrlrelay/templates/__init__.py +0 -0
  38. ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
  39. ctrlrelay/templates/launchd/__init__.py +0 -0
  40. ctrlrelay/templates/launchd/bridge.plist.template +37 -0
  41. ctrlrelay/templates/launchd/poller.plist.template +44 -0
  42. ctrlrelay/templates/systemd/__init__.py +0 -0
  43. ctrlrelay/templates/systemd/bridge.service.template +20 -0
  44. ctrlrelay/templates/systemd/poller.service.template +25 -0
  45. ctrlrelay/transports/__init__.py +34 -0
  46. ctrlrelay/transports/base.py +74 -0
  47. ctrlrelay/transports/file_mock.py +103 -0
  48. ctrlrelay/transports/socket_client.py +298 -0
  49. ctrlrelay-0.10.0.dist-info/METADATA +254 -0
  50. ctrlrelay-0.10.0.dist-info/RECORD +53 -0
  51. ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
  52. ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
  53. ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,230 @@
1
+ """PR verification: wait for CI and confirm mergeability before hand-off."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from ctrlrelay.core.github import GitHubCLI, GitHubError
10
+ from ctrlrelay.core.obs import get_logger, log_event
11
+
12
+ _logger = get_logger("core.pr_verifier")
13
+
14
+ # `gh pr checks --json bucket` returns one of: pass, fail, pending, skipping, cancel.
15
+ # Treat skipping as pass (skipped jobs don't block a merge) and everything except
16
+ # pending as "terminal".
17
+ _PENDING_BUCKETS = frozenset({"pending"})
18
+ _PASSING_BUCKETS = frozenset({"pass", "skipping"})
19
+ _TERMINAL_MERGEABLE_VALUES = frozenset({"MERGEABLE", "CONFLICTING"})
20
+
21
+ # The dev pipeline's contract is "open a PR and hand it to humans for review",
22
+ # not "merge the PR". So the verifier does NOT require the PR be in a directly-
23
+ # mergeable state — human-gated states (awaiting review, unresolved comments,
24
+ # pending deployments, merge-queue requirements) are all expected terminal
25
+ # states for us. What we DO reject is state that (a) indicates broken code
26
+ # (failing checks) or (b) the orchestrator can itself fix before hand-off
27
+ # (conflicts, behind base). All other mergeStateStatus values are accepted.
28
+ _REBASE_REQUIRED_MERGE_STATE_STATUS = frozenset({"BEHIND"})
29
+
30
+ # Require 2 consecutive empty check-list responses separated by a poll interval
31
+ # before concluding "no CI configured". GitHub registers check runs a few
32
+ # seconds after `gh pr create`, so a single-shot empty read is unreliable.
33
+ _EMPTY_CHECKS_CONFIRM_POLLS = 2
34
+
35
+
36
+ @dataclass
37
+ class VerificationResult:
38
+ """Outcome of verifying a PR is ready for hand-off."""
39
+
40
+ ready: bool
41
+ reason: str = ""
42
+ failing_checks: list[dict[str, Any]] = field(default_factory=list)
43
+ pending_checks: list[dict[str, Any]] = field(default_factory=list)
44
+ # Set True when wait_for_checks returned with pending entries (timeout hit
45
+ # while CI was still running). Callers use this to distinguish "needs a
46
+ # fix" from "just slow CI" and avoid burning retry budget on the latter.
47
+ timed_out: bool = False
48
+ mergeable: str | None = None
49
+ merge_state_status: str | None = None
50
+
51
+
52
+ @dataclass
53
+ class PRVerifier:
54
+ """Verifies a PR is green and conflict-free before declaring a dev task done."""
55
+
56
+ github: GitHubCLI
57
+ poll_interval: int = 30
58
+ check_timeout: int = 1800
59
+ mergeable_poll_attempts: int = 10
60
+
61
+ async def wait_for_checks(
62
+ self,
63
+ repo: str,
64
+ pr_number: int,
65
+ timeout: int | None = None,
66
+ ) -> list[dict[str, Any]]:
67
+ """Poll PR checks until every check has left the 'pending' bucket or
68
+ the timeout is reached.
69
+
70
+ Empty-check handling: GitHub registers check runs asynchronously after
71
+ `gh pr create` so a single empty read is ambiguous — the repo might
72
+ have no CI, or CI just hasn't registered yet. We require
73
+ `_EMPTY_CHECKS_CONFIRM_POLLS` consecutive empty reads separated by
74
+ `poll_interval` before concluding "no CI configured".
75
+
76
+ Transient `gh` failures (subprocess timeout, GitHubError) are
77
+ treated as "still pending" and retried on the next iteration —
78
+ a flaky network or a GitHub rate-limit back-off shouldn't abort
79
+ the wait. The outer `limit` deadline is the safety net.
80
+
81
+ Sleep cap: when called with `timeout < poll_interval` the loop
82
+ used to block the full `poll_interval` before noticing the
83
+ deadline (issue #90). Now caps `sleep` at `max(0, limit -
84
+ elapsed)` so a 1-second timeout with a 15-second interval
85
+ returns within ~1 second.
86
+ """
87
+ limit = self.check_timeout if timeout is None else timeout
88
+ loop = asyncio.get_event_loop()
89
+ # Wall-clock deadline (monotonic) so the loop terminates even
90
+ # if `poll_interval` is 0 and every poll errors — accumulating
91
+ # sleep durations would loop forever in that case because
92
+ # max(0, 0) is still 0.
93
+ deadline = loop.time() + limit
94
+ empty_streak = 0
95
+ checks: list[dict[str, Any]] = []
96
+ had_successful_read = False
97
+ last_transient_error: Exception | None = None
98
+ while True:
99
+ try:
100
+ checks = await self.github.get_pr_checks(repo, pr_number)
101
+ except (GitHubError, asyncio.TimeoutError) as e:
102
+ # Transient gh failure — treat as still-pending, retry
103
+ # on next iteration. Unconditional re-raise of
104
+ # asyncio.TimeoutError used to surface as an ugly
105
+ # traceback in `ctrlrelay ci wait` (issue #90).
106
+ last_transient_error = e
107
+ log_event(
108
+ _logger,
109
+ "pr_verifier.transient_gh_error",
110
+ repo=repo,
111
+ pr_number=pr_number,
112
+ reason=type(e).__name__,
113
+ error=str(e)[:200],
114
+ )
115
+ else:
116
+ had_successful_read = True
117
+ last_transient_error = None
118
+ if not checks:
119
+ empty_streak += 1
120
+ if empty_streak >= _EMPTY_CHECKS_CONFIRM_POLLS:
121
+ return checks
122
+ else:
123
+ empty_streak = 0
124
+ if all(
125
+ c.get("bucket") not in _PENDING_BUCKETS
126
+ for c in checks
127
+ ):
128
+ return checks
129
+ remaining = deadline - loop.time()
130
+ if remaining <= 0:
131
+ # Fail closed if every poll failed: an empty `checks`
132
+ # list would be misread as "no CI configured" and the
133
+ # caller would silently greenlight the PR while
134
+ # GitHub was actually unavailable. Surface the last
135
+ # transient error so the CLI / verify() can react —
136
+ # the widened except clause in `ci_wait` catches it
137
+ # cleanly. (Codex P1 caught on the first PR pass.)
138
+ if not had_successful_read and last_transient_error is not None:
139
+ raise last_transient_error
140
+ return checks
141
+ sleep_for = min(self.poll_interval, remaining)
142
+ await asyncio.sleep(sleep_for)
143
+
144
+ async def verify(
145
+ self,
146
+ repo: str,
147
+ pr_number: int,
148
+ timeout: int | None = None,
149
+ ) -> VerificationResult:
150
+ """Wait for CI, then check mergeability. Report ready only when both are green."""
151
+ checks = await self.wait_for_checks(repo, pr_number, timeout=timeout)
152
+ pending = [c for c in checks if c.get("bucket") in _PENDING_BUCKETS]
153
+ failing = [
154
+ c for c in checks
155
+ if c.get("bucket") not in _PENDING_BUCKETS
156
+ and c.get("bucket") not in _PASSING_BUCKETS
157
+ ]
158
+ # Failing checks take priority over pending. A matrix where lint
159
+ # already failed but a long integration run is still pending must be
160
+ # reported as broken, not timed out — otherwise the caller would hand
161
+ # off a known-bad PR.
162
+ if failing:
163
+ names = ", ".join(c.get("name", "?") for c in failing)
164
+ return VerificationResult(
165
+ ready=False,
166
+ reason=f"{len(failing)} check(s) failing: {names}",
167
+ failing_checks=failing,
168
+ pending_checks=pending,
169
+ )
170
+ if pending:
171
+ # All failing paths ruled out; we simply hit the timeout while
172
+ # everything still in flight was healthy. Don't ask Claude to
173
+ # "fix" slow CI — surface it as a distinct outcome so the caller
174
+ # hands off the PR as-is.
175
+ names = ", ".join(c.get("name", "?") for c in pending)
176
+ return VerificationResult(
177
+ ready=False,
178
+ timed_out=True,
179
+ reason=(
180
+ f"CI still running after timeout: {len(pending)} "
181
+ f"check(s) pending ({names})"
182
+ ),
183
+ pending_checks=pending,
184
+ )
185
+
186
+ mergeable: str | None = None
187
+ merge_state: str | None = None
188
+ for _ in range(self.mergeable_poll_attempts):
189
+ state = await self.github.get_pr_state(repo, pr_number)
190
+ mergeable = state.get("mergeable")
191
+ merge_state = state.get("mergeStateStatus")
192
+ if mergeable in _TERMINAL_MERGEABLE_VALUES:
193
+ break
194
+ await asyncio.sleep(self.poll_interval)
195
+
196
+ if mergeable == "CONFLICTING":
197
+ return VerificationResult(
198
+ ready=False,
199
+ reason="PR has merge conflicts with the base branch",
200
+ mergeable=mergeable,
201
+ merge_state_status=merge_state,
202
+ )
203
+ if mergeable != "MERGEABLE":
204
+ return VerificationResult(
205
+ ready=False,
206
+ reason=f"PR mergeable state unresolved: {mergeable}",
207
+ mergeable=mergeable,
208
+ merge_state_status=merge_state,
209
+ )
210
+ if merge_state in _REBASE_REQUIRED_MERGE_STATE_STATUS:
211
+ return VerificationResult(
212
+ ready=False,
213
+ reason=(
214
+ "PR is behind the base branch and must be rebased before "
215
+ "merge (mergeStateStatus=BEHIND)"
216
+ ),
217
+ mergeable=mergeable,
218
+ merge_state_status=merge_state,
219
+ )
220
+
221
+ # Any remaining state (CLEAN, HAS_HOOKS, BLOCKED, UNSTABLE, DRAFT,
222
+ # etc.) is accepted. CI is verified green above, conflicts and
223
+ # behind-base are handled explicitly, so what's left is either
224
+ # directly mergeable or human-gated — both are valid hand-off states
225
+ # for a pipeline that never auto-merges.
226
+ return VerificationResult(
227
+ ready=True,
228
+ mergeable=mergeable,
229
+ merge_state_status=merge_state,
230
+ )
@@ -0,0 +1,121 @@
1
+ """PR merge watcher for monitoring PR state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+ from typing import Awaitable, Callable
8
+
9
+ from ctrlrelay.core.github import GitHubCLI, GitHubError
10
+ from ctrlrelay.core.obs import get_logger, log_event
11
+
12
+ _logger = get_logger("core.pr_watcher")
13
+
14
+ # After this many CONSECUTIVE transient failures we give up on the watch.
15
+ # gh can raise "transient-looking" errors (GitHubError, TimeoutError,
16
+ # OSError) for permanent problems too — bad repo, expired auth,
17
+ # permission change, missing gh binary. Without a cap, those would
18
+ # silently loop for the full 7-day timeout and never surface the
19
+ # problem.
20
+ #
21
+ # Sizing: at the default 60s poll interval, 60 consecutive failures
22
+ # covers a ~1-hour outage window. That's enough slack for routine VPN
23
+ # flaps, GitHub incidents, or auth token rotations without abandoning
24
+ # the watch, while still bounding truly permanent failures (deleted
25
+ # repo, revoked credentials) to an hour instead of the full 7-day
26
+ # timeout. A successful poll resets the counter, so genuinely
27
+ # intermittent failures never accumulate.
28
+ _TRANSIENT_FAILURE_CAP = 60
29
+
30
+
31
+ @dataclass
32
+ class PRWatcher:
33
+ """Watches PRs for merge events."""
34
+
35
+ github: GitHubCLI
36
+ poll_interval: int = 60
37
+
38
+ async def check_merged(self, repo: str, pr_number: int) -> bool:
39
+ """Check if a PR has been merged.
40
+
41
+ Args:
42
+ repo: Repository name (owner/repo)
43
+ pr_number: PR number
44
+
45
+ Returns:
46
+ True if merged, False otherwise
47
+ """
48
+ pr_state = await self.github.get_pr_state(repo, pr_number)
49
+ return pr_state.get("state") == "MERGED"
50
+
51
+ async def wait_for_merge(
52
+ self,
53
+ repo: str,
54
+ pr_number: int,
55
+ timeout: int = 86400,
56
+ on_poll: Callable[[], Awaitable[None]] | None = None,
57
+ ) -> bool:
58
+ """Wait for a PR to be merged.
59
+
60
+ Args:
61
+ repo: Repository name
62
+ pr_number: PR number
63
+ timeout: Max seconds to wait (default 24h)
64
+ on_poll: Optional callback after each poll
65
+
66
+ Returns:
67
+ True if merged within timeout, False otherwise
68
+
69
+ Transient-failure handling: individual ``gh`` failures
70
+ (``GitHubError``, ``TimeoutError``, network-level ``OSError``)
71
+ during a multi-day watch MUST NOT abort the loop — otherwise a
72
+ single flaky poll cycle permanently stops monitoring the PR.
73
+ Log a structured ``pr_watch.transient_error`` event and keep
74
+ polling. ``asyncio.CancelledError`` is always re-raised so a
75
+ clean shutdown propagates.
76
+ """
77
+ elapsed = 0
78
+ consecutive_failures = 0
79
+ while elapsed < timeout:
80
+ try:
81
+ if await self.check_merged(repo, pr_number):
82
+ return True
83
+ consecutive_failures = 0 # successful poll resets the counter
84
+ except asyncio.CancelledError:
85
+ raise
86
+ except (GitHubError, TimeoutError, OSError) as e:
87
+ consecutive_failures += 1
88
+ log_event(
89
+ _logger, "pr_watch.transient_error",
90
+ repo=repo, pr_number=pr_number,
91
+ reason=type(e).__name__,
92
+ error=str(e)[:200],
93
+ elapsed=elapsed,
94
+ consecutive_failures=consecutive_failures,
95
+ )
96
+ if consecutive_failures >= _TRANSIENT_FAILURE_CAP:
97
+ # Likely permanent: bad repo, expired auth, 404,
98
+ # missing gh binary. Fail fast instead of zombie-
99
+ # sleeping for 7 days.
100
+ log_event(
101
+ _logger, "pr_watch.abandoned_after_too_many_errors",
102
+ repo=repo, pr_number=pr_number,
103
+ consecutive_failures=consecutive_failures,
104
+ last_reason=type(e).__name__,
105
+ last_error=str(e)[:200],
106
+ )
107
+ raise
108
+ # Fall through to the sleep + retry.
109
+
110
+ if on_poll:
111
+ try:
112
+ await on_poll()
113
+ except asyncio.CancelledError:
114
+ raise
115
+ except Exception:
116
+ pass # on_poll is best-effort diagnostic plumbing
117
+
118
+ await asyncio.sleep(self.poll_interval)
119
+ elapsed += self.poll_interval
120
+
121
+ return False
@@ -0,0 +1,193 @@
1
+ """Retirement of BLOCKED questions the operator can no longer usefully answer.
2
+
3
+ A ``pending_resumes`` row deliberately outlives the in-session wait: the
4
+ transport gives up after ``ask_timeout_seconds``, but the row survives so
5
+ a reply arriving hours later still drives the resume. Nothing ever
6
+ retired those rows, so two failure modes accumulated:
7
+
8
+ - **Nobody answered.** Real numbers from one deployment: 46 questions
9
+ posted, 4 ever answered. The unanswered 42 stayed live targets for
10
+ orphan reply routing, and each sweep re-posted the same question.
11
+ - **Somebody acted outside ctrlrelay.** The PR got merged by hand, or
12
+ the issue was closed by a teammate. The question is now not just
13
+ unanswered but meaningless, and an answer to it would drive a resume
14
+ against state that has already moved on.
15
+
16
+ This module retires both. It only ever sets ``expired_at`` on rows that
17
+ are still unanswered, so a reply that lands in the same tick wins.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ import time
24
+ from typing import Any
25
+
26
+ from ctrlrelay.core.github import GitHubCLI, GitHubError
27
+ from ctrlrelay.core.obs import get_logger, log_event
28
+ from ctrlrelay.core.state import StateDB
29
+
30
+ _logger = get_logger("core.question_expiry")
31
+
32
+ # Captures both "PR #60" and "#60" forms the agent uses interchangeably
33
+ # in BLOCKED questions. The negative-lookahead on trailing digits avoids
34
+ # matching CVE-2026-... style identifiers; PR numbers are always >= 1.
35
+ _PR_NUM_RE = re.compile(r"(?:PR\s*)?#(\d+)(?!\d)", re.IGNORECASE)
36
+
37
+ # Per-call cap on the `gh api` probe. The sweep runs on a schedule and
38
+ # holds no repo lock, but a hung gh would still stall the whole pass;
39
+ # a single REST read has no business taking longer than this.
40
+ _STATE_PROBE_TIMEOUT_SECONDS = 15
41
+
42
+ # Ceiling on probes per run, so one pass cannot outlive its own cron
43
+ # interval. Unbounded, the cost is one serialized `gh api` call per
44
+ # cited number: 2000 backlogged rows against a slow GitHub is
45
+ # 2000 x 15s = 8.3h of a job that fires hourly, and the sweep would
46
+ # spend all day overlapping itself. Rows past the cap are simply
47
+ # examined on the next run — expiry is never urgent, and the TTL pass
48
+ # retires them on age regardless of whether they were ever probed.
49
+ # 200 x 15s worst case = 50min, inside the hour with room to spare.
50
+ _MAX_RESOLVED_PROBES_PER_RUN = 200
51
+
52
+ EXPIRY_REASON_TTL = "ttl"
53
+ EXPIRY_REASON_RESOLVED = "resolved_elsewhere"
54
+
55
+
56
+ def extract_referenced_numbers(question: str) -> list[str]:
57
+ """Pull deduplicated issue/PR numbers out of a question, in the
58
+ order the agent listed them."""
59
+ seen: set[str] = set()
60
+ out: list[str] = []
61
+ for n in _PR_NUM_RE.findall(question or ""):
62
+ if n in seen:
63
+ continue
64
+ seen.add(n)
65
+ out.append(n)
66
+ return out
67
+
68
+
69
+ async def _all_references_resolved(
70
+ github: GitHubCLI,
71
+ *,
72
+ repo: str,
73
+ numbers: list[str],
74
+ ) -> bool:
75
+ """True when every cited number is closed or merged.
76
+
77
+ Conservative on purpose. One still-open reference means the question
78
+ retains something to decide, so the row stays. Any probe error at
79
+ all — network, auth, a number that names nothing — returns False:
80
+ we would rather re-ask a dead question than silently drop a live
81
+ one, since the operator can ignore noise but cannot recover a
82
+ question that was never posted.
83
+ """
84
+ if not numbers:
85
+ return False
86
+
87
+ for number in numbers:
88
+ try:
89
+ state = await github.get_issue_or_pr_state(
90
+ repo,
91
+ int(number),
92
+ timeout=_STATE_PROBE_TIMEOUT_SECONDS,
93
+ )
94
+ except (GitHubError, TimeoutError, OSError, ValueError):
95
+ return False
96
+ except Exception:
97
+ return False
98
+
99
+ if (state.get("state") or "").lower() != "closed":
100
+ return False
101
+
102
+ return True
103
+
104
+
105
+ async def expire_stale_questions(
106
+ state_db: StateDB,
107
+ github: GitHubCLI | None,
108
+ *,
109
+ ttl_seconds: int,
110
+ now: int | None = None,
111
+ ) -> list[dict[str, Any]]:
112
+ """Retire questions that aged out or whose subject is already
113
+ resolved. Returns the rows actually expired.
114
+
115
+ TTL runs first and needs no network, so a GitHub outage still lets
116
+ the age-based sweep make progress. ``github=None`` disables only the
117
+ resolved-elsewhere pass.
118
+ """
119
+ now = int(time.time()) if now is None else now
120
+ expired: list[dict[str, Any]] = []
121
+
122
+ for row in state_db.list_expirable_pending_resumes(now - ttl_seconds):
123
+ if state_db.expire_pending_resume(row["session_id"], EXPIRY_REASON_TTL):
124
+ expired.append({**row, "expired_reason": EXPIRY_REASON_TTL})
125
+ log_event(
126
+ _logger,
127
+ "question.expired",
128
+ session_id=row["session_id"],
129
+ repo=row["repo"],
130
+ pipeline=row["pipeline"],
131
+ reason=EXPIRY_REASON_TTL,
132
+ age_seconds=now - int(row["created_at"]),
133
+ )
134
+
135
+ if github is None:
136
+ return expired
137
+
138
+ probes_used = 0
139
+ for row in state_db.list_unanswered_pending_resumes():
140
+ numbers = extract_referenced_numbers(row["question"])
141
+ # Rows citing nothing are skipped without spending budget —
142
+ # they can never be resolution-expired anyway.
143
+ if not numbers:
144
+ continue
145
+
146
+ # Check the budget against what this row will actually COST,
147
+ # before spending any of it. Testing `probes_used` alone lets a
148
+ # single row overshoot without limit: one question citing 300
149
+ # numbers passes a `0 >= 200` check and then issues all 300
150
+ # calls, ~75min of a job that fires hourly.
151
+ if len(numbers) > _MAX_RESOLVED_PROBES_PER_RUN:
152
+ # Bigger than any run's whole budget, so it can never be
153
+ # resolution-checked — expiry needs EVERY reference closed.
154
+ # Skip rather than break: one pathological row must not
155
+ # starve the rest. The TTL pass still retires it on age.
156
+ log_event(
157
+ _logger,
158
+ "question.expiry.row_exceeds_budget",
159
+ session_id=row["session_id"],
160
+ repo=row["repo"],
161
+ references=len(numbers),
162
+ budget=_MAX_RESOLVED_PROBES_PER_RUN,
163
+ )
164
+ continue
165
+ if probes_used + len(numbers) > _MAX_RESOLVED_PROBES_PER_RUN:
166
+ log_event(
167
+ _logger,
168
+ "question.expiry.probe_budget_exhausted",
169
+ budget=_MAX_RESOLVED_PROBES_PER_RUN,
170
+ probes_used=probes_used,
171
+ reason="remaining rows deferred to the next run",
172
+ )
173
+ break
174
+ probes_used += len(numbers)
175
+ if not await _all_references_resolved(
176
+ github, repo=row["repo"], numbers=numbers
177
+ ):
178
+ continue
179
+ if state_db.expire_pending_resume(
180
+ row["session_id"], EXPIRY_REASON_RESOLVED
181
+ ):
182
+ expired.append({**row, "expired_reason": EXPIRY_REASON_RESOLVED})
183
+ log_event(
184
+ _logger,
185
+ "question.expired",
186
+ session_id=row["session_id"],
187
+ repo=row["repo"],
188
+ pipeline=row["pipeline"],
189
+ reason=EXPIRY_REASON_RESOLVED,
190
+ references=",".join(numbers),
191
+ )
192
+
193
+ return expired