alissa-tools-github-revloop 0.16.12__tar.gz → 0.16.13__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.12/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.13}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/__main__.py +8 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/config.py +48 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/loop.py +396 -2
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/state.py +13 -2
- alissa_tools_github_revloop-0.16.13/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/webui/page.py +1 -1
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.16.12/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/README.md +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/setup.py +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -122,6 +122,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
122
122
|
help="page-worthy threshold: more live reviewer sessions than this "
|
|
123
123
|
"after a sweep and the daemon logs loudly",
|
|
124
124
|
)
|
|
125
|
+
over.add_argument(
|
|
126
|
+
"--max-concurrent-sessions",
|
|
127
|
+
type=int,
|
|
128
|
+
metavar="N",
|
|
129
|
+
help="spawn gate: at this many live reviewer sessions an owed round "
|
|
130
|
+
"waits for a slot instead of spawning (must be <= --reap-session-cap)",
|
|
131
|
+
)
|
|
125
132
|
over.add_argument(
|
|
126
133
|
"--checks-wait-seconds",
|
|
127
134
|
type=int,
|
|
@@ -165,6 +172,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
165
172
|
"on_missing_hub": args.on_missing_hub,
|
|
166
173
|
"reap_grace_seconds": args.reap_grace_seconds,
|
|
167
174
|
"reap_session_cap": args.reap_session_cap,
|
|
175
|
+
"max_concurrent_sessions": args.max_concurrent_sessions,
|
|
168
176
|
"checks_wait_seconds": args.checks_wait_seconds,
|
|
169
177
|
"dry_run": args.dry_run,
|
|
170
178
|
}
|
|
@@ -129,6 +129,7 @@ CONFIG_KEYS = (
|
|
|
129
129
|
"on_missing_hub",
|
|
130
130
|
"reap_grace_seconds",
|
|
131
131
|
"reap_session_cap",
|
|
132
|
+
"max_concurrent_sessions",
|
|
132
133
|
"checks_wait_seconds",
|
|
133
134
|
"dry_run",
|
|
134
135
|
)
|
|
@@ -170,6 +171,25 @@ DEFAULT_REAP_GRACE_SECONDS = 30 * 60
|
|
|
170
171
|
# healthy deployment reaches, not a capacity limit.
|
|
171
172
|
DEFAULT_REAP_SESSION_CAP = 6
|
|
172
173
|
|
|
174
|
+
# The spawn gate: how many reviewer sessions of THIS daemon's own grammar may be
|
|
175
|
+
# live before an owed round waits for a slot instead of spawning (issue #70).
|
|
176
|
+
#
|
|
177
|
+
# Distinct from `reap_session_cap` above in kind, not just in number: that one is
|
|
178
|
+
# an ALARM on a condition the loop cannot fix (sessions the sweep could not
|
|
179
|
+
# reap), this one is a LIMIT the loop enforces on itself before it acts. Nothing
|
|
180
|
+
# bounded concurrency before it -- `round_cap` bounds rounds per PR, and the
|
|
181
|
+
# alarm only logs -- so a merge wave spawned one interactive claude session per
|
|
182
|
+
# PR, all at once, against a fixed container budget. On 2026-07-29 the 18:45-19:00Z
|
|
183
|
+
# burst pegged the deployment's 2 vCPU ceiling with 4+ concurrent reviewers plus
|
|
184
|
+
# the poll loop; throttled sessions review slower, hold their round slots longer,
|
|
185
|
+
# and widen the very burst that is starving them.
|
|
186
|
+
#
|
|
187
|
+
# 4 is the deployed shape's honest ceiling: two vCPUs, and a reviewer session is
|
|
188
|
+
# a full interactive agent. It is deliberately BELOW the reap alarm (6) so the
|
|
189
|
+
# steady state never pages -- and `Config.build` refuses a config where the alarm
|
|
190
|
+
# sits under the limit, which would page on healthy load.
|
|
191
|
+
DEFAULT_MAX_CONCURRENT_SESSIONS = 4
|
|
192
|
+
|
|
173
193
|
# How long a round holds its APPROVE while the head's CI rollup is still
|
|
174
194
|
# running (or unreadable) before it gives up and records the verdict as a
|
|
175
195
|
# COMMENT instead. An approve from the reviewer identity is the operator's cue
|
|
@@ -233,6 +253,11 @@ class Config:
|
|
|
233
253
|
reap_grace_seconds: int = DEFAULT_REAP_GRACE_SECONDS
|
|
234
254
|
reap_session_cap: int = DEFAULT_REAP_SESSION_CAP
|
|
235
255
|
|
|
256
|
+
# The spawn gate's limit -- see DEFAULT_MAX_CONCURRENT_SESSIONS. At or above
|
|
257
|
+
# it an owed round defers to a later poll instead of spawning; it burns no
|
|
258
|
+
# round number and no attempt while it waits.
|
|
259
|
+
max_concurrent_sessions: int = DEFAULT_MAX_CONCURRENT_SESSIONS
|
|
260
|
+
|
|
236
261
|
# The bound on holding a round's approve for a rollup that has not settled;
|
|
237
262
|
# see DEFAULT_CHECKS_WAIT_SECONDS. 0 is legal and means "never hold": a
|
|
238
263
|
# rollup that is not already green degrades the verdict to a comment on the
|
|
@@ -357,6 +382,28 @@ class Config:
|
|
|
357
382
|
# state of a working loop -- an alarm that always fires is noise.
|
|
358
383
|
raise ValueError(f"reap_session_cap must be >= 1, got {session_cap}")
|
|
359
384
|
|
|
385
|
+
max_sessions = int(
|
|
386
|
+
raw.get("max_concurrent_sessions", cls.max_concurrent_sessions)
|
|
387
|
+
)
|
|
388
|
+
if max_sessions < 1:
|
|
389
|
+
# 0 would defer every round forever: no session may spawn, so no
|
|
390
|
+
# slot ever frees. "Review nothing" is not a tuning value.
|
|
391
|
+
raise ValueError(
|
|
392
|
+
f"max_concurrent_sessions must be >= 1, got {max_sessions}"
|
|
393
|
+
)
|
|
394
|
+
if session_cap < max_sessions:
|
|
395
|
+
# The alarm would then fire on load the gate considers healthy --
|
|
396
|
+
# every poll of a fully-loaded, correctly-behaving daemon pages the
|
|
397
|
+
# operator, and a page that fires in the steady state trains people
|
|
398
|
+
# to ignore the one that matters. Refused at load rather than
|
|
399
|
+
# discovered at 3am.
|
|
400
|
+
raise ValueError(
|
|
401
|
+
f"reap_session_cap ({session_cap}) must be >= "
|
|
402
|
+
f"max_concurrent_sessions ({max_sessions}): the cap is the "
|
|
403
|
+
f"page-worthy alarm and the gate is the spawn limit, so an "
|
|
404
|
+
f"alarm below the limit pages on healthy load"
|
|
405
|
+
)
|
|
406
|
+
|
|
360
407
|
checks_wait = int(raw.get("checks_wait_seconds", cls.checks_wait_seconds))
|
|
361
408
|
if checks_wait < 0:
|
|
362
409
|
raise ValueError(f"checks_wait_seconds must be >= 0, got {checks_wait}")
|
|
@@ -404,6 +451,7 @@ class Config:
|
|
|
404
451
|
on_missing_hub=hub_mode,
|
|
405
452
|
reap_grace_seconds=grace,
|
|
406
453
|
reap_session_cap=session_cap,
|
|
454
|
+
max_concurrent_sessions=max_sessions,
|
|
407
455
|
checks_wait_seconds=checks_wait,
|
|
408
456
|
dry_run=bool(raw.get("dry_run", False)),
|
|
409
457
|
)
|
|
@@ -141,6 +141,81 @@ POLL_FAILURE_LOG_EVERY = 10
|
|
|
141
141
|
EXPECTED_POLL_FAILURES = (CommandError,)
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
# -- the spawn gate (issue #70) -----------------------------------------------
|
|
145
|
+
#
|
|
146
|
+
# The one INFO line a poll pass emits about deferrals, summarizing them all.
|
|
147
|
+
# Per PASS, never per round: a wave of eight PRs behind a gate of four is one
|
|
148
|
+
# fact about the container, not eight facts about PRs, and the deployed daemon
|
|
149
|
+
# polls every 30s. Streak-limited on the same rule as the poll firewall (the
|
|
150
|
+
# first few in full, then one in ten) so a long queue costs a handful of lines
|
|
151
|
+
# an hour.
|
|
152
|
+
DEFERRAL_SUMMARY = (
|
|
153
|
+
"spawn gate: %d round(s) deferred — %d/%d reviewer sessions live. "
|
|
154
|
+
"%s Nothing is lost: a deferred round burns no round number and no "
|
|
155
|
+
"attempt, and the oldest waiter takes the next free slot."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# What the summary becomes once the gate has been shut, with NOTHING spawning,
|
|
159
|
+
# for longer than POLL_ESCALATE_SECONDS (PR #71 round-1 [major]). Deferral
|
|
160
|
+
# itself is never page-worthy -- a container at its limit that keeps handing
|
|
161
|
+
# out freed slots is the gate working -- but a gate that has spawned nothing
|
|
162
|
+
# for half an hour is not that. Three session classes can hold it shut with the
|
|
163
|
+
# reap alarm silent: a BUSY session is never reaped whatever its PR's state, a
|
|
164
|
+
# hand-spawned `review-pr-<n>` on an OPEN PR is out of the reaper's scope by
|
|
165
|
+
# design (issue #46), and an undecidable session is spared every poll. Four of
|
|
166
|
+
# those against the shipped defaults (limit 4, alarm 6) is a fleet-wide review
|
|
167
|
+
# outage that no other channel reports: the gated rounds write no ledger row,
|
|
168
|
+
# so the stale-round probe cannot see them either.
|
|
169
|
+
DEFERRAL_STALLED = (
|
|
170
|
+
"spawn gate: %d round(s) deferred — %d/%d reviewer sessions live, and "
|
|
171
|
+
"NOTHING has spawned for %.0f min. %s This is no longer back-pressure "
|
|
172
|
+
"doing its job: the reap sweep never frees a busy session, and never "
|
|
173
|
+
"frees a hand-spawned review-pr-<n> on an open PR, so a wedged session "
|
|
174
|
+
"holds its slot indefinitely — check the sweep's survivors above."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# The recovery line for an ESCALATED stall: the gate started something again
|
|
178
|
+
# while rounds are still waiting. Logged unconditionally, outside the streak
|
|
179
|
+
# limit, on the rule _note_ledger_writable states -- the operator's last word
|
|
180
|
+
# on a degraded daemon must not be the degradation.
|
|
181
|
+
GATE_STALL_CLEARED = (
|
|
182
|
+
"spawn gate: spawning again after %d pass(es) over %.0f min with nothing "
|
|
183
|
+
"started — %d round(s) still waiting, and the queue is moving"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# The recovery line, logged once when a deferral streak ends -- the operator's
|
|
187
|
+
# only evidence in the log that a queue drained on its own.
|
|
188
|
+
DEFERRAL_CLEARED = (
|
|
189
|
+
"spawn gate: clear after %d pass(es) over %.0f min — every owed round "
|
|
190
|
+
"spawned"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class Waiting:
|
|
196
|
+
"""One round's place in the spawn queue, from its FIRST deferral.
|
|
197
|
+
|
|
198
|
+
`seq` is the ordering key and it is a COUNTER, not a clock: two rounds
|
|
199
|
+
deferred in the same pass are microseconds apart, and a wall-clock tie
|
|
200
|
+
would be broken by whatever `sorted` felt like -- which is the search's own
|
|
201
|
+
order, i.e. the starvation this exists to prevent. `since` is monotonic and
|
|
202
|
+
only ever reported, never compared.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
seq: int
|
|
206
|
+
since: float
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _slug_key(slug: str) -> tuple[str, int]:
|
|
210
|
+
"""`acme/widgets#7` -> the `_waiting` key `("acme/widgets", 7)`.
|
|
211
|
+
|
|
212
|
+
The poll walk carries decisions keyed by slug and the gate keys by
|
|
213
|
+
(repo, number); this is the one place the two spellings meet.
|
|
214
|
+
"""
|
|
215
|
+
repo, _, number = slug.partition("#")
|
|
216
|
+
return repo, int(number)
|
|
217
|
+
|
|
218
|
+
|
|
144
219
|
class LedgerUnwritable(RuntimeError):
|
|
145
220
|
"""Raised by `poll_once` when the ledger gate refuses the pass.
|
|
146
221
|
|
|
@@ -851,6 +926,17 @@ class Action(str, Enum):
|
|
|
851
926
|
# this one has given up, and the poll snapshot and console aggregate the
|
|
852
927
|
# action rather than the reason.
|
|
853
928
|
ABANDONED = "abandoned"
|
|
929
|
+
# The round is owed and nothing about the PR blocks it -- the SPAWN GATE
|
|
930
|
+
# does: `max_concurrent_sessions` reviewer sessions are already live, so it
|
|
931
|
+
# waits for a slot and is retried on a later poll (issue #70).
|
|
932
|
+
#
|
|
933
|
+
# NOT folded into the liveness deferral's IN_FLIGHT/`deferred` pair, which
|
|
934
|
+
# it superficially resembles: that one names a live session still working
|
|
935
|
+
# its round, and the console counts it as an ACTIVE SESSION
|
|
936
|
+
# (webui.sources: in_flight + deferred). A gated round has no session at
|
|
937
|
+
# all -- counting it as one would inflate exactly the number this gate
|
|
938
|
+
# exists to hold down.
|
|
939
|
+
QUEUED = "queued"
|
|
854
940
|
|
|
855
941
|
|
|
856
942
|
@dataclass(frozen=True)
|
|
@@ -1017,6 +1103,38 @@ class ReviewWatcher:
|
|
|
1017
1103
|
# de-duplication, and a corpus that outlived its pass would answer the
|
|
1018
1104
|
# next one from titles and statuses that have since moved.
|
|
1019
1105
|
self._pass_tasks: list[Task] | None = None
|
|
1106
|
+
# -- the spawn gate's per-pass and cross-pass state (issue #70) ------
|
|
1107
|
+
# How many own-grammar reviewer sessions are live THIS pass, and
|
|
1108
|
+
# whether anyone has looked. The sweep seeds both (it lists the
|
|
1109
|
+
# sessions anyway); a count of None with `_census_probed` True means
|
|
1110
|
+
# the list could not be read, which the gate treats as "unknown" and
|
|
1111
|
+
# fails OPEN -- see _live_session_count.
|
|
1112
|
+
self._session_census: int | None = None
|
|
1113
|
+
self._census_probed = False
|
|
1114
|
+
self._census_warned = False
|
|
1115
|
+
# (repo full name, PR number) -> where that round sits in the spawn
|
|
1116
|
+
# queue, from the first pass that deferred it. Cross-pass and
|
|
1117
|
+
# in-memory: it is a FAIRNESS ORDER, not a decision the daemon must
|
|
1118
|
+
# remember -- a restart costs the queue its order for one pass (every
|
|
1119
|
+
# waiter is re-stamped on its next deferral) and nothing else, which
|
|
1120
|
+
# does not justify a ledger table on the poll path. Pruned every pass
|
|
1121
|
+
# against the live candidate set, so a merged or converged PR cannot
|
|
1122
|
+
# hold a place forever.
|
|
1123
|
+
self._waiting: dict[tuple[str, int], Waiting] = {}
|
|
1124
|
+
self._wait_seq = 0
|
|
1125
|
+
# Consecutive passes that deferred at least one round -- the summary
|
|
1126
|
+
# line's streak limiter, and ONLY that. It never escalates: a queue
|
|
1127
|
+
# that keeps draining is the gate working, and a wave permanently
|
|
1128
|
+
# larger than the limit would otherwise page forever on a daemon that
|
|
1129
|
+
# is reviewing everything, just serially.
|
|
1130
|
+
self._gate_streak = Streak()
|
|
1131
|
+
# Consecutive passes that deferred a round and spawned NOTHING -- the
|
|
1132
|
+
# escalating one (PR #71 round-1 [major]). Cleared by any spawn,
|
|
1133
|
+
# because a spawn is the proof the queue is moving; once it outlasts
|
|
1134
|
+
# POLL_ESCALATE_SECONDS the summary switches to DEFERRAL_STALLED at
|
|
1135
|
+
# WARNING. Separate from the limiter above so the predicate that pages
|
|
1136
|
+
# is "the gate is stuck", not "the gate is busy".
|
|
1137
|
+
self._gate_stall = Streak()
|
|
1020
1138
|
# Consecutive passes refused by the ledger gate in poll_once, and when
|
|
1021
1139
|
# the refusal began -- the same streak-limit-then-escalate shape the
|
|
1022
1140
|
# poll firewall uses, for the same reason: a read-only volume refuses
|
|
@@ -1240,6 +1358,22 @@ class ReviewWatcher:
|
|
|
1240
1358
|
deferred = self._defer_stale_round(pr, round_, age, cap)
|
|
1241
1359
|
if deferred is not None:
|
|
1242
1360
|
return deferred
|
|
1361
|
+
|
|
1362
|
+
# THE SPAWN GATE, and it sits here -- past every branch that decides
|
|
1363
|
+
# WHETHER a round is owed, immediately before the one that acts.
|
|
1364
|
+
# Upstream of it the loop is only reading; downstream it starts an
|
|
1365
|
+
# interactive agent. So a gated round has passed convergence, the cap
|
|
1366
|
+
# and CR9 re-entry identically to one that spawns: re-entry-granted
|
|
1367
|
+
# rounds queue through the gate like any other, delayed and never
|
|
1368
|
+
# denied. A stale-round respawn is gated too -- it is a spawn, and the
|
|
1369
|
+
# dead session it replaces is exactly as absent next poll.
|
|
1370
|
+
held = self._gate_spawn(pr, round_)
|
|
1371
|
+
if held is not None:
|
|
1372
|
+
return held
|
|
1373
|
+
|
|
1374
|
+
if age is not None:
|
|
1375
|
+
# Logged only once the gate has let the respawn through, so the
|
|
1376
|
+
# line cannot claim a re-enqueue that back-pressure then deferred.
|
|
1243
1377
|
log.warning(
|
|
1244
1378
|
"%s round %d has been in flight %.0f min with no submitted review "
|
|
1245
1379
|
"and its session is gone or finished — re-enqueuing (reviewer "
|
|
@@ -1251,6 +1385,206 @@ class ReviewWatcher:
|
|
|
1251
1385
|
|
|
1252
1386
|
return self._spawn(pr, round_, task, cap, reenqueued=age is not None)
|
|
1253
1387
|
|
|
1388
|
+
# -- the spawn gate ----------------------------------------------------
|
|
1389
|
+
|
|
1390
|
+
def _gate_spawn(self, pr: PullRequest, round_: int) -> Decision | None:
|
|
1391
|
+
"""Hold an owed round back when the container is already full.
|
|
1392
|
+
|
|
1393
|
+
Returns a QUEUED Decision when the round must wait, or None to spawn.
|
|
1394
|
+
|
|
1395
|
+
DEFERRAL IS NOT FAILURE, and every part of that is load-bearing:
|
|
1396
|
+
|
|
1397
|
+
* it writes NO spawn-ledger row, so the round number is untouched (the
|
|
1398
|
+
next poll computes the same `completed + 1`) and no attempt is spent;
|
|
1399
|
+
* with no row there is no `spawn_age`, so the stale-round probe and its
|
|
1400
|
+
respawn branch cannot see the round at all -- a round can sit gated
|
|
1401
|
+
for hours without ever being "in flight 90 minutes";
|
|
1402
|
+
* it posts nothing: no PR comment, no operator page, no escalation.
|
|
1403
|
+
The only trace is one summary line per pass (see _note_deferrals).
|
|
1404
|
+
|
|
1405
|
+
The count is of sessions matching THIS package's grammar and no other
|
|
1406
|
+
(`Alissa.list_review_sessions` filters on `parse_session_name`), which
|
|
1407
|
+
is the same ownership boundary the reaper draws: other lanes share the
|
|
1408
|
+
container and are invisible here in both directions. Hand-spawned
|
|
1409
|
+
`review-pr-<n>` sessions are ours by that grammar and DO count -- they
|
|
1410
|
+
consume the same CPU, and a gate that ignored them would let a
|
|
1411
|
+
hand-driven round and a daemon round each think it had the last slot.
|
|
1412
|
+
|
|
1413
|
+
Sessions this pass has already enqueued count too (`_spawn` increments
|
|
1414
|
+
the census): the census is read once per pass, so without that a single
|
|
1415
|
+
pass would hand the same free slot to every PR in the wave.
|
|
1416
|
+
"""
|
|
1417
|
+
limit = self.config.max_concurrent_sessions
|
|
1418
|
+
live = self._live_session_count()
|
|
1419
|
+
key = (pr.full_name, pr.number)
|
|
1420
|
+
if live is None or live < limit:
|
|
1421
|
+
# Spawning: this round is no longer waiting on anything. Dropped
|
|
1422
|
+
# here rather than in `_spawn`, which can still bail on a missing
|
|
1423
|
+
# hub or review task -- a round that never reaches the enqueue is
|
|
1424
|
+
# not holding a queue place either.
|
|
1425
|
+
self._waiting.pop(key, None)
|
|
1426
|
+
return None
|
|
1427
|
+
|
|
1428
|
+
wait = self._waiting.get(key)
|
|
1429
|
+
if wait is None:
|
|
1430
|
+
self._wait_seq += 1
|
|
1431
|
+
wait = Waiting(seq=self._wait_seq, since=time.monotonic())
|
|
1432
|
+
self._waiting[key] = wait
|
|
1433
|
+
return Decision(
|
|
1434
|
+
Action.QUEUED,
|
|
1435
|
+
f"round {round_} deferred — {live}/{limit} reviewer sessions live "
|
|
1436
|
+
f"(waiting {int(time.monotonic() - wait.since)}s)",
|
|
1437
|
+
round_,
|
|
1438
|
+
)
|
|
1439
|
+
|
|
1440
|
+
def _live_session_count(self) -> int | None:
|
|
1441
|
+
"""Own-grammar reviewer sessions live this pass, or None if unknown.
|
|
1442
|
+
|
|
1443
|
+
Seeded by the reap sweep, which lists them anyway, so the gate costs no
|
|
1444
|
+
extra `alissa tmux ls` in the daemon. A caller that reaches the gate
|
|
1445
|
+
without a sweep (a direct `evaluate`, or a pass whose sweep bailed
|
|
1446
|
+
early) probes once and memoizes for the pass.
|
|
1447
|
+
|
|
1448
|
+
None -- the list could not be read -- FAILS OPEN: the spawn proceeds.
|
|
1449
|
+
The alternative was tried on paper and rejected: `alissa tmux ls` is
|
|
1450
|
+
also the reaper's only input, so a CLI that cannot answer it means
|
|
1451
|
+
nothing is being reaped either, and refusing every spawn would turn one
|
|
1452
|
+
broken subprocess into a fleet-wide review outage while the container
|
|
1453
|
+
sat idle. Failing open restores exactly the pre-gate behaviour for as
|
|
1454
|
+
long as the outage lasts, and it is loud (once per pass) about doing
|
|
1455
|
+
so.
|
|
1456
|
+
"""
|
|
1457
|
+
if not self._census_probed:
|
|
1458
|
+
self._census_probed = True
|
|
1459
|
+
try:
|
|
1460
|
+
self._session_census = len(self.alissa.list_review_sessions())
|
|
1461
|
+
except CommandError as exc:
|
|
1462
|
+
self._session_census = None
|
|
1463
|
+
log.warning(
|
|
1464
|
+
"spawn gate: could not count live reviewer sessions (%s) — "
|
|
1465
|
+
"allowing spawns this pass rather than stalling the loop on "
|
|
1466
|
+
"a session list the reaper cannot read either",
|
|
1467
|
+
exc,
|
|
1468
|
+
)
|
|
1469
|
+
self._census_warned = True
|
|
1470
|
+
if self._session_census is None and not self._census_warned:
|
|
1471
|
+
self._census_warned = True
|
|
1472
|
+
log.warning(
|
|
1473
|
+
"spawn gate: the live reviewer-session count is unknown this "
|
|
1474
|
+
"pass — allowing spawns (the gate is back-pressure, not a "
|
|
1475
|
+
"safety interlock)"
|
|
1476
|
+
)
|
|
1477
|
+
return self._session_census
|
|
1478
|
+
|
|
1479
|
+
def _gate_order(
|
|
1480
|
+
self, requests: list[tuple[str, str, int]]
|
|
1481
|
+
) -> list[tuple[str, str, int]]:
|
|
1482
|
+
"""This pass's candidates, oldest waiter first.
|
|
1483
|
+
|
|
1484
|
+
FIFO fairness, and it needs its own order because the walk order it
|
|
1485
|
+
replaces is NOT fair: `review_requests` is a `search/issues` query, and
|
|
1486
|
+
that API sorts by best match by default -- a relevance ranking with no
|
|
1487
|
+
relation to how long a round has been waiting, and not even stable
|
|
1488
|
+
between calls. Under a gate that hands out one slot per freed session,
|
|
1489
|
+
an unstable order starves whichever PR keeps losing the coin toss.
|
|
1490
|
+
|
|
1491
|
+
So: everything that has already been deferred goes first, in the order
|
|
1492
|
+
it was FIRST deferred (`Waiting.seq`), then everything else in the
|
|
1493
|
+
search's own order. A round that has waited two passes therefore takes
|
|
1494
|
+
the next free slot ahead of one that has waited none -- which is the
|
|
1495
|
+
whole anti-starvation guarantee -- and a pass with no deferrals is
|
|
1496
|
+
byte-for-byte the old walk.
|
|
1497
|
+
"""
|
|
1498
|
+
if not self._waiting:
|
|
1499
|
+
return requests
|
|
1500
|
+
return sorted(
|
|
1501
|
+
requests,
|
|
1502
|
+
key=lambda item: (
|
|
1503
|
+
self._waiting[(f"{item[0]}/{item[1]}", item[2])].seq
|
|
1504
|
+
if (f"{item[0]}/{item[1]}", item[2]) in self._waiting
|
|
1505
|
+
else self._wait_seq + 1
|
|
1506
|
+
),
|
|
1507
|
+
)
|
|
1508
|
+
|
|
1509
|
+
def _note_deferrals(self, results: list[tuple[str, Decision]]) -> None:
|
|
1510
|
+
"""One streak-limited line per pass summarizing the gate.
|
|
1511
|
+
|
|
1512
|
+
INFO while the queue MOVES -- a full container that keeps handing out
|
|
1513
|
+
freed slots is the gate working, and paging on it would make normal
|
|
1514
|
+
load indistinguishable from a leak. It escalates to WARNING on a
|
|
1515
|
+
different predicate: passes that deferred and spawned NOTHING,
|
|
1516
|
+
consecutively, for longer than POLL_ESCALATE_SECONDS. That is not
|
|
1517
|
+
back-pressure, it is a review outage the reap alarm can miss entirely
|
|
1518
|
+
(see DEFERRAL_STALLED), and this module's own rule for it is stated at
|
|
1519
|
+
_note_ledger_unwritable: a daemon that is up, polling, and deciding
|
|
1520
|
+
nothing is the most misleading state it can be in.
|
|
1521
|
+
|
|
1522
|
+
The two are separate streaks on purpose. One spawn proves the queue is
|
|
1523
|
+
moving and clears the stall, but it does NOT end the deferral episode
|
|
1524
|
+
the log limiter is rationing -- collapsing them would either page on a
|
|
1525
|
+
draining wave or reset the limiter every pass and log one line per
|
|
1526
|
+
poll, which is the spam the summary exists to avoid.
|
|
1527
|
+
"""
|
|
1528
|
+
now = time.monotonic()
|
|
1529
|
+
held = [(slug, d) for slug, d in results if d.action is Action.QUEUED]
|
|
1530
|
+
if not held:
|
|
1531
|
+
self._gate_stall.clear()
|
|
1532
|
+
ended = self._gate_streak.resolve(now)
|
|
1533
|
+
if ended is not None:
|
|
1534
|
+
passes, seconds = ended
|
|
1535
|
+
log.info(DEFERRAL_CLEARED, passes, seconds / 60)
|
|
1536
|
+
return
|
|
1537
|
+
|
|
1538
|
+
should_log, _ = self._gate_streak.record(now)
|
|
1539
|
+
if any(d.action is Action.SPAWNED for _, d in results):
|
|
1540
|
+
# The queue is draining: rounds waited, but a slot freed and the
|
|
1541
|
+
# oldest waiter took it. Whatever the backlog, this is not a stall.
|
|
1542
|
+
# A stall that had ESCALATED says so out loud on its way out, past
|
|
1543
|
+
# the streak limit -- otherwise the last word an operator has on
|
|
1544
|
+
# the gate is a WARNING the daemon has already recovered from.
|
|
1545
|
+
escalated = self._gate_stall.escalated
|
|
1546
|
+
ended = self._gate_stall.resolve(now)
|
|
1547
|
+
if escalated and ended is not None:
|
|
1548
|
+
passes, seconds = ended
|
|
1549
|
+
log.info(GATE_STALL_CLEARED, passes, seconds / 60, len(held))
|
|
1550
|
+
else:
|
|
1551
|
+
_, crossing = self._gate_stall.record(now)
|
|
1552
|
+
# The crossing BYPASSES the streak limit, on the same reasoning as
|
|
1553
|
+
# the ledger gate's: it is a state change, and suppressing it would
|
|
1554
|
+
# hide the one transition the line exists to show.
|
|
1555
|
+
should_log = should_log or crossing
|
|
1556
|
+
if not should_log:
|
|
1557
|
+
return
|
|
1558
|
+
# A round is only ever deferred against a census the gate could read,
|
|
1559
|
+
# so this is never the fallback -- an unreadable count fails open and
|
|
1560
|
+
# produces no deferrals at all.
|
|
1561
|
+
live = self._session_census or 0
|
|
1562
|
+
waiting = "Waiting, oldest first: " + ", ".join(
|
|
1563
|
+
slug for slug, _ in sorted(
|
|
1564
|
+
held,
|
|
1565
|
+
key=lambda item: self._waiting.get(
|
|
1566
|
+
_slug_key(item[0]), Waiting(seq=0, since=0.0)
|
|
1567
|
+
).seq,
|
|
1568
|
+
)
|
|
1569
|
+
) + "."
|
|
1570
|
+
if self._gate_stall.escalated:
|
|
1571
|
+
log.warning(
|
|
1572
|
+
DEFERRAL_STALLED,
|
|
1573
|
+
len(held),
|
|
1574
|
+
live,
|
|
1575
|
+
self.config.max_concurrent_sessions,
|
|
1576
|
+
self._gate_stall.held(now) / 60,
|
|
1577
|
+
waiting,
|
|
1578
|
+
)
|
|
1579
|
+
return
|
|
1580
|
+
log.info(
|
|
1581
|
+
DEFERRAL_SUMMARY,
|
|
1582
|
+
len(held),
|
|
1583
|
+
live,
|
|
1584
|
+
self.config.max_concurrent_sessions,
|
|
1585
|
+
waiting,
|
|
1586
|
+
)
|
|
1587
|
+
|
|
1254
1588
|
# -- native verdict post -----------------------------------------------
|
|
1255
1589
|
|
|
1256
1590
|
def _close_round_natively(
|
|
@@ -2402,6 +2736,10 @@ class ReviewWatcher:
|
|
|
2402
2736
|
sessions = self.alissa.list_review_sessions()
|
|
2403
2737
|
except CommandError as exc:
|
|
2404
2738
|
log.warning("reap sweep skipped: could not list sessions: %s", exc)
|
|
2739
|
+
# Probed and unanswerable. The spawn gate reads the same list, so
|
|
2740
|
+
# it is told the count is unknown rather than left to pay for a
|
|
2741
|
+
# second failing subprocess per candidate PR.
|
|
2742
|
+
self._census_probed, self._session_census = True, None
|
|
2405
2743
|
return 0
|
|
2406
2744
|
|
|
2407
2745
|
# Drop cached resolutions for sessions that are gone. Names are unique
|
|
@@ -2422,6 +2760,13 @@ class ReviewWatcher:
|
|
|
2422
2760
|
completed_cache: dict[tuple[str, int, str | None], float | None] = {}
|
|
2423
2761
|
holdouts: dict[str, str] = {}
|
|
2424
2762
|
reaped: list[str] = []
|
|
2763
|
+
# Dry-run only: what a production sweep would have killed here. The
|
|
2764
|
+
# spawn gate's census has to be seeded with production's slot count or
|
|
2765
|
+
# `--dry-run` reports rounds QUEUED that production would spawn -- the
|
|
2766
|
+
# opposite error to the one _spawn's self-increment fixes, in the one
|
|
2767
|
+
# tool an operator reaches for during exactly this incident class (PR
|
|
2768
|
+
# #71 round-1 [minor]). Empty in production, where `reaped` carries it.
|
|
2769
|
+
would_reap: list[str] = []
|
|
2425
2770
|
|
|
2426
2771
|
for ses in sessions:
|
|
2427
2772
|
idle_for = time.time() - ses.last_activity
|
|
@@ -2469,6 +2814,7 @@ class ReviewWatcher:
|
|
|
2469
2814
|
if self.config.dry_run:
|
|
2470
2815
|
log.info("[dry-run] would reap reviewer session %s (%s)", ses.name, evidence)
|
|
2471
2816
|
holdouts[ses.name] = f"dry-run: would have reaped ({evidence})"
|
|
2817
|
+
would_reap.append(ses.name)
|
|
2472
2818
|
continue
|
|
2473
2819
|
try:
|
|
2474
2820
|
self.alissa.kill_session(ses.name)
|
|
@@ -2483,7 +2829,7 @@ class ReviewWatcher:
|
|
|
2483
2829
|
reaped.append(ses.name)
|
|
2484
2830
|
log.info("reaped reviewer session %s (%s)", ses.name, evidence)
|
|
2485
2831
|
|
|
2486
|
-
self._check_session_cap(sessions, reaped, holdouts)
|
|
2832
|
+
self._check_session_cap(sessions, reaped, holdouts, would_reap)
|
|
2487
2833
|
return len(reaped)
|
|
2488
2834
|
|
|
2489
2835
|
def _hold(self, holdouts: dict[str, str], ses: ManagedSession, why: str) -> None:
|
|
@@ -2646,6 +2992,7 @@ class ReviewWatcher:
|
|
|
2646
2992
|
sessions: list[ManagedSession],
|
|
2647
2993
|
reaped: list[str],
|
|
2648
2994
|
holdouts: dict[str, str],
|
|
2995
|
+
would_reap: list[str] | None = None,
|
|
2649
2996
|
) -> None:
|
|
2650
2997
|
"""Page-worthy log when the sweep is not keeping up.
|
|
2651
2998
|
|
|
@@ -2673,6 +3020,23 @@ class ReviewWatcher:
|
|
|
2673
3020
|
"""
|
|
2674
3021
|
killed = set(reaped)
|
|
2675
3022
|
remaining = sorted(s.name for s in sessions if s.name not in killed)
|
|
3023
|
+
# The spawn gate's census for this pass, taken from the same post-sweep
|
|
3024
|
+
# list the alarm counts: what the sweep could not free is exactly what
|
|
3025
|
+
# is still holding CPU when the gate decides. Seeded here so the gate
|
|
3026
|
+
# never runs its own `alissa tmux ls`.
|
|
3027
|
+
#
|
|
3028
|
+
# `would_reap` is the dry-run correction and nothing else: a dry-run
|
|
3029
|
+
# sweep kills nothing, so without it the census counts slots production
|
|
3030
|
+
# would have freed and the diagnostic reports rounds gated that
|
|
3031
|
+
# production spawns. Deliberately NOT subtracted from `remaining`
|
|
3032
|
+
# below -- the alarm's own count is documented as "sessions this pass
|
|
3033
|
+
# could not free", and a dry-run pass genuinely freed none of them
|
|
3034
|
+
# (each carries a `dry-run: would have reaped` holdout reason that says
|
|
3035
|
+
# so). That reading predates the gate; the census is the part the gate
|
|
3036
|
+
# owns.
|
|
3037
|
+
pretend = set(would_reap or ())
|
|
3038
|
+
self._census_probed = True
|
|
3039
|
+
self._session_census = sum(1 for name in remaining if name not in pretend)
|
|
2676
3040
|
if len(remaining) <= self.config.reap_session_cap:
|
|
2677
3041
|
self._paged_cap = None
|
|
2678
3042
|
return
|
|
@@ -2804,6 +3168,16 @@ class ReviewWatcher:
|
|
|
2804
3168
|
dry_run=self.config.dry_run,
|
|
2805
3169
|
)
|
|
2806
3170
|
|
|
3171
|
+
# This pass's own spawns count against the gate immediately (issue
|
|
3172
|
+
# #70). The census is read once per pass and a just-enqueued session
|
|
3173
|
+
# takes a moment to appear in `alissa tmux ls`, so without this a
|
|
3174
|
+
# merge wave would hand one free slot to every PR in it -- the
|
|
3175
|
+
# unbounded burst the gate exists to prevent, reintroduced by a
|
|
3176
|
+
# caching detail. Incremented in dry-run too: the diagnostic's job is
|
|
3177
|
+
# to report the decisions production would take.
|
|
3178
|
+
if self._session_census is not None:
|
|
3179
|
+
self._session_census += 1
|
|
3180
|
+
|
|
2807
3181
|
if not self.config.dry_run:
|
|
2808
3182
|
self.state.record_spawn(
|
|
2809
3183
|
repo=pr.full_name,
|
|
@@ -3104,6 +3478,14 @@ class ReviewWatcher:
|
|
|
3104
3478
|
# to this one's title searches as if it were current, and it is the one
|
|
3105
3479
|
# piece of per-pass state whose staleness would be invisible.
|
|
3106
3480
|
self._pass_tasks = None
|
|
3481
|
+
# ...and so does the spawn gate's session census: a count carried over
|
|
3482
|
+
# from the previous pass would gate this one on sessions that have
|
|
3483
|
+
# since been reaped (or miss ones that have since spawned). Cleared
|
|
3484
|
+
# with `_pass_tasks` and for the same reason. The `_waiting` queue is
|
|
3485
|
+
# deliberately NOT cleared -- it is what remembers who has waited
|
|
3486
|
+
# longest ACROSS passes.
|
|
3487
|
+
self._session_census = None
|
|
3488
|
+
self._census_probed = self._census_warned = False
|
|
3107
3489
|
|
|
3108
3490
|
# THE LEDGER GATE (issue #62, PR #63 round-1 blocker). Nothing below
|
|
3109
3491
|
# may run when the ledger cannot record what it does.
|
|
@@ -3162,8 +3544,17 @@ class ReviewWatcher:
|
|
|
3162
3544
|
requests = self.github.review_requests(self.config.repos)
|
|
3163
3545
|
log.info("%d PR(s) with a review pending from %s", len(requests), self.github.login)
|
|
3164
3546
|
|
|
3547
|
+
# Forget queue places belonging to PRs this pass cannot act on at all
|
|
3548
|
+
# (merged, converged, withdrawn): they are gone from the search, so
|
|
3549
|
+
# nothing would ever clear them and the oldest-first order would be
|
|
3550
|
+
# anchored to a PR that is never coming back.
|
|
3551
|
+
live_keys = {(f"{owner}/{repo}", number) for owner, repo, number in requests}
|
|
3552
|
+
self._waiting = {
|
|
3553
|
+
key: wait for key, wait in self._waiting.items() if key in live_keys
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3165
3556
|
results = []
|
|
3166
|
-
for owner, repo, number in requests:
|
|
3557
|
+
for owner, repo, number in self._gate_order(requests):
|
|
3167
3558
|
slug = f"{owner}/{repo}#{number}"
|
|
3168
3559
|
if not self.config.watches(f"{owner}/{repo}"):
|
|
3169
3560
|
continue
|
|
@@ -3179,6 +3570,8 @@ class ReviewWatcher:
|
|
|
3179
3570
|
log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
|
|
3180
3571
|
results.append((slug, decision))
|
|
3181
3572
|
|
|
3573
|
+
self._note_deferrals(results)
|
|
3574
|
+
|
|
3182
3575
|
# Persist one poll_snapshots row per pass, built entirely from the
|
|
3183
3576
|
# Decision list already in hand plus the reap count -- no new GitHub
|
|
3184
3577
|
# calls. Written in dry-run too: a snapshot OBSERVES the pass, it is
|
|
@@ -3289,6 +3682,7 @@ class ReviewWatcher:
|
|
|
3289
3682
|
if d.action is Action.IN_FLIGHT and d.deferred
|
|
3290
3683
|
)
|
|
3291
3684
|
self.state.record_snapshot(
|
|
3685
|
+
queued=counts[Action.QUEUED],
|
|
3292
3686
|
duration_ms=duration_ms,
|
|
3293
3687
|
candidates=len(results),
|
|
3294
3688
|
spawned=spawned,
|
|
@@ -188,6 +188,12 @@ CREATE TABLE IF NOT EXISTS poll_snapshots (
|
|
|
188
188
|
posted INTEGER NOT NULL DEFAULT 0,
|
|
189
189
|
awaiting_post INTEGER NOT NULL DEFAULT 0,
|
|
190
190
|
abandoned INTEGER NOT NULL DEFAULT 0,
|
|
191
|
+
-- Rounds the spawn gate held back this pass because the container was
|
|
192
|
+
-- already at max_concurrent_sessions (issue #70). Its own column rather
|
|
193
|
+
-- than a share of `deferred`: that one counts rounds whose LIVE session is
|
|
194
|
+
-- still working, and the console reads the two together as active
|
|
195
|
+
-- sessions -- a gated round has no session at all.
|
|
196
|
+
queued INTEGER NOT NULL DEFAULT 0,
|
|
191
197
|
stages_json TEXT NOT NULL
|
|
192
198
|
);
|
|
193
199
|
"""
|
|
@@ -204,6 +210,7 @@ _ADDED_COLUMNS = {
|
|
|
204
210
|
("posted", "INTEGER NOT NULL DEFAULT 0"),
|
|
205
211
|
("awaiting_post", "INTEGER NOT NULL DEFAULT 0"),
|
|
206
212
|
("abandoned", "INTEGER NOT NULL DEFAULT 0"),
|
|
213
|
+
("queued", "INTEGER NOT NULL DEFAULT 0"),
|
|
207
214
|
),
|
|
208
215
|
"verdict_posts": (
|
|
209
216
|
("checks_held_at", "INTEGER"),
|
|
@@ -970,6 +977,7 @@ class State:
|
|
|
970
977
|
posted: int = 0,
|
|
971
978
|
awaiting_post: int = 0,
|
|
972
979
|
abandoned: int = 0,
|
|
980
|
+
queued: int = 0,
|
|
973
981
|
stages: list[dict],
|
|
974
982
|
) -> bool:
|
|
975
983
|
"""Append one poll-pass observation, then prune to the newest
|
|
@@ -1004,6 +1012,7 @@ class State:
|
|
|
1004
1012
|
posted=posted,
|
|
1005
1013
|
awaiting_post=awaiting_post,
|
|
1006
1014
|
abandoned=abandoned,
|
|
1015
|
+
queued=queued,
|
|
1007
1016
|
stages=stages,
|
|
1008
1017
|
),
|
|
1009
1018
|
"poll snapshot",
|
|
@@ -1026,6 +1035,7 @@ class State:
|
|
|
1026
1035
|
posted: int,
|
|
1027
1036
|
awaiting_post: int,
|
|
1028
1037
|
abandoned: int,
|
|
1038
|
+
queued: int,
|
|
1029
1039
|
stages: list[dict],
|
|
1030
1040
|
) -> None:
|
|
1031
1041
|
"""The snapshot INSERT + prune itself, strict. Split out so the
|
|
@@ -1034,8 +1044,8 @@ class State:
|
|
|
1034
1044
|
"INSERT INTO poll_snapshots "
|
|
1035
1045
|
"(ts, duration_ms, candidates, spawned, stale_reenqueued, "
|
|
1036
1046
|
"in_flight, deferred, converged, capped, escalated, skipped, "
|
|
1037
|
-
"reaped, posted, awaiting_post, abandoned, stages_json) "
|
|
1038
|
-
"VALUES (
|
|
1047
|
+
"reaped, posted, awaiting_post, abandoned, queued, stages_json) "
|
|
1048
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
1039
1049
|
(
|
|
1040
1050
|
int(time.time()),
|
|
1041
1051
|
duration_ms,
|
|
@@ -1052,6 +1062,7 @@ class State:
|
|
|
1052
1062
|
posted,
|
|
1053
1063
|
awaiting_post,
|
|
1054
1064
|
abandoned,
|
|
1065
|
+
queued,
|
|
1055
1066
|
json.dumps(stages, separators=(",", ":")),
|
|
1056
1067
|
),
|
|
1057
1068
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.16.13
|
|
@@ -204,7 +204,7 @@ section.panel {
|
|
|
204
204
|
border-color: var(--status-in-progress); background: var(--status-in-progress-bg); }
|
|
205
205
|
.pill.in-flight { color: var(--status-committed);
|
|
206
206
|
border-color: var(--status-committed); background: var(--status-committed-bg); }
|
|
207
|
-
.pill.deferred { color: var(--status-pending);
|
|
207
|
+
.pill.deferred, .pill.queued { color: var(--status-pending);
|
|
208
208
|
border-color: var(--status-pending); background: var(--status-pending-bg); }
|
|
209
209
|
.pill.converged, .pill.posted { color: var(--status-in-progress);
|
|
210
210
|
border-color: var(--status-in-progress); background: var(--status-in-progress-bg); }
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.16.12
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{alissa_tools_github_revloop-0.16.12 → alissa_tools_github_revloop-0.16.13}/requirements.txt
RENAMED
|
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
|