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.
- ctrlrelay/__init__.py +33 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +89 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +740 -0
- ctrlrelay/bridge/telegram_handler.py +136 -0
- ctrlrelay/cli.py +3138 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +778 -0
- ctrlrelay/core/dispatcher.py +372 -0
- ctrlrelay/core/github.py +432 -0
- ctrlrelay/core/network.py +277 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +920 -0
- ctrlrelay/core/pr_verifier.py +230 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/question_expiry.py +193 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +642 -0
- ctrlrelay/core/worktree.py +893 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/gh_protocol.py +47 -0
- ctrlrelay/install.py +203 -0
- ctrlrelay/personalization/__init__.py +26 -0
- ctrlrelay/personalization/manager.py +1183 -0
- ctrlrelay/personalization/paths.py +143 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +1367 -0
- ctrlrelay/pipelines/post_merge.py +411 -0
- ctrlrelay/pipelines/secops.py +1018 -0
- ctrlrelay/pipelines/task.py +713 -0
- ctrlrelay/setup.py +617 -0
- ctrlrelay/templates/__init__.py +0 -0
- ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
- ctrlrelay/templates/launchd/__init__.py +0 -0
- ctrlrelay/templates/launchd/bridge.plist.template +37 -0
- ctrlrelay/templates/launchd/poller.plist.template +44 -0
- ctrlrelay/templates/systemd/__init__.py +0 -0
- ctrlrelay/templates/systemd/bridge.service.template +20 -0
- ctrlrelay/templates/systemd/poller.service.template +25 -0
- ctrlrelay/transports/__init__.py +34 -0
- ctrlrelay/transports/base.py +74 -0
- ctrlrelay/transports/file_mock.py +103 -0
- ctrlrelay/transports/socket_client.py +298 -0
- ctrlrelay-0.10.0.dist-info/METADATA +254 -0
- ctrlrelay-0.10.0.dist-info/RECORD +53 -0
- ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
- ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
- 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
|