alissa-tools-github-revloop 0.16.7__tar.gz → 0.16.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {alissa_tools_github_revloop-0.16.7/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.9}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/__main__.py +25 -1
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/loop.py +360 -20
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/state.py +313 -23
- alissa_tools_github_revloop-0.16.9/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.16.7/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/README.md +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/setup.py +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/config.py +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.9}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -19,7 +19,7 @@ from .config import (
|
|
|
19
19
|
resolve_config_path,
|
|
20
20
|
)
|
|
21
21
|
from .ghclient import IdentityMismatch
|
|
22
|
-
from .loop import ReviewWatcher
|
|
22
|
+
from .loop import LedgerUnwritable, ReviewWatcher
|
|
23
23
|
from .proc import CommandError
|
|
24
24
|
|
|
25
25
|
log = logging.getLogger(__name__)
|
|
@@ -189,6 +189,16 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
189
189
|
datefmt="%H:%M:%S",
|
|
190
190
|
)
|
|
191
191
|
|
|
192
|
+
# STARTUP and STEADY STATE are separated on purpose (issue #62). The
|
|
193
|
+
# handlers below label a FileNotFoundError / ValueError "config error" and
|
|
194
|
+
# exit 2, which is right for the startup phase -- a missing config file or
|
|
195
|
+
# an unparseable value cannot be fixed by trying again. It was fatally
|
|
196
|
+
# wrong for the poll loop, where the same classes mean a transient
|
|
197
|
+
# subprocess ENOENT or a bad response: `run_forever` now firewalls those
|
|
198
|
+
# per iteration and never lets them reach here at all. The one-shot modes
|
|
199
|
+
# (`--pr`, `--once`) still surface a failure to their caller, as a
|
|
200
|
+
# one-shot must -- including a refused pass, which leaves through the
|
|
201
|
+
# LedgerUnwritable handler below with exit 1 rather than looking clean.
|
|
192
202
|
try:
|
|
193
203
|
config = resolve_config(args)
|
|
194
204
|
log.info("workspace: %s", config.workspace_root)
|
|
@@ -207,6 +217,20 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
207
217
|
watcher.poll_once()
|
|
208
218
|
else:
|
|
209
219
|
watcher.run_forever()
|
|
220
|
+
except LedgerUnwritable as exc:
|
|
221
|
+
# Only reachable from `--once`: run_forever handles its own refusals and
|
|
222
|
+
# keeps polling. A one-shot REPORTS rather than retries, so this must
|
|
223
|
+
# not look like a clean pass to `... --once && echo ok` or to a health
|
|
224
|
+
# probe. Exit 1 ("the environment failed"), not the 2 reserved for
|
|
225
|
+
# "your config is wrong" -- a config error tells you to edit a file, an
|
|
226
|
+
# unwritable ledger tells you to look at the volume mount.
|
|
227
|
+
print(
|
|
228
|
+
f"ledger error: {exc} is not writable — no decisions were taken. "
|
|
229
|
+
"The daemon refuses to spawn, escalate, grant or post what it "
|
|
230
|
+
"cannot record; fix the volume mount or its ownership.",
|
|
231
|
+
file=sys.stderr,
|
|
232
|
+
)
|
|
233
|
+
return 1
|
|
210
234
|
except IdentityMismatch as exc:
|
|
211
235
|
print(f"identity error: {exc}", file=sys.stderr)
|
|
212
236
|
return 2
|
|
@@ -16,7 +16,7 @@ import secrets
|
|
|
16
16
|
import sqlite3
|
|
17
17
|
import time
|
|
18
18
|
from collections import Counter
|
|
19
|
-
from dataclasses import dataclass
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
20
|
from enum import Enum
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
@@ -99,6 +99,152 @@ MAX_VERDICT_POST_ATTEMPTS = 5
|
|
|
99
99
|
# day, per stuck PR, with the operator paged exactly once.
|
|
100
100
|
MAX_VERDICT_POST_BACKOFF_SECONDS = 60 * 60
|
|
101
101
|
|
|
102
|
+
# -- the poll-failure firewall (issue #62) ------------------------------------
|
|
103
|
+
#
|
|
104
|
+
# 2026-07-29 killed the Railway daemon three times in one day: a subprocess
|
|
105
|
+
# ENOENT on the `alissa` CLI (an image-layer file that vanished mid-run) and a
|
|
106
|
+
# readonly-sqlite snapshot write both escaped `poll_once`, and __main__'s
|
|
107
|
+
# startup-shaped handlers turned each into `exit 2`. One bad poll must never end
|
|
108
|
+
# the daemon: the steady state's contract is degraded-but-alive with a loud
|
|
109
|
+
# signal, never a silent exit. Startup config errors keep the fast exit -- those
|
|
110
|
+
# a restart genuinely cannot fix.
|
|
111
|
+
|
|
112
|
+
# Ceiling on the doubling backoff a failing poll applies. Matches the
|
|
113
|
+
# rate-limit branch's cap: a daemon that has been failing for a quarter of an
|
|
114
|
+
# hour gains nothing from polling more often than every 15 minutes, and a
|
|
115
|
+
# recovered substrate is picked up within one window.
|
|
116
|
+
POLL_BACKOFF_CAP_SECONDS = 900
|
|
117
|
+
|
|
118
|
+
# How long one exception CLASS must fire on every consecutive poll before the
|
|
119
|
+
# firewall stops calling it transient and escalates to a page-worthy ERROR.
|
|
120
|
+
# Half an hour is several backoff windows -- long past anything a container
|
|
121
|
+
# blip explains -- and still inside the window an operator can act on the same
|
|
122
|
+
# day. A different class arriving resets the streak: that is a different
|
|
123
|
+
# condition, not a continuation of this one.
|
|
124
|
+
POLL_ESCALATE_SECONDS = 30 * 60
|
|
125
|
+
|
|
126
|
+
# Streak limiting for the firewall's log line: the first few failures of a
|
|
127
|
+
# streak are logged in full, then one in every POLL_FAILURE_LOG_EVERY, so a
|
|
128
|
+
# substrate outage costs a handful of lines an hour instead of one per poll.
|
|
129
|
+
# The escalation crossing and the recovery line are logged unconditionally --
|
|
130
|
+
# both are state changes, and suppressing either would hide the very transition
|
|
131
|
+
# the log exists to show.
|
|
132
|
+
POLL_FAILURE_LOG_HEAD = 3
|
|
133
|
+
POLL_FAILURE_LOG_EVERY = 10
|
|
134
|
+
|
|
135
|
+
# Poll failures whose message is the whole diagnosis: CommandError already
|
|
136
|
+
# carries the command and its stderr, so a traceback adds noise. Anything else
|
|
137
|
+
# reaching the firewall is by definition unanticipated -- log where it came
|
|
138
|
+
# from.
|
|
139
|
+
EXPECTED_POLL_FAILURES = (CommandError,)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class LedgerUnwritable(RuntimeError):
|
|
143
|
+
"""Raised by `poll_once` when the ledger gate refuses the pass.
|
|
144
|
+
|
|
145
|
+
A dedicated signal rather than an empty result, because the two callers
|
|
146
|
+
have to tell "refused" apart from "polled, nothing to do" and a bare `[]`
|
|
147
|
+
cannot (PR #63 round-2 major and one of its minors, both closed by this):
|
|
148
|
+
|
|
149
|
+
* `run_forever` must leave the firewall's failure streak COMPLETELY alone.
|
|
150
|
+
A refused pass is not evidence that anything cleared -- it is evidence
|
|
151
|
+
the daemon did not look -- so counting it as a success printed a false
|
|
152
|
+
recovery line and re-armed the escalation clock for a fault that was
|
|
153
|
+
still failing. That is the same power the RateLimited branch was stripped
|
|
154
|
+
of in round 2, for the same reason.
|
|
155
|
+
* `--once` must exit non-zero. A one-shot reports rather than retries, and
|
|
156
|
+
a health probe or `... --once && echo ok` reading a refused pass as a
|
|
157
|
+
clean one is the one failure mode it cannot survive.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class Streak:
|
|
163
|
+
"""A run of consecutive identical outcomes, with the log policy attached:
|
|
164
|
+
how many, how long, whether it has been escalated, and whether THIS one is
|
|
165
|
+
worth a line.
|
|
166
|
+
|
|
167
|
+
Extracted so the two callers that need "streak, limit, escalate, recover"
|
|
168
|
+
-- the poll firewall and the ledger gate -- cannot disagree about it (PR #63
|
|
169
|
+
round-2 nit; the gate's hand-rolled copy dropped the crossing bypass, which
|
|
170
|
+
made the parameters table's "the escalation crossing is never suppressed"
|
|
171
|
+
false at any poll interval other than 60s).
|
|
172
|
+
|
|
173
|
+
Deliberately a value object taking `now` from its caller rather than reading
|
|
174
|
+
the clock: the escalation rule is a statement about elapsed time, and it has
|
|
175
|
+
to be testable without sleeping through it.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
count: int = 0
|
|
179
|
+
first_at: float = 0.0
|
|
180
|
+
escalated: bool = False
|
|
181
|
+
|
|
182
|
+
def record(self, now: float) -> tuple[bool, bool]:
|
|
183
|
+
"""Fold one occurrence in. Returns (log_this_one, escalated_just_now).
|
|
184
|
+
|
|
185
|
+
The crossing BYPASSES the streak limit: it is a state change, and
|
|
186
|
+
suppressing it would hide the one transition the log exists to show.
|
|
187
|
+
"""
|
|
188
|
+
if self.count == 0:
|
|
189
|
+
self.first_at, self.escalated = now, False
|
|
190
|
+
self.count += 1
|
|
191
|
+
crossing = not self.escalated and now - self.first_at >= POLL_ESCALATE_SECONDS
|
|
192
|
+
if crossing:
|
|
193
|
+
self.escalated = True
|
|
194
|
+
should_log = (
|
|
195
|
+
crossing
|
|
196
|
+
or self.count <= POLL_FAILURE_LOG_HEAD
|
|
197
|
+
or self.count % POLL_FAILURE_LOG_EVERY == 0
|
|
198
|
+
)
|
|
199
|
+
return should_log, crossing
|
|
200
|
+
|
|
201
|
+
def held(self, now: float) -> float:
|
|
202
|
+
"""Seconds since the streak began. Zero when there is no streak."""
|
|
203
|
+
return 0.0 if self.count == 0 else now - self.first_at
|
|
204
|
+
|
|
205
|
+
def clear(self) -> None:
|
|
206
|
+
self.count, self.first_at, self.escalated = 0, 0.0, False
|
|
207
|
+
|
|
208
|
+
def resolve(self, now: float) -> tuple[int, float] | None:
|
|
209
|
+
"""End the streak. Returns (occurrences, seconds), or None if there was
|
|
210
|
+
no streak -- the caller logs the recovery, which is the only evidence in
|
|
211
|
+
the log that a degraded daemon came back on its own."""
|
|
212
|
+
if self.count == 0:
|
|
213
|
+
return None
|
|
214
|
+
ended = (self.count, now - self.first_at)
|
|
215
|
+
self.clear()
|
|
216
|
+
return ended
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
220
|
+
class PollFailures:
|
|
221
|
+
"""The firewall's memory of the current run of consecutive poll failures.
|
|
222
|
+
|
|
223
|
+
All the counting, limiting, escalation and recovery lives in `Streak`; what
|
|
224
|
+
is genuinely this class's own is the KEY. A streak is identified by the
|
|
225
|
+
exception CLASS, and a different class arriving mid-outage starts a new one
|
|
226
|
+
(re-arming escalation) because it is a different fault -- reporting "ENOENT
|
|
227
|
+
has been failing for 40 minutes" when the last 30 were sqlite errors would
|
|
228
|
+
be a lie the operator acts on.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
kind: str | None = None
|
|
232
|
+
streak: Streak = field(default_factory=Streak)
|
|
233
|
+
|
|
234
|
+
def record(self, exc: BaseException, now: float) -> tuple[bool, bool]:
|
|
235
|
+
"""Fold one failure in. Returns (log_this_one, escalated_just_now)."""
|
|
236
|
+
kind = type(exc).__name__
|
|
237
|
+
if kind != self.kind:
|
|
238
|
+
self.kind = kind
|
|
239
|
+
self.streak.clear()
|
|
240
|
+
return self.streak.record(now)
|
|
241
|
+
|
|
242
|
+
def resolve(self, now: float) -> tuple[int, float] | None:
|
|
243
|
+
"""Clear the streak on a successful poll."""
|
|
244
|
+
self.kind = None
|
|
245
|
+
return self.streak.resolve(now)
|
|
246
|
+
|
|
247
|
+
|
|
102
248
|
# The GitHub review states the CI gate can produce for a round it refused to
|
|
103
249
|
# approve: a red head lands as CHANGES_REQUESTED, a rollup that never concluded
|
|
104
250
|
# as COMMENTED. Read by _convergence_reason, which must not converge on an
|
|
@@ -181,8 +327,9 @@ CHECKS_UNSETTLED_LEAD = (
|
|
|
181
327
|
"concluded.**\n\n"
|
|
182
328
|
"{detail}\n\n"
|
|
183
329
|
"This round's verdict was held for {waited} min waiting for the head's "
|
|
184
|
-
"checks to settle ({bound} min bound) and they did not, so it is
|
|
185
|
-
"as a comment: an approve would claim a head this loop never saw go
|
|
330
|
+
"checks to settle ({bound} min bound){total_note} and they did not, so it is "
|
|
331
|
+
"recorded as a comment: an approve would claim a head this loop never saw go "
|
|
332
|
+
"green. "
|
|
186
333
|
"Nothing about the review itself changed — the verdict below is the round's "
|
|
187
334
|
"own.\n\n"
|
|
188
335
|
"Submitting this review consumes the pending review request, so the daemon "
|
|
@@ -221,6 +368,13 @@ CHECKS_UNSETTLED_PAGE = (
|
|
|
221
368
|
"No label was touched and no further round is queued."
|
|
222
369
|
)
|
|
223
370
|
|
|
371
|
+
# Appended to `{bound}` above only when the hold was PROMOTED -- an unreadable
|
|
372
|
+
# wait that became a genuine pending one restarts the clock, so the bound the
|
|
373
|
+
# operator configured applies per condition and the round can be held up to
|
|
374
|
+
# twice it. Saying "held 30 min (30 min bound)" after 60 real minutes is the
|
|
375
|
+
# report being wrong about the one number an operator tunes.
|
|
376
|
+
CHECKS_TOTAL_HELD = ", {total} min in total across both waits,"
|
|
377
|
+
|
|
224
378
|
# The `{detail}` above, per reason the rollup did not settle.
|
|
225
379
|
CHECKS_STILL_RUNNING = "Still running at the bound: {names}."
|
|
226
380
|
CHECKS_UNREADABLE = (
|
|
@@ -801,6 +955,14 @@ class ReviewWatcher:
|
|
|
801
955
|
# rollup (two API calls) on every poll, forever, for every PR with an
|
|
802
956
|
# owed approve. In-memory for the same reason _dry_run_drift is.
|
|
803
957
|
self._dry_run_rollups: dict[tuple[str, int, int, str], str] = {}
|
|
958
|
+
# Consecutive passes refused by the ledger gate in poll_once, and when
|
|
959
|
+
# the refusal began -- the same streak-limit-then-escalate shape the
|
|
960
|
+
# poll firewall uses, for the same reason: a read-only volume refuses
|
|
961
|
+
# every pass, and one line per poll would bury the condition it is
|
|
962
|
+
# reporting. In memory because it describes THIS process's degraded
|
|
963
|
+
# state, and because the only durable place to put it is the ledger
|
|
964
|
+
# that cannot be written.
|
|
965
|
+
self._ledger_streak = Streak()
|
|
804
966
|
|
|
805
967
|
# -- per-PR decision ---------------------------------------------------
|
|
806
968
|
|
|
@@ -1165,22 +1327,29 @@ class ReviewWatcher:
|
|
|
1165
1327
|
# never "restart whenever the state changes" -- a reader flapping between
|
|
1166
1328
|
# the two would then push the bound out forever, which is precisely the
|
|
1167
1329
|
# unbounded hold this bound exists to prevent.
|
|
1168
|
-
|
|
1169
|
-
promoted =
|
|
1170
|
-
if
|
|
1171
|
-
|
|
1330
|
+
hold = self.state.checks_hold(pr.full_name, pr.number, round_)
|
|
1331
|
+
promoted = hold.condition == CHECKS_UNKNOWN and rollup.state == CHECKS_PENDING
|
|
1332
|
+
if hold.since is None or promoted:
|
|
1333
|
+
self.state.record_checks_hold(
|
|
1172
1334
|
pr.full_name, pr.number, round_, rollup.state
|
|
1173
1335
|
)
|
|
1174
|
-
|
|
1336
|
+
hold = self.state.checks_hold(pr.full_name, pr.number, round_)
|
|
1337
|
+
# Two numbers, both reported: `waited` is the wait THIS condition has had
|
|
1338
|
+
# and is what the bound applies to; `held` is how long the round has been
|
|
1339
|
+
# held at all. They differ by up to a full bound once a hold has been
|
|
1340
|
+
# promoted, so a report that shows only the first tells an operator who
|
|
1341
|
+
# set 30 minutes that a 60-minute hold waited 30.
|
|
1342
|
+
waited = max(time.time() - (hold.since or time.time()), 0)
|
|
1343
|
+
held = max(time.time() - (hold.first_at or time.time()), 0)
|
|
1175
1344
|
bound = self.config.checks_wait_seconds
|
|
1176
1345
|
if waited < bound:
|
|
1177
1346
|
# One line per poll, one activity note per held round; see
|
|
1178
1347
|
# checks_hold_kind.
|
|
1179
1348
|
log.info(
|
|
1180
|
-
"%s round %d: holding its %s — CI rollup at %s is %s (%dm
|
|
1181
|
-
"%dm bound
|
|
1349
|
+
"%s round %d: holding its %s — CI rollup at %s is %s (%dm on this "
|
|
1350
|
+
"condition of the %dm bound; %dm held in total)",
|
|
1182
1351
|
pr.slug, round_, VERDICT_APPROVE, judged[:8], rollup.summary,
|
|
1183
|
-
waited // 60, bound // 60,
|
|
1352
|
+
waited // 60, bound // 60, held // 60,
|
|
1184
1353
|
)
|
|
1185
1354
|
self._note_checks_hold(pr, round_, judged, rollup)
|
|
1186
1355
|
return ChecksGate(
|
|
@@ -1188,7 +1357,8 @@ class ReviewWatcher:
|
|
|
1188
1357
|
Action.AWAITING_POST,
|
|
1189
1358
|
f"round {round_} holds its {VERDICT_APPROVE} — the CI rollup "
|
|
1190
1359
|
f"at {judged[:8]} is {rollup.summary}; "
|
|
1191
|
-
f"{int((bound - waited) // 60)}m of the wait bound left"
|
|
1360
|
+
f"{int((bound - waited) // 60)}m of the wait bound left "
|
|
1361
|
+
f"({int(held // 60)}m held in total)",
|
|
1192
1362
|
round_,
|
|
1193
1363
|
task_ref=task.ref,
|
|
1194
1364
|
),
|
|
@@ -1196,10 +1366,10 @@ class ReviewWatcher:
|
|
|
1196
1366
|
)
|
|
1197
1367
|
|
|
1198
1368
|
log.warning(
|
|
1199
|
-
"%s round %d: the CI rollup at %s is still %s after %dm
|
|
1200
|
-
"the %s envelope as a %s
|
|
1201
|
-
"head",
|
|
1202
|
-
pr.slug, round_, judged[:8], rollup.summary, waited // 60,
|
|
1369
|
+
"%s round %d: the CI rollup at %s is still %s after %dm on this "
|
|
1370
|
+
"condition (%dm held in total) — recording the %s envelope as a %s "
|
|
1371
|
+
"review, never an APPROVE on an unverified head",
|
|
1372
|
+
pr.slug, round_, judged[:8], rollup.summary, waited // 60, held // 60,
|
|
1203
1373
|
VERDICT_APPROVE, EVENT_COMMENT,
|
|
1204
1374
|
)
|
|
1205
1375
|
detail = (
|
|
@@ -1216,6 +1386,11 @@ class ReviewWatcher:
|
|
|
1216
1386
|
detail=detail,
|
|
1217
1387
|
waited=int(waited // 60),
|
|
1218
1388
|
bound=int(bound // 60),
|
|
1389
|
+
total_note=(
|
|
1390
|
+
CHECKS_TOTAL_HELD.format(total=int(held // 60))
|
|
1391
|
+
if hold.promoted
|
|
1392
|
+
else ""
|
|
1393
|
+
),
|
|
1219
1394
|
),
|
|
1220
1395
|
state=rollup.state,
|
|
1221
1396
|
detail=detail,
|
|
@@ -2748,6 +2923,42 @@ class ReviewWatcher:
|
|
|
2748
2923
|
# -- polling -----------------------------------------------------------
|
|
2749
2924
|
|
|
2750
2925
|
def poll_once(self) -> list[tuple[str, Decision]]:
|
|
2926
|
+
# THE LEDGER GATE (issue #62, PR #63 round-1 blocker). Nothing below
|
|
2927
|
+
# may run when the ledger cannot record what it does.
|
|
2928
|
+
#
|
|
2929
|
+
# Keeping the correctness writes strict aborts the pass that fails, but
|
|
2930
|
+
# the firewall in run_forever hands the loop straight back here -- and
|
|
2931
|
+
# by then the side effect is already taken. Concretely, over a
|
|
2932
|
+
# read-only volume: _spawn enqueues a reviewer session, record_spawn
|
|
2933
|
+
# raises, the pass dies, and the next pass finds no spawn row (the
|
|
2934
|
+
# in-flight check is a READ of the row that never landed), so it
|
|
2935
|
+
# enqueues another one. Every poll. Each a live agent that submits a
|
|
2936
|
+
# real review and burns the round budget. Before the firewall existed
|
|
2937
|
+
# the daemon died after one such duplicate -- bad, but bounded.
|
|
2938
|
+
#
|
|
2939
|
+
# So the gate is above everything, including the reap sweep (a kill is
|
|
2940
|
+
# a side effect and record_reap is a correctness write). The pass takes
|
|
2941
|
+
# no decisions and returns empty; the loop stays alive and keeps
|
|
2942
|
+
# probing. One race remains and is deliberate: a volume that flips
|
|
2943
|
+
# read-only BETWEEN this probe and record_spawn costs one duplicate,
|
|
2944
|
+
# which is the pre-firewall blast radius, and every pass after it is
|
|
2945
|
+
# gated. Closing it would mean recording before enqueuing, which trades
|
|
2946
|
+
# this for an orphan row that wedges the round for a full stale window
|
|
2947
|
+
# on any enqueue failure.
|
|
2948
|
+
# DRY-RUN IS EXEMPT, and vacuously so: it already suppresses every side
|
|
2949
|
+
# effect AND every correctness write (`_spawn` skips record_spawn, the
|
|
2950
|
+
# reaper logs instead of killing, the drift/cap-out/deferral paths
|
|
2951
|
+
# return before both their comment and their record). Its only ledger
|
|
2952
|
+
# write is the snapshot, which this module classifies as best-effort
|
|
2953
|
+
# telemetry and which _write_snapshot writes in dry-run deliberately.
|
|
2954
|
+
# So the gate would protect nothing there and cost the operator the one
|
|
2955
|
+
# tool that answers "what would you do right now" -- asked, precisely,
|
|
2956
|
+
# during the substrate incident this whole change is about.
|
|
2957
|
+
if not self.config.dry_run and not self.state.writable():
|
|
2958
|
+
self._note_ledger_unwritable()
|
|
2959
|
+
raise LedgerUnwritable(str(self.config.state_db))
|
|
2960
|
+
self._note_ledger_writable()
|
|
2961
|
+
|
|
2751
2962
|
# Sweep BEFORE evaluating: a full worker is exactly when a fresh spawn
|
|
2752
2963
|
# needs the slot a finished session is squatting on. Deliberately not
|
|
2753
2964
|
# inside the per-request loop below — the sweep must reach sessions
|
|
@@ -2785,6 +2996,48 @@ class ReviewWatcher:
|
|
|
2785
2996
|
)
|
|
2786
2997
|
return results
|
|
2787
2998
|
|
|
2999
|
+
def _note_ledger_unwritable(self) -> None:
|
|
3000
|
+
"""Report a pass refused because the ledger cannot record it.
|
|
3001
|
+
|
|
3002
|
+
Shares `Streak` with the poll firewall, so the streak limit, the
|
|
3003
|
+
escalation window and -- the part the hand-rolled copy got wrong -- the
|
|
3004
|
+
crossing's bypass of that limit are one implementation. A daemon that
|
|
3005
|
+
is up, polling, and deciding NOTHING is the most misleading state it
|
|
3006
|
+
can be in, so once the condition outlasts POLL_ESCALATE_SECONDS every
|
|
3007
|
+
logged line says so at page-worthy level.
|
|
3008
|
+
"""
|
|
3009
|
+
now = time.monotonic()
|
|
3010
|
+
should_log, crossing = self._ledger_streak.record(now)
|
|
3011
|
+
if not should_log:
|
|
3012
|
+
return
|
|
3013
|
+
log.log(
|
|
3014
|
+
logging.ERROR if self._ledger_streak.escalated else logging.WARNING,
|
|
3015
|
+
"ledger at %s cannot be written — skipping this pass entirely "
|
|
3016
|
+
"(%d consecutive, %.0f min)%s: the daemon will not spawn, escalate, "
|
|
3017
|
+
"grant or post what it cannot record. It is alive and re-probing "
|
|
3018
|
+
"every poll; no review will be queued until the volume is writable.",
|
|
3019
|
+
self.config.state_db,
|
|
3020
|
+
self._ledger_streak.count,
|
|
3021
|
+
self._ledger_streak.held(now) / 60,
|
|
3022
|
+
" — this is no longer transient" if crossing else "",
|
|
3023
|
+
)
|
|
3024
|
+
|
|
3025
|
+
def _note_ledger_writable(self) -> None:
|
|
3026
|
+
"""Announce that the gate has re-opened. Unconditional, like the
|
|
3027
|
+
firewall's recovery line: the operator's last word on a degraded
|
|
3028
|
+
daemon must not be the degradation."""
|
|
3029
|
+
ended = self._ledger_streak.resolve(time.monotonic())
|
|
3030
|
+
if ended is None:
|
|
3031
|
+
return
|
|
3032
|
+
skipped, seconds = ended
|
|
3033
|
+
log.info(
|
|
3034
|
+
"ledger at %s is writable again after %d skipped pass(es) over "
|
|
3035
|
+
"%.0fs — resuming normal decisions",
|
|
3036
|
+
self.config.state_db,
|
|
3037
|
+
skipped,
|
|
3038
|
+
seconds,
|
|
3039
|
+
)
|
|
3040
|
+
|
|
2788
3041
|
def _stage_record(self, slug: str, decision: Decision) -> dict:
|
|
2789
3042
|
"""One per-item entry of a poll snapshot's compact JSON: the PR
|
|
2790
3043
|
reference (the slug and the number parsed from it), the current stage
|
|
@@ -2863,7 +3116,16 @@ class ReviewWatcher:
|
|
|
2863
3116
|
def run_forever(self) -> None:
|
|
2864
3117
|
# preflight() is the caller's responsibility -- the CLI runs it once for
|
|
2865
3118
|
# every mode, so calling it here too would double every check.
|
|
3119
|
+
#
|
|
3120
|
+
# Every exception a poll can raise is caught HERE (issue #62). The
|
|
3121
|
+
# daemon's steady state has no fatal errors: a transient subprocess
|
|
3122
|
+
# ENOENT, a parse error, a readonly ledger -- any of them is one bad
|
|
3123
|
+
# poll, and poll N+1 may well succeed. Only KeyboardInterrupt and
|
|
3124
|
+
# SystemExit pass through (neither is an `Exception`), and only startup
|
|
3125
|
+
# -- resolve_config, before this loop is ever entered -- still exits
|
|
3126
|
+
# fast.
|
|
2866
3127
|
backoff = self.config.poll_interval
|
|
3128
|
+
failures = PollFailures()
|
|
2867
3129
|
while True:
|
|
2868
3130
|
# The sleep lives INSIDE the KeyboardInterrupt guard: with a 60s
|
|
2869
3131
|
# poll interval (up to 900s backing off) the loop spends nearly
|
|
@@ -2873,13 +3135,91 @@ class ReviewWatcher:
|
|
|
2873
3135
|
try:
|
|
2874
3136
|
self.poll_once()
|
|
2875
3137
|
backoff = self.config.poll_interval
|
|
3138
|
+
self._note_poll_recovered(failures)
|
|
3139
|
+
except LedgerUnwritable:
|
|
3140
|
+
# Already logged, streak-limited and escalating, by the
|
|
3141
|
+
# gate itself. What matters HERE is what is NOT done: the
|
|
3142
|
+
# firewall's `failures` is left completely untouched, so a
|
|
3143
|
+
# fault that is still failing keeps counting toward its own
|
|
3144
|
+
# page instead of having the clock re-armed by a pass the
|
|
3145
|
+
# daemon never took. The backoff DOES reset to the poll
|
|
3146
|
+
# interval, deliberately: probing at cadence is the point
|
|
3147
|
+
# of the gate, and inheriting a failing streak's 15-minute
|
|
3148
|
+
# backoff would leave a healed volume unnoticed that long.
|
|
3149
|
+
backoff = self.config.poll_interval
|
|
2876
3150
|
except RateLimited as exc:
|
|
2877
|
-
|
|
3151
|
+
# Not a failure of the daemon: GitHub is telling it to slow
|
|
3152
|
+
# down, and it does. It does NOT count toward the firewall's
|
|
3153
|
+
# streak -- and, just as deliberately, it does not END one
|
|
3154
|
+
# either (PR #63 round-1 major). A rate limit is not
|
|
3155
|
+
# evidence that a substrate fault cleared: `review_requests`
|
|
3156
|
+
# is the first GitHub call in the pass, so it can pre-empt
|
|
3157
|
+
# the failing call site entirely, and resolving the streak
|
|
3158
|
+
# here let a busy hour re-arm the escalation clock forever
|
|
3159
|
+
# and cancel a page the DoD requires. The streak is left
|
|
3160
|
+
# exactly as it was; only a genuinely successful poll ends
|
|
3161
|
+
# one, and that path logs the recovery.
|
|
3162
|
+
backoff = min(backoff * 2, POLL_BACKOFF_CAP_SECONDS)
|
|
2878
3163
|
log.warning("rate limited (%s) — backing off %ds", exc, backoff)
|
|
2879
|
-
except
|
|
2880
|
-
backoff = min(backoff * 2,
|
|
2881
|
-
|
|
3164
|
+
except Exception as exc:
|
|
3165
|
+
backoff = min(backoff * 2, POLL_BACKOFF_CAP_SECONDS)
|
|
3166
|
+
self._note_poll_failure(failures, exc, backoff)
|
|
2882
3167
|
time.sleep(backoff)
|
|
2883
3168
|
except KeyboardInterrupt:
|
|
2884
3169
|
log.info("stopping")
|
|
2885
3170
|
return
|
|
3171
|
+
|
|
3172
|
+
def _note_poll_failure(
|
|
3173
|
+
self, failures: PollFailures, exc: Exception, backoff: int
|
|
3174
|
+
) -> None:
|
|
3175
|
+
"""Log one firewalled poll failure, streak-limited and escalating.
|
|
3176
|
+
|
|
3177
|
+
WARNING while the fault still looks transient, ERROR once the same
|
|
3178
|
+
class has fired on every poll for POLL_ESCALATE_SECONDS -- the daemon
|
|
3179
|
+
is alive either way, so the log level is the only thing that can tell
|
|
3180
|
+
an operator "this one is not healing".
|
|
3181
|
+
"""
|
|
3182
|
+
should_log, crossing = failures.record(exc, time.monotonic())
|
|
3183
|
+
if not should_log:
|
|
3184
|
+
return
|
|
3185
|
+
# A class the firewall did not anticipate gets its traceback; the ones
|
|
3186
|
+
# that carry their own diagnosis do not (see EXPECTED_POLL_FAILURES).
|
|
3187
|
+
traced = not isinstance(exc, EXPECTED_POLL_FAILURES)
|
|
3188
|
+
if crossing:
|
|
3189
|
+
log.error(
|
|
3190
|
+
"poll has failed with %s on every attempt for %.0f min "
|
|
3191
|
+
"(%d consecutive failures, latest: %s) — the daemon is alive and "
|
|
3192
|
+
"still retrying every %ds, but this is no longer transient",
|
|
3193
|
+
failures.kind,
|
|
3194
|
+
failures.streak.held(time.monotonic()) / 60,
|
|
3195
|
+
failures.streak.count,
|
|
3196
|
+
exc,
|
|
3197
|
+
backoff,
|
|
3198
|
+
exc_info=traced,
|
|
3199
|
+
)
|
|
3200
|
+
return
|
|
3201
|
+
log.log(
|
|
3202
|
+
logging.ERROR if failures.streak.escalated else logging.WARNING,
|
|
3203
|
+
"poll failed (%s: %s) — failure %d of this streak; retrying in %ds",
|
|
3204
|
+
failures.kind,
|
|
3205
|
+
exc,
|
|
3206
|
+
failures.streak.count,
|
|
3207
|
+
backoff,
|
|
3208
|
+
exc_info=traced,
|
|
3209
|
+
)
|
|
3210
|
+
|
|
3211
|
+
@staticmethod
|
|
3212
|
+
def _note_poll_recovered(failures: PollFailures) -> None:
|
|
3213
|
+
"""Announce that a failing streak ended. Unconditional: the recovery is
|
|
3214
|
+
the counterpart of the escalation, and a streak that healed silently
|
|
3215
|
+
leaves an operator reading the last ERROR as the current state."""
|
|
3216
|
+
ended = failures.resolve(time.monotonic())
|
|
3217
|
+
if ended is None:
|
|
3218
|
+
return
|
|
3219
|
+
count, seconds = ended
|
|
3220
|
+
log.info(
|
|
3221
|
+
"poll recovered after %d consecutive failure(s) over %.0fs — "
|
|
3222
|
+
"resuming normal polling",
|
|
3223
|
+
count,
|
|
3224
|
+
seconds,
|
|
3225
|
+
)
|
|
@@ -26,11 +26,15 @@ it, alongside the untouched legacy ledgers.
|
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
28
|
import json
|
|
29
|
+
import logging
|
|
29
30
|
import sqlite3
|
|
30
31
|
import time
|
|
31
|
-
from collections.abc import Iterable
|
|
32
|
+
from collections.abc import Callable, Iterable
|
|
33
|
+
from dataclasses import dataclass
|
|
32
34
|
from pathlib import Path
|
|
33
35
|
|
|
36
|
+
log = logging.getLogger(__name__)
|
|
37
|
+
|
|
34
38
|
# Poll-snapshot retention: the newest N rows are kept, older ones pruned on
|
|
35
39
|
# every write. Fixed, not a config key -- `poll_snapshots` is an observation
|
|
36
40
|
# buffer for a future console sidecar, and a bounded ring is all it needs (it
|
|
@@ -38,6 +42,13 @@ from pathlib import Path
|
|
|
38
42
|
# observable buffer size, so it is pinned by a test.
|
|
39
43
|
SNAPSHOT_RETENTION = 1000
|
|
40
44
|
|
|
45
|
+
# Streak limiting for the best-effort telemetry writer's WARN (issue #62), on
|
|
46
|
+
# the same rule the poll firewall uses: the first few failures in full, then
|
|
47
|
+
# one in ten. A ledger that has gone read-only fails on every single poll, and
|
|
48
|
+
# the warning is worth nothing if it drowns the decisions around it.
|
|
49
|
+
TELEMETRY_LOG_HEAD = 3
|
|
50
|
+
TELEMETRY_LOG_EVERY = 10
|
|
51
|
+
|
|
41
52
|
# Shared between SCHEMA and the migration so the two can never drift.
|
|
42
53
|
_SPAWNS_TABLE = """
|
|
43
54
|
CREATE TABLE IF NOT EXISTS spawns (
|
|
@@ -108,10 +119,9 @@ CREATE TABLE IF NOT EXISTS verdict_posts (
|
|
|
108
119
|
-- then released rather than held open forever; see loop._abandon_verdict.
|
|
109
120
|
abandoned_at INTEGER,
|
|
110
121
|
-- When this round's APPROVE was FIRST held back because the judged head's
|
|
111
|
-
-- CI rollup had not settled
|
|
112
|
-
--
|
|
113
|
-
--
|
|
114
|
-
-- poll happened to land. NULL means the gate never held this round.
|
|
122
|
+
-- CI rollup had not settled -- and it is never overwritten afterwards, so
|
|
123
|
+
-- "how long has this round really been held?" always has an answer. NULL
|
|
124
|
+
-- means the gate never held this round.
|
|
115
125
|
checks_held_at INTEGER,
|
|
116
126
|
-- WHICH unsettled condition that stamp belongs to ('pending' -- checks are
|
|
117
127
|
-- genuinely running -- or 'unknown' -- the rollup could not be read). The
|
|
@@ -121,6 +131,13 @@ CREATE TABLE IF NOT EXISTS verdict_posts (
|
|
|
121
131
|
-- 'pending' one. The policy lives in loop._gate_on_checks; this column is
|
|
122
132
|
-- what lets it be decided from the ledger instead of from memory.
|
|
123
133
|
checks_held_state TEXT,
|
|
134
|
+
-- When that promotion happened: the stamp the bound is measured from once
|
|
135
|
+
-- the wait is on checks that are genuinely running. Separate from
|
|
136
|
+
-- `checks_held_at` rather than replacing it, because the two answer
|
|
137
|
+
-- different questions and a report that conflates them says a promoted hold
|
|
138
|
+
-- waited one bound when it waited two. NULL until (and unless) the promotion
|
|
139
|
+
-- happens; the bound then reads `checks_pending_at or checks_held_at`.
|
|
140
|
+
checks_pending_at INTEGER,
|
|
124
141
|
review_url TEXT,
|
|
125
142
|
last_error TEXT,
|
|
126
143
|
PRIMARY KEY (repo, number, round)
|
|
@@ -163,10 +180,42 @@ _ADDED_COLUMNS = {
|
|
|
163
180
|
"verdict_posts": (
|
|
164
181
|
("checks_held_at", "INTEGER"),
|
|
165
182
|
("checks_held_state", "TEXT"),
|
|
183
|
+
("checks_pending_at", "INTEGER"),
|
|
166
184
|
),
|
|
167
185
|
}
|
|
168
186
|
|
|
169
187
|
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class ChecksHold:
|
|
190
|
+
"""One round's CI hold, as the ledger remembers it.
|
|
191
|
+
|
|
192
|
+
Two stamps, because the gate has two honest numbers to report and they can
|
|
193
|
+
differ by a whole wait bound:
|
|
194
|
+
|
|
195
|
+
* `first_at` -- when the round was first held at all, on whatever condition;
|
|
196
|
+
* `pending_at` -- when an unreadable hold was promoted to a genuinely
|
|
197
|
+
pending one, which restarts the clock the bound is measured from (see
|
|
198
|
+
loop._gate_on_checks for why exactly once).
|
|
199
|
+
|
|
200
|
+
`since` is the one the bound uses. `first_at` is the one an operator means by
|
|
201
|
+
"how long has this been held?", and reporting only `since` after a promotion
|
|
202
|
+
understates it by up to the full bound.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
first_at: int | None = None
|
|
206
|
+
condition: str | None = None
|
|
207
|
+
pending_at: int | None = None
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def since(self) -> int | None:
|
|
211
|
+
"""The stamp the wait bound is measured from."""
|
|
212
|
+
return self.pending_at or self.first_at
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def promoted(self) -> bool:
|
|
216
|
+
return self.pending_at is not None
|
|
217
|
+
|
|
218
|
+
|
|
170
219
|
class State:
|
|
171
220
|
def __init__(self, path: Path, *, read_only: bool = False):
|
|
172
221
|
"""Open the ledger. The daemon opens it read-write (creating the file,
|
|
@@ -192,6 +241,16 @@ class State:
|
|
|
192
241
|
for any path an operator can type.
|
|
193
242
|
"""
|
|
194
243
|
path = Path(path).expanduser()
|
|
244
|
+
# Kept so a best-effort telemetry write can RECONNECT after a failure
|
|
245
|
+
# (see _reconnect): the daemon's ledger lives on a platform volume, and
|
|
246
|
+
# a remount leaves the open handle pointing at a file descriptor that
|
|
247
|
+
# is gone while the path is perfectly good again.
|
|
248
|
+
self._path = path
|
|
249
|
+
self._read_only = read_only
|
|
250
|
+
# Consecutive failures of the best-effort writer, for streak-limited
|
|
251
|
+
# logging and for firing the one reconnect attempt on the FIRST failure
|
|
252
|
+
# of a streak rather than on every write.
|
|
253
|
+
self._telemetry_failures = 0
|
|
195
254
|
if read_only:
|
|
196
255
|
uri = Path(path).absolute().as_uri() + "?mode=ro"
|
|
197
256
|
self._db = sqlite3.connect(uri, uri=True)
|
|
@@ -265,6 +324,167 @@ class State:
|
|
|
265
324
|
return False # fresh database, nothing to migrate
|
|
266
325
|
return [r["name"] for r in info if r["pk"]] != ["session"]
|
|
267
326
|
|
|
327
|
+
# -- best-effort writes (issue #62) ------------------------------------
|
|
328
|
+
#
|
|
329
|
+
# CLASSIFICATION. Every write in this class is one of two kinds, and only
|
|
330
|
+
# one of them may ever be swallowed:
|
|
331
|
+
#
|
|
332
|
+
# * TELEMETRY -- `record_snapshot`, and nothing else. `poll_snapshots` is an
|
|
333
|
+
# observation buffer: the daemon never reads it back to make a decision
|
|
334
|
+
# (only `read_snapshots`, for the console, does), so a row lost to a
|
|
335
|
+
# read-only volume costs one missing datapoint on a dashboard. On
|
|
336
|
+
# 2026-07-29 it cost the whole daemon instead -- the sqlite exception
|
|
337
|
+
# escaped `poll_once` and killed the process mid-poll.
|
|
338
|
+
#
|
|
339
|
+
# * CORRECTNESS -- every other write here (`record_spawn`, `record_reap`,
|
|
340
|
+
# `record_ping`, `record_escalation`, `record_grant`, the `verdict_posts`
|
|
341
|
+
# writes, `age_out_spawn`). Each is a dedupe key or an in-flight marker
|
|
342
|
+
# for an action the daemon TAKES: swallowing one does not lose a
|
|
343
|
+
# datapoint, it re-spawns a reviewer round, re-pages an operator, or
|
|
344
|
+
# re-grants a cap. Those stay strict and raise.
|
|
345
|
+
#
|
|
346
|
+
# STRICTNESS IS NOT, BY ITSELF, THE PROTECTION -- and the first draft of
|
|
347
|
+
# this change claimed it was (PR #63 round-1 blocker). Raising aborts the
|
|
348
|
+
# pass that failed; it says nothing about the next one. The poll firewall
|
|
349
|
+
# then hands the loop straight back to the same code path, and the side
|
|
350
|
+
# effect the write was meant to dedupe has ALREADY been taken -- so a
|
|
351
|
+
# read-only volume turned "enqueue a reviewer, fail to record it" into a
|
|
352
|
+
# fresh reviewer session every poll, indefinitely, where before it merely
|
|
353
|
+
# killed the daemon after one. What actually protects the side effect is
|
|
354
|
+
# `writable()` above, checked by `loop.poll_once` before the pass takes any
|
|
355
|
+
# decision at all: the daemon does not take an action it cannot record.
|
|
356
|
+
# Strictness is what makes an unrecordable action VISIBLE; the gate is what
|
|
357
|
+
# makes it not repeat.
|
|
358
|
+
|
|
359
|
+
@staticmethod
|
|
360
|
+
def _write_probe(db: sqlite3.Connection) -> bool:
|
|
361
|
+
"""Can this connection actually write? A no-op header write, which
|
|
362
|
+
exercises exactly the path a real write needs, changes nothing, and
|
|
363
|
+
costs one page. Read-only-ness is the thing being detected, so it
|
|
364
|
+
cannot be answered by inspecting the file's mode: sqlite decides it at
|
|
365
|
+
open time and a handle can be read-only over a writable file (and, for
|
|
366
|
+
one recoverable moment, the reverse)."""
|
|
367
|
+
try:
|
|
368
|
+
version = db.execute("PRAGMA user_version").fetchone()[0]
|
|
369
|
+
db.execute(f"PRAGMA user_version = {int(version)}")
|
|
370
|
+
db.commit()
|
|
371
|
+
except sqlite3.DatabaseError:
|
|
372
|
+
return False
|
|
373
|
+
return True
|
|
374
|
+
|
|
375
|
+
def writable(self) -> bool:
|
|
376
|
+
"""Whether the ledger can accept a write RIGHT NOW.
|
|
377
|
+
|
|
378
|
+
The daemon asks this before it takes any action it would have to
|
|
379
|
+
record (issue #62, round-1 blocker). Keeping a correctness write strict
|
|
380
|
+
aborts the pass that fails, but the poll firewall hands the loop
|
|
381
|
+
straight back to the same code path -- so without this gate a read-only
|
|
382
|
+
volume turns "enqueue a reviewer, then fail to record it" into a fresh
|
|
383
|
+
reviewer session every poll, forever, each one a live agent. The
|
|
384
|
+
invariant the gate buys is simple: the daemon does not take an action
|
|
385
|
+
it cannot record.
|
|
386
|
+
|
|
387
|
+
A failing probe retries through `_reconnect`, whose candidate is
|
|
388
|
+
write-probed before adoption -- so a stale handle over a live file
|
|
389
|
+
heals here too, and only a genuinely unwritable ledger answers False.
|
|
390
|
+
A read-only `State` (the console's) is never writable by construction.
|
|
391
|
+
"""
|
|
392
|
+
if self._read_only:
|
|
393
|
+
return False
|
|
394
|
+
return self._write_probe(self._db) or self._reconnect()
|
|
395
|
+
|
|
396
|
+
def _reconnect(self) -> bool:
|
|
397
|
+
"""Swap in a fresh connection, but ONLY if the fresh one is better.
|
|
398
|
+
True when the swap happened -- which, because the candidate is
|
|
399
|
+
write-probed, is also proof that the ledger is writable.
|
|
400
|
+
|
|
401
|
+
Deliberately raw: it re-establishes the connection and NOTHING else --
|
|
402
|
+
no schema script, no migration. The reconnect exists for the
|
|
403
|
+
stale-handle-after-remount case, where the database on disk is the one
|
|
404
|
+
this process already migrated; re-running DDL through a path that only
|
|
405
|
+
a failed telemetry write reaches would be a far larger act than the
|
|
406
|
+
failure justifies.
|
|
407
|
+
|
|
408
|
+
The candidate is WRITE-PROBED before it is adopted, and the old
|
|
409
|
+
connection is kept when the probe fails, because a blind reconnect
|
|
410
|
+
makes the read-only case permanently worse rather than better: sqlite
|
|
411
|
+
decides read-only-ness when it OPENS the file, so a handle opened while
|
|
412
|
+
the volume was read-only stays read-only for the rest of its life even
|
|
413
|
+
after the volume comes back -- while the handle opened before the fault
|
|
414
|
+
heals by itself the moment writes are possible again. Replacing the
|
|
415
|
+
healable handle with a poisoned one would trade a transient outage for
|
|
416
|
+
a permanent one.
|
|
417
|
+
|
|
418
|
+
WRITE MODE ONLY. A read-only `State` is the console's, it must never
|
|
419
|
+
write, and there is nothing a reconnect could improve for it -- so it
|
|
420
|
+
returns False rather than swapping one equivalent handle for another.
|
|
421
|
+
That also keeps the contract absolute: a True from here always means a
|
|
422
|
+
candidate passed the write probe, which is what `writable()` relies on.
|
|
423
|
+
"""
|
|
424
|
+
if self._read_only:
|
|
425
|
+
return False
|
|
426
|
+
candidate: sqlite3.Connection | None = None
|
|
427
|
+
try:
|
|
428
|
+
candidate = sqlite3.connect(str(self._path))
|
|
429
|
+
candidate.row_factory = sqlite3.Row
|
|
430
|
+
except sqlite3.Error as exc:
|
|
431
|
+
log.debug("state: reconnect to %s declined: %s", self._path, exc)
|
|
432
|
+
return False
|
|
433
|
+
if not self._write_probe(candidate):
|
|
434
|
+
log.debug("state: reconnect to %s declined (candidate cannot write)", self._path)
|
|
435
|
+
try:
|
|
436
|
+
candidate.close()
|
|
437
|
+
except sqlite3.Error:
|
|
438
|
+
pass
|
|
439
|
+
return False
|
|
440
|
+
try:
|
|
441
|
+
self._db.close()
|
|
442
|
+
except sqlite3.Error:
|
|
443
|
+
pass # already broken; the point was to replace it
|
|
444
|
+
self._db = candidate
|
|
445
|
+
return True
|
|
446
|
+
|
|
447
|
+
def _write_telemetry(self, write: "Callable[[], None]", what: str) -> bool:
|
|
448
|
+
"""Run a TELEMETRY write, absorbing any database error. True on success.
|
|
449
|
+
|
|
450
|
+
One reconnect attempt on the FIRST failure of a streak (not on every
|
|
451
|
+
one: a database that is read-only stays read-only, and reconnecting per
|
|
452
|
+
poll would add a file open to every pass for nothing), then a
|
|
453
|
+
streak-limited WARN and back to polling.
|
|
454
|
+
"""
|
|
455
|
+
try:
|
|
456
|
+
write()
|
|
457
|
+
except sqlite3.DatabaseError as exc:
|
|
458
|
+
first = self._telemetry_failures == 0
|
|
459
|
+
if first and self._reconnect():
|
|
460
|
+
try:
|
|
461
|
+
write()
|
|
462
|
+
except sqlite3.DatabaseError as retry_exc:
|
|
463
|
+
exc = retry_exc
|
|
464
|
+
else:
|
|
465
|
+
log.info(
|
|
466
|
+
"state: %s succeeded after reconnecting to %s",
|
|
467
|
+
what, self._path,
|
|
468
|
+
)
|
|
469
|
+
return True
|
|
470
|
+
self._telemetry_failures += 1
|
|
471
|
+
n = self._telemetry_failures
|
|
472
|
+
if n <= TELEMETRY_LOG_HEAD or n % TELEMETRY_LOG_EVERY == 0:
|
|
473
|
+
log.warning(
|
|
474
|
+
"state: %s failed (%s: %s) — failure %d of this streak; "
|
|
475
|
+
"telemetry is best-effort, the loop keeps polling",
|
|
476
|
+
what, type(exc).__name__, exc, n,
|
|
477
|
+
)
|
|
478
|
+
return False
|
|
479
|
+
if self._telemetry_failures:
|
|
480
|
+
log.info(
|
|
481
|
+
"state: %s succeeded after %d failed attempt(s) — telemetry "
|
|
482
|
+
"is persisting again",
|
|
483
|
+
what, self._telemetry_failures,
|
|
484
|
+
)
|
|
485
|
+
self._telemetry_failures = 0
|
|
486
|
+
return True
|
|
487
|
+
|
|
268
488
|
def close(self) -> None:
|
|
269
489
|
self._db.close()
|
|
270
490
|
|
|
@@ -509,38 +729,59 @@ class State:
|
|
|
509
729
|
row = self.get_verdict_post(repo, number, round_)
|
|
510
730
|
return int(row["attempts"]) if row else 0
|
|
511
731
|
|
|
512
|
-
def checks_hold(
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
condition -- (None, None) if it has never been held.
|
|
732
|
+
def checks_hold(self, repo: str, number: int, round_: int) -> "ChecksHold":
|
|
733
|
+
"""This round's CI hold: when it began, what it is waiting on, and when
|
|
734
|
+
that became a genuine `pending` -- an all-None ChecksHold if it has never
|
|
735
|
+
been held.
|
|
517
736
|
|
|
518
737
|
A read, deliberately: whether an existing stamp still applies is a
|
|
519
738
|
policy question about CI (see loop._gate_on_checks), and this table's job
|
|
520
|
-
is to remember the answer, not to make it.
|
|
739
|
+
is to remember the answer, not to make it. It remembers BOTH stamps
|
|
740
|
+
because the two answer different questions -- `since` bounds the wait,
|
|
741
|
+
`first_at` is how long the round has really been held -- and a report
|
|
742
|
+
that conflates them tells an operator a promoted hold waited 30 minutes
|
|
743
|
+
when it waited 60.
|
|
521
744
|
"""
|
|
522
745
|
row = self.get_verdict_post(repo, number, round_)
|
|
523
746
|
if row is None or not row["checks_held_at"]:
|
|
524
|
-
return
|
|
525
|
-
|
|
747
|
+
return ChecksHold()
|
|
748
|
+
pending_at = row["checks_pending_at"]
|
|
749
|
+
return ChecksHold(
|
|
750
|
+
first_at=int(row["checks_held_at"]),
|
|
751
|
+
condition=(row["checks_held_state"] or None),
|
|
752
|
+
pending_at=int(pending_at) if pending_at else None,
|
|
753
|
+
)
|
|
526
754
|
|
|
527
755
|
def record_checks_hold(
|
|
528
756
|
self, repo: str, number: int, round_: int, condition: str
|
|
529
757
|
) -> int:
|
|
530
|
-
"""
|
|
758
|
+
"""Record that this round is held on `condition`; return the stamp the
|
|
759
|
+
bound is measured from.
|
|
531
760
|
|
|
532
761
|
The caller decides WHEN to call this -- once when the hold begins, and at
|
|
533
762
|
most once more when an unreadable hold is promoted to a genuinely pending
|
|
534
763
|
one, because the bound is defined against the first observation of the
|
|
535
|
-
condition actually being waited on.
|
|
536
|
-
|
|
764
|
+
condition actually being waited on (loop._gate_on_checks owns that rule).
|
|
765
|
+
|
|
766
|
+
The promotion NO LONGER overwrites `checks_held_at`: it fills
|
|
767
|
+
`checks_pending_at` instead, so the ledger keeps when the round was first
|
|
768
|
+
held as well as when its current wait started. Nothing about the bound
|
|
769
|
+
changes; what changes is that the daemon can now say both numbers out
|
|
770
|
+
loud, which the operator-facing report needs.
|
|
537
771
|
"""
|
|
538
772
|
now = int(time.time())
|
|
539
|
-
self.
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
773
|
+
if self.checks_hold(repo, number, round_).first_at is None:
|
|
774
|
+
self._db.execute(
|
|
775
|
+
"UPDATE verdict_posts SET checks_held_at = ?, checks_held_state = ? "
|
|
776
|
+
"WHERE repo=? AND number=? AND round=?",
|
|
777
|
+
(now, condition, repo, number, round_),
|
|
778
|
+
)
|
|
779
|
+
else:
|
|
780
|
+
self._db.execute(
|
|
781
|
+
"UPDATE verdict_posts SET checks_pending_at = ?, "
|
|
782
|
+
"checks_held_state = ? WHERE repo=? AND number=? AND round=?",
|
|
783
|
+
(now, condition, repo, number, round_),
|
|
784
|
+
)
|
|
544
785
|
self._db.commit()
|
|
545
786
|
return now
|
|
546
787
|
|
|
@@ -603,7 +844,7 @@ class State:
|
|
|
603
844
|
return self._read_rows(
|
|
604
845
|
"SELECT repo, number, round, first_seen_at, head_sha, attempts, "
|
|
605
846
|
"last_attempt_at, posted_at, abandoned_at, checks_held_at, "
|
|
606
|
-
"checks_held_state, review_url, last_error "
|
|
847
|
+
"checks_held_state, checks_pending_at, review_url, last_error "
|
|
607
848
|
"FROM verdict_posts "
|
|
608
849
|
"ORDER BY first_seen_at DESC, number DESC",
|
|
609
850
|
limit,
|
|
@@ -629,7 +870,7 @@ class State:
|
|
|
629
870
|
awaiting_post: int = 0,
|
|
630
871
|
abandoned: int = 0,
|
|
631
872
|
stages: list[dict],
|
|
632
|
-
) ->
|
|
873
|
+
) -> bool:
|
|
633
874
|
"""Append one poll-pass observation, then prune to the newest
|
|
634
875
|
SNAPSHOT_RETENTION rows. `ts` is stamped here (wall-clock seconds,
|
|
635
876
|
like every other row in this ledger); `stages` is the compact
|
|
@@ -638,7 +879,56 @@ class State:
|
|
|
638
879
|
dry-run included -- and pruned on write, so the table is
|
|
639
880
|
self-bounding. The count kwargs default to 0 so a caller need only
|
|
640
881
|
pass the ones a given pass produced.
|
|
882
|
+
|
|
883
|
+
BEST-EFFORT, and the only write in this class that is (issue #62): a
|
|
884
|
+
snapshot observes the pass, it is not something the daemon has to
|
|
885
|
+
remember, so a database error here is absorbed, reported once per
|
|
886
|
+
streak-limited window, and the loop keeps polling. Returns whether the
|
|
887
|
+
row landed, for a caller that wants to say so; nothing in the daemon
|
|
888
|
+
depends on it.
|
|
641
889
|
"""
|
|
890
|
+
return self._write_telemetry(
|
|
891
|
+
lambda: self._insert_snapshot(
|
|
892
|
+
duration_ms=duration_ms,
|
|
893
|
+
candidates=candidates,
|
|
894
|
+
spawned=spawned,
|
|
895
|
+
stale_reenqueued=stale_reenqueued,
|
|
896
|
+
in_flight=in_flight,
|
|
897
|
+
deferred=deferred,
|
|
898
|
+
converged=converged,
|
|
899
|
+
capped=capped,
|
|
900
|
+
escalated=escalated,
|
|
901
|
+
skipped=skipped,
|
|
902
|
+
reaped=reaped,
|
|
903
|
+
posted=posted,
|
|
904
|
+
awaiting_post=awaiting_post,
|
|
905
|
+
abandoned=abandoned,
|
|
906
|
+
stages=stages,
|
|
907
|
+
),
|
|
908
|
+
"poll snapshot",
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
def _insert_snapshot(
|
|
912
|
+
self,
|
|
913
|
+
*,
|
|
914
|
+
duration_ms: int,
|
|
915
|
+
candidates: int,
|
|
916
|
+
spawned: int,
|
|
917
|
+
stale_reenqueued: int,
|
|
918
|
+
in_flight: int,
|
|
919
|
+
deferred: int,
|
|
920
|
+
converged: int,
|
|
921
|
+
capped: int,
|
|
922
|
+
escalated: int,
|
|
923
|
+
skipped: int,
|
|
924
|
+
reaped: int,
|
|
925
|
+
posted: int,
|
|
926
|
+
awaiting_post: int,
|
|
927
|
+
abandoned: int,
|
|
928
|
+
stages: list[dict],
|
|
929
|
+
) -> None:
|
|
930
|
+
"""The snapshot INSERT + prune itself, strict. Split out so the
|
|
931
|
+
best-effort wrapper can RETRY it verbatim after a reconnect."""
|
|
642
932
|
self._db.execute(
|
|
643
933
|
"INSERT INTO poll_snapshots "
|
|
644
934
|
"(ts, duration_ms, candidates, spawned, stale_reenqueued, "
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.16.9
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.16.7
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|