alissa-tools-github-devloop 0.8.3__tar.gz → 0.8.4__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_devloop-0.8.3/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.4}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/loop.py +21 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/state.py +358 -57
- alissa_tools_github_devloop-0.8.4/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_devloop-0.8.3/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/config.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.3 → alissa_tools_github_devloop-0.8.4}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -353,6 +353,7 @@ import calendar
|
|
|
353
353
|
import dataclasses
|
|
354
354
|
import logging
|
|
355
355
|
import re
|
|
356
|
+
import sqlite3
|
|
356
357
|
import time
|
|
357
358
|
from collections import Counter
|
|
358
359
|
from dataclasses import dataclass
|
|
@@ -6330,6 +6331,26 @@ class DevWatcher:
|
|
|
6330
6331
|
except CommandError as exc:
|
|
6331
6332
|
backoff = min(backoff * 2, 900)
|
|
6332
6333
|
log.error("poll failed: %s — retrying in %ds", exc, backoff)
|
|
6334
|
+
except sqlite3.Error as exc:
|
|
6335
|
+
# The BACKSTOP for the strict half of the state layer
|
|
6336
|
+
# (issue #76). The bookkeeping writes degrade on their own
|
|
6337
|
+
# (state._best_effort) and never arrive here; what does is
|
|
6338
|
+
# a spawn-ledger write, which must NOT be swallowed -- a
|
|
6339
|
+
# forgotten spawn row would let the daemon re-spawn over
|
|
6340
|
+
# live work. Abandoning the pass and retrying is the honest
|
|
6341
|
+
# middle: the ledger is intact, the cause is named, and a
|
|
6342
|
+
# volume that goes read-only under a running daemon costs a
|
|
6343
|
+
# backed-off retry instead of the process. That is the
|
|
6344
|
+
# whole lesson of the 2026-07-29 revloop incident -- a
|
|
6345
|
+
# local storage fault must never be terminal for an
|
|
6346
|
+
# unattended loop.
|
|
6347
|
+
backoff = min(backoff * 2, 900)
|
|
6348
|
+
log.error(
|
|
6349
|
+
"poll abandoned: the state ledger raised %s — this is "
|
|
6350
|
+
"a LOCAL storage fault (a read-only or wedged volume), "
|
|
6351
|
+
"not a GitHub or auth failure; retrying in %ds",
|
|
6352
|
+
exc, backoff,
|
|
6353
|
+
)
|
|
6333
6354
|
time.sleep(backoff)
|
|
6334
6355
|
except KeyboardInterrupt:
|
|
6335
6356
|
log.info("stopping")
|
|
@@ -69,15 +69,26 @@ and a bare-name key would collide their ledgers into one row.
|
|
|
69
69
|
from __future__ import annotations
|
|
70
70
|
|
|
71
71
|
import json
|
|
72
|
+
import logging
|
|
72
73
|
import sqlite3
|
|
73
74
|
import time
|
|
74
75
|
from pathlib import Path
|
|
76
|
+
from typing import Callable
|
|
77
|
+
|
|
78
|
+
log = logging.getLogger(__name__)
|
|
75
79
|
|
|
76
80
|
# Poll-snapshot retention: the newest N rows are kept, older pruned on write.
|
|
77
81
|
# Fixed, not a config key -- the table is an observation buffer for the UI,
|
|
78
82
|
# and a bounded ring is all the console needs (it reads the recent tail).
|
|
79
83
|
SNAPSHOT_RETENTION = 1000
|
|
80
84
|
|
|
85
|
+
# How many consecutive best-effort write failures get their own WARN before the
|
|
86
|
+
# stream is suppressed. A wedged volume fails EVERY write of EVERY poll, so an
|
|
87
|
+
# unbounded warning is a log flood that buries the daemon's actual work; the
|
|
88
|
+
# first few carry the diagnosis, and the recovery line (always logged) reports
|
|
89
|
+
# the total the streak reached. See State._best_effort.
|
|
90
|
+
DEGRADED_WARN_STREAK = 3
|
|
91
|
+
|
|
81
92
|
SCHEMA = """
|
|
82
93
|
CREATE TABLE IF NOT EXISTS spawns (
|
|
83
94
|
repo_slug TEXT NOT NULL,
|
|
@@ -153,10 +164,46 @@ _COLUMN_MIGRATIONS = (
|
|
|
153
164
|
)
|
|
154
165
|
|
|
155
166
|
|
|
167
|
+
# The dedupe-gate tables, and the column order _flush_pending_gates replays
|
|
168
|
+
# them in: (key column, number column, kind column, timestamp column). Both are
|
|
169
|
+
# `INSERT OR IGNORE` targets keyed on their first three columns, which is what
|
|
170
|
+
# makes a replay safe to run against rows that may already exist.
|
|
171
|
+
_GATE_REPLAY = {
|
|
172
|
+
"escalations": ("repo_slug", "issue", "kind", "escalated_at"),
|
|
173
|
+
"activity_log": ("repo_slug", "number", "kind", "logged_at"),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _migrate_columns(db: sqlite3.Connection) -> None:
|
|
178
|
+
"""Apply the additive column migrations `IF NOT EXISTS` cannot: add any
|
|
179
|
+
_COLUMN_MIGRATIONS column missing from an already-existing table.
|
|
180
|
+
Idempotent -- the live column set is checked first, so a fresh DB (the
|
|
181
|
+
column already in SCHEMA) and a re-open both no-op.
|
|
182
|
+
|
|
183
|
+
Module-level, taking the connection: State._reconnect has to bring a
|
|
184
|
+
REPLACEMENT handle up to the same schema as __init__ did, and a method
|
|
185
|
+
bound to self._db could not be used before the swap."""
|
|
186
|
+
for table, column, ddl in _COLUMN_MIGRATIONS:
|
|
187
|
+
live = {row["name"] for row in db.execute(f"PRAGMA table_info({table})")}
|
|
188
|
+
if column not in live:
|
|
189
|
+
db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
|
|
190
|
+
|
|
191
|
+
|
|
156
192
|
class State:
|
|
157
193
|
def __init__(self, path: Path):
|
|
158
194
|
path = Path(path).expanduser()
|
|
159
195
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
self._path = path
|
|
197
|
+
# Length of the current streak of failed best-effort writes; 0 while
|
|
198
|
+
# the store is healthy. Drives both the reconnect attempt and the
|
|
199
|
+
# warning suppression (see _best_effort).
|
|
200
|
+
self._degraded = 0
|
|
201
|
+
# Dedupe keys whose row could NOT be written, mapped to the timestamp
|
|
202
|
+
# the row would have carried. Consulted by escalated() and
|
|
203
|
+
# activity_logged() so a gate that exists to fire ONCE still fires once
|
|
204
|
+
# while the store is unwritable, and REPLAYED by _flush_pending_gates
|
|
205
|
+
# when writes work again -- see _degraded_gate.
|
|
206
|
+
self._pending_gates: "dict[tuple[str, str, int, str], int]" = {}
|
|
160
207
|
self._db = sqlite3.connect(str(path))
|
|
161
208
|
self._db.row_factory = sqlite3.Row
|
|
162
209
|
self._db.executescript(SCHEMA)
|
|
@@ -164,19 +211,214 @@ class State:
|
|
|
164
211
|
self._db.commit()
|
|
165
212
|
|
|
166
213
|
def _migrate(self) -> None:
|
|
167
|
-
"""Apply the additive column migrations
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
214
|
+
"""Apply the additive column migrations to this State's connection."""
|
|
215
|
+
_migrate_columns(self._db)
|
|
216
|
+
|
|
217
|
+
# -- best-effort writes (the 2026-07-29 volume incident, issue #76) -----
|
|
218
|
+
#
|
|
219
|
+
# This ledger sits on the same platform volume the daemon's config does,
|
|
220
|
+
# and that volume can go read-only underneath a RUNNING process -- which is
|
|
221
|
+
# exactly what happened to the revloop sibling on 2026-07-29: a telemetry
|
|
222
|
+
# write raised `attempt to write a readonly database` and took the whole
|
|
223
|
+
# poll loop down with it. Losing telemetry is a nuisance; losing the daemon
|
|
224
|
+
# needs a human.
|
|
225
|
+
#
|
|
226
|
+
# So the writes are split by what a LOST ROW actually costs:
|
|
227
|
+
#
|
|
228
|
+
# best-effort record_snapshot / record_activity / record_escalation --
|
|
229
|
+
# observation and dedupe bookkeeping. Their side effect (the
|
|
230
|
+
# snapshot row, the posted comment) either does not exist yet
|
|
231
|
+
# or has ALREADY landed on GitHub by the time the row is
|
|
232
|
+
# written, so a dropped row degrades the ledger -- a missing
|
|
233
|
+
# console sample -- and nothing more.
|
|
234
|
+
#
|
|
235
|
+
# The two that are DEDUPE GATES are additionally contained
|
|
236
|
+
# in memory (_degraded_gate): "degrade" must mean a stale
|
|
237
|
+
# console, not the same operator comment re-posted to a real
|
|
238
|
+
# GitHub issue once per poll for the length of an outage this
|
|
239
|
+
# daemon is built to survive for hours (PR #77 review r1).
|
|
240
|
+
#
|
|
241
|
+
# strict record_spawn / record_fix_spawn / record_maintain_spawn --
|
|
242
|
+
# the attempt ledger. A dropped row makes the daemon forget a
|
|
243
|
+
# session it just launched: it would re-spawn over live work
|
|
244
|
+
# and mis-count the attempt cap. That must not be swallowed,
|
|
245
|
+
# so those keep raising -- and loop.run_forever now catches
|
|
246
|
+
# sqlite3.Error at the pass boundary, so raising costs a
|
|
247
|
+
# backed-off retry instead of the process.
|
|
248
|
+
#
|
|
249
|
+
# Callers do not choose: the classification lives on the method, so a new
|
|
250
|
+
# call site inherits the right posture from the method it calls.
|
|
251
|
+
|
|
252
|
+
def _reconnect(self) -> bool:
|
|
253
|
+
"""Replace the connection after a failed write, then let the caller
|
|
254
|
+
retry it once.
|
|
255
|
+
|
|
256
|
+
Attempted on EVERY failure, not just the first of a streak, and that is
|
|
257
|
+
deliberate: when sqlite opens a file it cannot write, the handle is
|
|
258
|
+
read-only for its whole life, so a store that recovers underneath a
|
|
259
|
+
once-reconnected daemon would stay broken until someone restarted the
|
|
260
|
+
container -- the precise opposite of self-healing, and the same shape of
|
|
261
|
+
bug as the crash this whole change is undoing. The cost of being
|
|
262
|
+
generous is one sqlite3.connect() per failed write, i.e. a syscall or
|
|
263
|
+
two per poll while degraded, against a recovery that needs no human.
|
|
264
|
+
|
|
265
|
+
The new connection is opened BEFORE the old one is dropped, so a failed
|
|
266
|
+
reconnect leaves the working handle in place. Closing first would trade
|
|
267
|
+
a store that is merely unwritable for one that cannot even be READ --
|
|
268
|
+
and every staleness and dedupe decision the daemon makes is a read.
|
|
269
|
+
|
|
270
|
+
The replacement gets the SCHEMA and the column migrations applied, the
|
|
271
|
+
same as __init__. sqlite3.connect() CREATES an empty database at a
|
|
272
|
+
missing path, and the documented recovery for the 2026-07-29 incident
|
|
273
|
+
was a human wiping the volume -- do that under a running daemon and a
|
|
274
|
+
bare reconnect would report success on a schema-less handle, after
|
|
275
|
+
which every (strict) read raises `no such table` and the daemon spins
|
|
276
|
+
at the backoff cap forever: alive, warning, and unable to decide
|
|
277
|
+
anything. `CREATE TABLE IF NOT EXISTS` throughout means re-applying is
|
|
278
|
+
idempotent, and on an intact read-only database it needs no write at
|
|
279
|
+
all, so this stays cheap on the path that actually runs."""
|
|
280
|
+
try:
|
|
281
|
+
db = sqlite3.connect(str(self._path))
|
|
282
|
+
db.row_factory = sqlite3.Row
|
|
283
|
+
db.executescript(SCHEMA)
|
|
284
|
+
_migrate_columns(db)
|
|
285
|
+
db.commit()
|
|
286
|
+
except sqlite3.Error as exc:
|
|
287
|
+
log.debug("state: reconnect to %s failed: %s", self._path, exc)
|
|
288
|
+
return False
|
|
289
|
+
try:
|
|
290
|
+
self._db.close()
|
|
291
|
+
except sqlite3.Error:
|
|
292
|
+
pass
|
|
293
|
+
self._db = db
|
|
294
|
+
return True
|
|
295
|
+
|
|
296
|
+
def _degraded_gate(self, table: str, repo_slug: str, number: int,
|
|
297
|
+
kind: str, ts: int, wrote: bool) -> None:
|
|
298
|
+
"""Remember a dedupe key whose row did not land, with the timestamp the
|
|
299
|
+
row would have carried. `escalated()` / `activity_logged()` consult it,
|
|
300
|
+
so a gate that exists to fire exactly once still fires exactly once
|
|
301
|
+
while the store is unwritable -- instead of re-posting its comment
|
|
302
|
+
every poll.
|
|
303
|
+
|
|
304
|
+
The key is released by _flush_pending_gates, NOT by a later call from
|
|
305
|
+
the same call site: every one of those is itself gated on this key, so
|
|
306
|
+
once it is held the code that would write the row is unreachable (PR
|
|
307
|
+
#77 review round 2). `ts` is captured here rather than at replay time
|
|
308
|
+
because `escalated_at` is an audit field for when the escalation was
|
|
309
|
+
first RAISED -- the row must not claim the volume's recovery time."""
|
|
310
|
+
key = (table, repo_slug, number, kind)
|
|
311
|
+
if wrote:
|
|
312
|
+
self._pending_gates.pop(key, None)
|
|
313
|
+
else:
|
|
314
|
+
self._pending_gates.setdefault(key, ts)
|
|
315
|
+
|
|
316
|
+
def _gate_pending(self, table: str, repo_slug: str, number: int,
|
|
317
|
+
kind: str) -> bool:
|
|
318
|
+
return (table, repo_slug, number, kind) in self._pending_gates
|
|
319
|
+
|
|
320
|
+
def _flush_pending_gates(self) -> None:
|
|
321
|
+
"""Land the deferred dedupe rows now that a write has succeeded.
|
|
322
|
+
|
|
323
|
+
This is the half that makes the in-memory containment two-way, and
|
|
324
|
+
without it the containment is a TRAP: every call site reads
|
|
325
|
+
escalated() / activity_logged() first, the in-memory key already
|
|
326
|
+
answers True, so record_escalation is never called for that key again
|
|
327
|
+
and the row would never land at all. The costs are not hypothetical --
|
|
328
|
+
`escalations` is not only a comment receipt but the orphan sweep's
|
|
329
|
+
state breadcrumb (orphan_ready_kind records that THIS daemon flipped a
|
|
330
|
+
PR ready), so a row that never lands means a restarted daemon re-runs
|
|
331
|
+
mark_ready and request_reviews against that head; and the console
|
|
332
|
+
sidecar is a separate process that can only see the table.
|
|
333
|
+
|
|
334
|
+
`INSERT OR IGNORE` throughout, so replaying a row that already exists
|
|
335
|
+
is a no-op. A still-failing store keeps every key and tries again on
|
|
336
|
+
the next successful write."""
|
|
337
|
+
if not self._pending_gates:
|
|
338
|
+
return
|
|
339
|
+
landed = []
|
|
340
|
+
for key, ts in list(self._pending_gates.items()):
|
|
341
|
+
table, repo_slug, number, kind = key
|
|
342
|
+
columns = _GATE_REPLAY[table]
|
|
343
|
+
try:
|
|
177
344
|
self._db.execute(
|
|
178
|
-
f"
|
|
345
|
+
f"INSERT OR IGNORE INTO {table} "
|
|
346
|
+
f"({', '.join(columns)}) VALUES (?,?,?,?)",
|
|
347
|
+
(repo_slug, number, kind, ts),
|
|
348
|
+
)
|
|
349
|
+
self._db.commit()
|
|
350
|
+
except sqlite3.Error as exc:
|
|
351
|
+
log.debug(
|
|
352
|
+
"state: replay of %s deferred while the ledger is still "
|
|
353
|
+
"unwritable (%s)", table, exc,
|
|
179
354
|
)
|
|
355
|
+
break
|
|
356
|
+
landed.append(key)
|
|
357
|
+
for key in landed:
|
|
358
|
+
self._pending_gates.pop(key, None)
|
|
359
|
+
if landed:
|
|
360
|
+
log.warning(
|
|
361
|
+
"state: replayed %d dedupe row(s) deferred while the ledger "
|
|
362
|
+
"was unwritable; %d still pending",
|
|
363
|
+
len(landed), len(self._pending_gates),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def _note_write_ok(self, what: str) -> None:
|
|
367
|
+
"""Close out a degraded streak once a write lands again: report the
|
|
368
|
+
total it reached, so the suppressed middle of a long outage is still
|
|
369
|
+
accounted for in the log, and re-arm the warnings."""
|
|
370
|
+
if self._degraded:
|
|
371
|
+
log.warning(
|
|
372
|
+
"state: %s succeeded -- the ledger at %s is accepting writes "
|
|
373
|
+
"again after %d failed write(s)",
|
|
374
|
+
what, self._path, self._degraded,
|
|
375
|
+
)
|
|
376
|
+
self._degraded = 0
|
|
377
|
+
# Unconditional, not folded into the branch above: a partial replay
|
|
378
|
+
# leaves keys behind with the streak already reset, and they must still
|
|
379
|
+
# get their next chance.
|
|
380
|
+
self._flush_pending_gates()
|
|
381
|
+
|
|
382
|
+
def _best_effort(self, what: str, write: Callable[[], None]) -> bool:
|
|
383
|
+
"""Run one bookkeeping write so a failing store degrades this ledger
|
|
384
|
+
instead of killing the caller. Returns whether the row landed.
|
|
385
|
+
|
|
386
|
+
`what` names the write for the log. Only sqlite3.Error is caught -- a
|
|
387
|
+
TypeError from a malformed call site is a bug and still raises."""
|
|
388
|
+
try:
|
|
389
|
+
write()
|
|
390
|
+
except sqlite3.Error as exc:
|
|
391
|
+
# Leave no half-open transaction behind: the READS are strict, and
|
|
392
|
+
# they are what every staleness and dedupe decision runs on.
|
|
393
|
+
try:
|
|
394
|
+
self._db.rollback()
|
|
395
|
+
except sqlite3.Error:
|
|
396
|
+
pass
|
|
397
|
+
if self._reconnect():
|
|
398
|
+
try:
|
|
399
|
+
write()
|
|
400
|
+
except sqlite3.Error as retry_exc:
|
|
401
|
+
exc = retry_exc
|
|
402
|
+
else:
|
|
403
|
+
self._note_write_ok(what)
|
|
404
|
+
return True
|
|
405
|
+
self._degraded += 1
|
|
406
|
+
if self._degraded <= DEGRADED_WARN_STREAK:
|
|
407
|
+
log.warning(
|
|
408
|
+
"state: %s failed (%s) -- the ledger at %s is not "
|
|
409
|
+
"accepting writes; the daemon keeps polling with degraded "
|
|
410
|
+
"bookkeeping (failure %d of this streak)",
|
|
411
|
+
what, exc, self._path, self._degraded,
|
|
412
|
+
)
|
|
413
|
+
if self._degraded == DEGRADED_WARN_STREAK:
|
|
414
|
+
log.warning(
|
|
415
|
+
"state: suppressing further write-failure warnings "
|
|
416
|
+
"until the ledger recovers (one line will report the "
|
|
417
|
+
"total)",
|
|
418
|
+
)
|
|
419
|
+
return False
|
|
420
|
+
self._note_write_ok(what)
|
|
421
|
+
return True
|
|
180
422
|
|
|
181
423
|
def close(self) -> None:
|
|
182
424
|
self._db.close()
|
|
@@ -423,7 +665,13 @@ class State:
|
|
|
423
665
|
def escalated(self, repo_slug: str, issue: int, kind: str) -> bool:
|
|
424
666
|
"""Whether an escalation of this KIND was already raised. Kinds
|
|
425
667
|
dedupe independently -- a cap-out row never silences an
|
|
426
|
-
assignment-rejection comment, and vice versa.
|
|
668
|
+
assignment-rejection comment, and vice versa.
|
|
669
|
+
|
|
670
|
+
Answers True as well when the row could not be WRITTEN this run (see
|
|
671
|
+
_degraded_gate): the comment went out, so the gate it stands for is
|
|
672
|
+
closed whether or not sqlite accepted the receipt."""
|
|
673
|
+
if self._gate_pending("escalations", repo_slug, issue, kind):
|
|
674
|
+
return True
|
|
427
675
|
row = self._db.execute(
|
|
428
676
|
"SELECT 1 FROM escalations WHERE repo_slug=? AND issue=? AND kind=?",
|
|
429
677
|
(repo_slug, issue, kind),
|
|
@@ -433,13 +681,33 @@ class State:
|
|
|
433
681
|
def record_escalation(self, repo_slug: str, issue: int, kind: str) -> None:
|
|
434
682
|
"""Idempotent per kind: OR IGNORE keeps the FIRST escalation's
|
|
435
683
|
timestamp, so `escalated_at` is an audit field for when this kind of
|
|
436
|
-
escalation was first raised, not the most recent re-raise.
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
684
|
+
escalation was first raised, not the most recent re-raise.
|
|
685
|
+
|
|
686
|
+
BEST-EFFORT (see _best_effort): every caller posts the operator comment
|
|
687
|
+
BEFORE recording, so the row is a dedupe gate, never the escalation
|
|
688
|
+
itself. Raising here would abandon a poll pass over bookkeeping for a
|
|
689
|
+
comment that already landed. Dropping the row is not free either -- it
|
|
690
|
+
would re-open the gate and re-post the comment every poll -- so a
|
|
691
|
+
failed write is CONTAINED in memory (_degraded_gate) and REPLAYED by
|
|
692
|
+
_flush_pending_gates on the next write that succeeds. The gate holds
|
|
693
|
+
meanwhile, and the row lands with its original timestamp. The replay is
|
|
694
|
+
what makes that true: this call site is itself gated on the key, so it
|
|
695
|
+
will never be reached a second time to write the row itself."""
|
|
696
|
+
ts = int(time.time())
|
|
697
|
+
|
|
698
|
+
def write() -> None:
|
|
699
|
+
self._db.execute(
|
|
700
|
+
"INSERT OR IGNORE INTO escalations "
|
|
701
|
+
"(repo_slug, issue, kind, escalated_at) VALUES (?,?,?,?)",
|
|
702
|
+
(repo_slug, issue, kind, ts),
|
|
703
|
+
)
|
|
704
|
+
self._db.commit()
|
|
705
|
+
|
|
706
|
+
self._degraded_gate(
|
|
707
|
+
"escalations", repo_slug, issue, kind, ts,
|
|
708
|
+
self._best_effort(
|
|
709
|
+
f"record_escalation({repo_slug}#{issue}, {kind})", write),
|
|
441
710
|
)
|
|
442
|
-
self._db.commit()
|
|
443
711
|
|
|
444
712
|
# -- mechanical activity comment dedupe -------------------------------
|
|
445
713
|
#
|
|
@@ -459,7 +727,13 @@ class State:
|
|
|
459
727
|
def activity_logged(self, repo_slug: str, number: int, kind: str) -> bool:
|
|
460
728
|
"""Whether this activity KIND already landed a line -- the
|
|
461
729
|
once-per-episode gate for capacity-deferral telemetry. Independent of
|
|
462
|
-
`escalated()`: a deferral line is not an operator escalation.
|
|
730
|
+
`escalated()`: a deferral line is not an operator escalation.
|
|
731
|
+
|
|
732
|
+
Contained in memory on a failed write, exactly like `escalated()`: the
|
|
733
|
+
line is already on the comment, so an unwritable ledger must not turn
|
|
734
|
+
"one line per episode" into one line per poll."""
|
|
735
|
+
if self._gate_pending("activity_log", repo_slug, number, kind):
|
|
736
|
+
return True
|
|
463
737
|
row = self._db.execute(
|
|
464
738
|
"SELECT 1 FROM activity_log WHERE repo_slug=? AND number=? AND kind=?",
|
|
465
739
|
(repo_slug, number, kind),
|
|
@@ -470,13 +744,28 @@ class State:
|
|
|
470
744
|
"""Idempotent per kind: OR IGNORE keeps the first line's timestamp.
|
|
471
745
|
Recorded by the caller only AFTER the append lands, so a transient
|
|
472
746
|
comment failure retries next poll and the line lands exactly once per
|
|
473
|
-
episode.
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
747
|
+
episode.
|
|
748
|
+
|
|
749
|
+
BEST-EFFORT (see _best_effort): this is the dedupe gate for a
|
|
750
|
+
MECHANICAL log line -- telemetry by construction, and already posted by
|
|
751
|
+
the time it is called. Contained in memory on failure like
|
|
752
|
+
record_escalation, so an unwritable store costs neither the poll pass
|
|
753
|
+
nor a repeated line."""
|
|
754
|
+
ts = int(time.time())
|
|
755
|
+
|
|
756
|
+
def write() -> None:
|
|
757
|
+
self._db.execute(
|
|
758
|
+
"INSERT OR IGNORE INTO activity_log "
|
|
759
|
+
"(repo_slug, number, kind, logged_at) VALUES (?,?,?,?)",
|
|
760
|
+
(repo_slug, number, kind, ts),
|
|
761
|
+
)
|
|
762
|
+
self._db.commit()
|
|
763
|
+
|
|
764
|
+
self._degraded_gate(
|
|
765
|
+
"activity_log", repo_slug, number, kind, ts,
|
|
766
|
+
self._best_effort(
|
|
767
|
+
f"record_activity({repo_slug}#{number}, {kind})", write),
|
|
478
768
|
)
|
|
479
|
-
self._db.commit()
|
|
480
769
|
|
|
481
770
|
# -- poll snapshots (the UI sidecar's exhaust buffer) ------------------
|
|
482
771
|
|
|
@@ -503,38 +792,50 @@ class State:
|
|
|
503
792
|
to JSON. `deferred` counts the max_sessions-deferred items of the
|
|
504
793
|
pass (defaults to 0 so callers predating the valve keep working).
|
|
505
794
|
Purely observational -- written on every pass, dry-run included --
|
|
506
|
-
and pruned on write, so the table is self-bounding.
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
reaped,
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
795
|
+
and pruned on write, so the table is self-bounding.
|
|
796
|
+
|
|
797
|
+
BEST-EFFORT (see _best_effort), and the most consequential of the
|
|
798
|
+
three: this is the ONE state write EVERY poll pass makes
|
|
799
|
+
unconditionally, so it was also the one guaranteed to meet a volume
|
|
800
|
+
that had gone read-only -- and to take the poll loop down with it on
|
|
801
|
+
the very first pass, exactly as it did to the revloop sibling on
|
|
802
|
+
2026-07-29. A console that goes stale is the right price for a daemon
|
|
803
|
+
that keeps polling."""
|
|
804
|
+
def write() -> None:
|
|
805
|
+
self._db.execute(
|
|
806
|
+
"INSERT INTO poll_snapshots "
|
|
807
|
+
"(ts, duration_ms, issue_candidates, pr_candidates, "
|
|
808
|
+
"maintain_candidates, reaped, spawned, in_flight, skipped, "
|
|
809
|
+
"escalated, capped, deferred, stages_json) "
|
|
810
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
811
|
+
(
|
|
812
|
+
int(time.time()),
|
|
813
|
+
duration_ms,
|
|
814
|
+
issue_candidates,
|
|
815
|
+
pr_candidates,
|
|
816
|
+
maintain_candidates,
|
|
817
|
+
reaped,
|
|
818
|
+
spawned,
|
|
819
|
+
in_flight,
|
|
820
|
+
skipped,
|
|
821
|
+
escalated,
|
|
822
|
+
capped,
|
|
823
|
+
deferred,
|
|
824
|
+
json.dumps(stages, separators=(",", ":")),
|
|
825
|
+
),
|
|
826
|
+
)
|
|
827
|
+
# Prune on write: keep the newest SNAPSHOT_RETENTION rows by id.
|
|
828
|
+
# The autoincrement id is monotonic across prunes, so "newest" is
|
|
829
|
+
# well defined even when a wall-clock step would leave `ts`
|
|
830
|
+
# unordered.
|
|
831
|
+
self._db.execute(
|
|
832
|
+
"DELETE FROM poll_snapshots WHERE id NOT IN "
|
|
833
|
+
"(SELECT id FROM poll_snapshots ORDER BY id DESC LIMIT ?)",
|
|
834
|
+
(SNAPSHOT_RETENTION,),
|
|
835
|
+
)
|
|
836
|
+
self._db.commit()
|
|
837
|
+
|
|
838
|
+
self._best_effort("record_snapshot", write)
|
|
538
839
|
|
|
539
840
|
def read_snapshots(self, limit: "int | None" = None) -> "list[dict]":
|
|
540
841
|
"""The UI reader: poll snapshots newest-first, each with its per-item
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.8.4
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.8.3
|
|
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
|
|
File without changes
|