alissa-tools-github-revloop 0.26.0__tar.gz → 0.27.0__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.26.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.27.0}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/loop.py +18 -7
- alissa_tools_github_revloop-0.27.0/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +6 -4
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/page.py +63 -11
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/sources.py +181 -18
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.26.0/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/README.md +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/setup.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/config.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/state.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -1523,10 +1523,11 @@ class StabilityNotice:
|
|
|
1523
1523
|
The bookkeeping travels WITH the text rather than being written when the
|
|
1524
1524
|
gate decides, because the two are not the same event: the gate runs above
|
|
1525
1525
|
the CI gate and `_ensure_hub`, either of which can still refuse the round.
|
|
1526
|
-
Writing `
|
|
1527
|
-
that was never queued -- and the grant is the only thing that can lift
|
|
1528
|
-
hold, so spending one silently is the one bookkeeping error this guard
|
|
1529
|
-
cannot afford.
|
|
1526
|
+
Writing `grants_seen` there would spend an operator's re-entry grant on a
|
|
1527
|
+
round that was never queued -- and the grant is the only thing that can lift
|
|
1528
|
+
the hold, so spending one silently is the one bookkeeping error this guard
|
|
1529
|
+
cannot afford. The seed strengthens that: a gate-time write would also seed
|
|
1530
|
+
a fresh episode's ledger off a round the reviewer never received.
|
|
1530
1531
|
"""
|
|
1531
1532
|
|
|
1532
1533
|
text: str
|
|
@@ -2319,9 +2320,19 @@ class ReviewWatcher:
|
|
|
2319
2320
|
# capped out at 10, was granted +5, and went tests-only at round 13
|
|
2320
2321
|
# would get five "grace" rounds and no hold -- the guard disarmed on
|
|
2321
2322
|
# exactly the shape issue #105 cites (PR #106 round 1, minor). After the
|
|
2322
|
-
# seed the predicate means what it should: only an ack
|
|
2323
|
-
#
|
|
2324
|
-
#
|
|
2323
|
+
# seed the predicate means what it should: only an ack THE LEDGER DID
|
|
2324
|
+
# NOT ALREADY KNOW ABOUT can lift the hold. Deliberately not "posted
|
|
2325
|
+
# after this guard last spoke", which claims more than the code
|
|
2326
|
+
# enforces -- the seed reads `granted_rounds`, i.e. acks already
|
|
2327
|
+
# collected into state, and `_collect_acks` runs from exactly two places
|
|
2328
|
+
# (the cap-out branch and the `graced` branch here). So an ack posted
|
|
2329
|
+
# while the loop is below its cap with no episode open is not recorded
|
|
2330
|
+
# when it is posted; it is first discovered by a later `graced` scan and
|
|
2331
|
+
# lifts the hold despite predating the notice. That is the doctrine
|
|
2332
|
+
# working -- nothing consumed that grant, so it is genuinely unspent,
|
|
2333
|
+
# and it errs toward extra rounds rather than a false hold (PR #106
|
|
2334
|
+
# round 2, nit). Either way the write happens only once `_spawn` has
|
|
2335
|
+
# actually queued the round.
|
|
2325
2336
|
notice = STABILITY_NOTICE.format(
|
|
2326
2337
|
base=base[:8],
|
|
2327
2338
|
head=pr.head_sha[:8],
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.27.0
|
|
@@ -5,10 +5,12 @@ budget of its own beyond two cached checks.
|
|
|
5
5
|
Ported from the devloop's worker console (`alissa.tools.github.devloop.webui`,
|
|
6
6
|
its PR #38) and adapted to reviewer semantics: the unit of work is a PR round,
|
|
7
7
|
not an issue attempt, so the pipeline board is PR-centric (PR ref → round k of
|
|
8
|
-
the cap → session → stage), the operator inbox pages cap-outs
|
|
9
|
-
deferrals
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
the cap → session → stage), the operator inbox pages cap-outs, stalled
|
|
9
|
+
deferrals and stability holds, split live from settled so a page whose PR has
|
|
10
|
+
left the newest poll's candidate set files itself away, and there is no
|
|
11
|
+
worker-tasks panel (reviewers create no tasks) and no maintenance edge. Module
|
|
12
|
+
shapes are copied deliberately -- family precedent is copy-adapt per repo; a
|
|
13
|
+
shared-webui package is a separate, deferred lane.
|
|
12
14
|
|
|
13
15
|
The daemon (loop.py) already persists everything the console needs: every poll
|
|
14
16
|
pass writes one `poll_snapshots` row (UI-1, PR #35) carrying the pass timing,
|
|
@@ -244,6 +244,13 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
|
244
244
|
.inbox-kind { font-family: var(--mono); font-size: 0.75rem; color: var(--status-blocked); }
|
|
245
245
|
.inbox-kind.cap-out { color: var(--status-cancelled); }
|
|
246
246
|
.inbox-kind.stability-held { color: var(--status-cancelled); }
|
|
247
|
+
.inbox-settled > summary { cursor: pointer; list-style: none; padding: 0.55rem 0;
|
|
248
|
+
border-top: 1px solid var(--surface-border); color: var(--text-muted);
|
|
249
|
+
font-family: var(--mono); font-size: 0.75rem; }
|
|
250
|
+
.inbox-settled > summary::-webkit-details-marker { display: none; }
|
|
251
|
+
.inbox-settled .inbox-item { opacity: 0.55; }
|
|
252
|
+
.inbox-note { color: var(--text-muted); font-size: 0.75rem; font-family: var(--mono);
|
|
253
|
+
padding: 0.55rem 0; border-top: 1px solid var(--surface-border); }
|
|
247
254
|
.log {
|
|
248
255
|
font-family: var(--mono); font-size: 0.75rem; line-height: 1.55;
|
|
249
256
|
color: var(--text-tertiary); background: var(--bg-primary);
|
|
@@ -504,16 +511,60 @@ _JS = r"""
|
|
|
504
511
|
});
|
|
505
512
|
}
|
|
506
513
|
|
|
507
|
-
function
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
514
|
+
function inboxRow(it) {
|
|
515
|
+
return '<div class="inbox-item"><span>' +
|
|
516
|
+
'<span class="inbox-kind ' + esc(it.kind) + '">' + esc(it.kind) + '</span> ' +
|
|
517
|
+
'<a href="' + esc(it.url) + '" target="_blank" rel="noopener">' +
|
|
518
|
+
esc(it.repo_slug) + '#' + esc(it.number) + '</a>' +
|
|
519
|
+
(it.detail ? ' <span class="muted mono">' + esc(it.detail) + '</span>' : '') +
|
|
520
|
+
'</span><span class="muted num">' + dur(it.age_seconds) + '</span></div>';
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Two lists, one panel. `items` is what the operator still owes; `settled`
|
|
524
|
+
// is pages whose PR has left the poll's candidate set (merged, closed, or
|
|
525
|
+
// the review request withdrawn) -- exhaust, so it rides behind a collapsed
|
|
526
|
+
// <details> and never displaces the empty state. An inbox whose live half
|
|
527
|
+
// is empty says "Inbox clear." even with a hundred settled rows behind it:
|
|
528
|
+
// that is the whole point of the split.
|
|
529
|
+
function renderInbox(items, settled, truncated, dropped) {
|
|
530
|
+
settled = settled || [];
|
|
531
|
+
dropped = dropped || 0;
|
|
532
|
+
// The panel re-renders every poll and the innerHTML assignment below
|
|
533
|
+
// destroys this subtree, taking the <details> node with it -- and its
|
|
534
|
+
// `open` is the OPERATOR's state, not the payload's. So read it off the
|
|
535
|
+
// live node first and fold it back into the string being built, rather
|
|
536
|
+
// than re-applying it after the write (which would flash the footer shut
|
|
537
|
+
// for a frame on every poll). No prior node -- the first render, or an
|
|
538
|
+
// inbox that had nothing settled last time -- reads false, which is how
|
|
539
|
+
// collapsed-by-default survives this: the `open` here is never
|
|
540
|
+
// unconditional. Fifty rows are not readable in ten seconds.
|
|
541
|
+
var prior = el('inbox').querySelector('details.inbox-settled');
|
|
542
|
+
var open = !!(prior && prior.open);
|
|
543
|
+
// `Inbox clear.` is a positive claim that nothing is owed, so it may only
|
|
544
|
+
// be made off a COMPLETE read. When a ledger read came back at its bound
|
|
545
|
+
// there are older rows nobody looked at, and the panel says that instead.
|
|
546
|
+
var empty = truncated
|
|
547
|
+
? 'No live pages in the ledger window read — it was truncated, so an older page may still be outstanding.'
|
|
548
|
+
: 'Inbox clear.';
|
|
549
|
+
var body = items.length ? items.map(inboxRow).join('')
|
|
550
|
+
: '<div class="empty">' + empty + '</div>';
|
|
551
|
+
// A truncated read qualifies the LIST as much as its absence: a partial
|
|
552
|
+
// list of live pages reads as complete unless the panel says otherwise,
|
|
553
|
+
// so the qualifier rides with the rows and not only with the empty state.
|
|
554
|
+
if (items.length && truncated) {
|
|
555
|
+
body += '<div class="inbox-note">ledger window truncated — older pages were not read</div>';
|
|
556
|
+
}
|
|
557
|
+
if (settled.length) {
|
|
558
|
+
// Two true numbers, not one: `settled.length` is what expanding the
|
|
559
|
+
// footer actually reveals, and `dropped` is what the per-half cap left
|
|
560
|
+
// out of the payload -- a drop that happens well inside a read that
|
|
561
|
+
// never hit its bound, so `truncated` does not cover it.
|
|
562
|
+
body += '<details class="inbox-settled"' + (open ? ' open' : '') + '><summary>' +
|
|
563
|
+
settled.length + ' settled' +
|
|
564
|
+
(dropped ? ' (+' + dropped + ' not shown)' : '') + ' — show</summary>' +
|
|
565
|
+
settled.map(inboxRow).join('') + '</details>';
|
|
566
|
+
}
|
|
567
|
+
el('inbox').innerHTML = body;
|
|
517
568
|
}
|
|
518
569
|
|
|
519
570
|
function act(url, body, btn) {
|
|
@@ -614,7 +665,8 @@ _JS = r"""
|
|
|
614
665
|
sparkline(el('spark-duration'), d.sparklines.poll_duration_ms);
|
|
615
666
|
sparkline(el('spark-active'), d.sparklines.active_sessions);
|
|
616
667
|
renderPipeline(d.pipeline);
|
|
617
|
-
renderInbox(d.inbox
|
|
668
|
+
renderInbox(d.inbox, d.inbox_settled, d.inbox_truncated,
|
|
669
|
+
d.inbox_settled_dropped);
|
|
618
670
|
renderSessions(d.sessions);
|
|
619
671
|
renderTopProcs(d.top_procs);
|
|
620
672
|
renderLog(d.log);
|
|
@@ -103,6 +103,28 @@ INBOX_STABILITY = "stability-held"
|
|
|
103
103
|
# clears stops being an inbox. The ping read applies the kind filter in SQL, so
|
|
104
104
|
# this counts pages and not the telemetry rows interleaved with them.
|
|
105
105
|
INBOX_LIMIT = 50
|
|
106
|
+
# How many rows each ledger read materialises for the live/settled split to
|
|
107
|
+
# choose from. It has to be WIDER than what is rendered. The split picks from
|
|
108
|
+
# whatever the read returned, so reading exactly INBOX_LIMIT rows would make
|
|
109
|
+
# the live half not "every live page, capped at INBOX_LIMIT" but "the live
|
|
110
|
+
# pages that happen to be among the newest INBOX_LIMIT rows of a never-pruned
|
|
111
|
+
# store" -- and a full window of settled rows would empty it while a page is
|
|
112
|
+
# outstanding, which is the failure this whole split exists to prevent, reached
|
|
113
|
+
# from the other side. Four windows deep gives the live half room to fill past
|
|
114
|
+
# a long run of settled rows (`escalations` is keyed per head, so one PR that
|
|
115
|
+
# caps out across five pushes spends five slots). The read is still bounded, so
|
|
116
|
+
# the never-pruned tables still cannot grow an unbounded payload, and what
|
|
117
|
+
# reaches the page is capped at INBOX_LIMIT per half exactly as before. A wider
|
|
118
|
+
# window is still a window, so `inbox_truncated` covers the residual case.
|
|
119
|
+
INBOX_READ_LIMIT = INBOX_LIMIT * 4
|
|
120
|
+
# How many poll intervals a freshly raised page is live for regardless of the
|
|
121
|
+
# snapshot. The liveness test reads the LATEST snapshot, so a page raised
|
|
122
|
+
# between two passes has no snapshot to appear in yet and would flicker into
|
|
123
|
+
# `settled` for one refresh; two intervals covers the raise-to-next-pass gap
|
|
124
|
+
# with a pass to spare. Erring long is the safe direction -- the failure this
|
|
125
|
+
# split exists to prevent is an operator skipping the inbox, and a settled row
|
|
126
|
+
# shown one refresh too long costs nothing.
|
|
127
|
+
INBOX_LIVE_GRACE_INTERVALS = 2
|
|
106
128
|
# The kind prefix that makes a ping row an operator page. `read_pings` matches
|
|
107
129
|
# it in SQL; `_inbox` re-checks it to split the session out of the kind.
|
|
108
130
|
PING_STALLED_PREFIX = f"{ESCALATION_STALLED}:"
|
|
@@ -116,6 +138,23 @@ RETRY_NO_ROW = "no ledger row"
|
|
|
116
138
|
RETRY_UNAVAILABLE = "state unavailable"
|
|
117
139
|
|
|
118
140
|
|
|
141
|
+
def _pr_key(repo: object, number: object) -> "tuple[str, int] | None":
|
|
142
|
+
"""The identity the inbox and the pipeline board compare PRs by.
|
|
143
|
+
|
|
144
|
+
The two sides reach this from different stores -- an inbox row's `number`
|
|
145
|
+
comes out of sqlite, a board row's out of a snapshot's JSON -- so the key
|
|
146
|
+
is normalised rather than compared as-is, and a row too malformed to key
|
|
147
|
+
(a null number) returns None instead of raising: every read path here
|
|
148
|
+
degrades, it never blanks the dashboard.
|
|
149
|
+
"""
|
|
150
|
+
if not isinstance(number, (int, str)):
|
|
151
|
+
return None
|
|
152
|
+
try:
|
|
153
|
+
return (str(repo), int(number))
|
|
154
|
+
except ValueError:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
119
158
|
def is_managed(name: "str | None") -> bool:
|
|
120
159
|
"""Whether a tmux session belongs to this daemon's reviewer namespace.
|
|
121
160
|
|
|
@@ -257,24 +296,27 @@ class Sources:
|
|
|
257
296
|
return self._read_state([], lambda st: st.read_snapshots(limit))
|
|
258
297
|
|
|
259
298
|
def ledgers(self) -> dict:
|
|
260
|
-
"""The two INBOX tables, each bounded to
|
|
261
|
-
the console pages on
|
|
262
|
-
|
|
263
|
-
|
|
299
|
+
"""The two INBOX tables, each bounded to INBOX_READ_LIMIT rows of the
|
|
300
|
+
kind the console pages on -- deliberately wider than the INBOX_LIMIT
|
|
301
|
+
the payload renders, because `_inbox` splits live from settled inside
|
|
302
|
+
whatever this returns and a window of settled rows must not be able to
|
|
303
|
+
squeeze the live half out. The spawn ledger is deliberately not here:
|
|
304
|
+
it is a lookup table read by key, not a display list bounded by recency
|
|
305
|
+
-- `sessions` reads it for exactly the session names it renders."""
|
|
264
306
|
empty: "dict[str, list]" = {
|
|
265
307
|
"escalations": [], "pings": [], "stability_pings": []
|
|
266
308
|
}
|
|
267
309
|
return self._read_state(empty, lambda st: {
|
|
268
|
-
"escalations": st.read_escalations(
|
|
310
|
+
"escalations": st.read_escalations(INBOX_READ_LIMIT),
|
|
269
311
|
"pings": st.read_pings(
|
|
270
|
-
|
|
312
|
+
INBOX_READ_LIMIT, kind_prefix=PING_STALLED_PREFIX
|
|
271
313
|
),
|
|
272
314
|
# A SECOND bounded read rather than one unfiltered one: `read_pings`
|
|
273
315
|
# narrows to a single prefix in SQL so its limit bounds the rows the
|
|
274
316
|
# console actually renders, and the telemetry kinds interleaved with
|
|
275
317
|
# both pages would otherwise evict them.
|
|
276
318
|
"stability_pings": st.read_pings(
|
|
277
|
-
|
|
319
|
+
INBOX_READ_LIMIT, kind_prefix=PING_STABILITY_PREFIX
|
|
278
320
|
),
|
|
279
321
|
})
|
|
280
322
|
|
|
@@ -542,6 +584,16 @@ class Sources:
|
|
|
542
584
|
# and still working (in_flight) and one whose respawn is deferred
|
|
543
585
|
# behind a session that still shows life (deferred).
|
|
544
586
|
chrono = list(reversed(snaps))
|
|
587
|
+
# The board rows and the inbox liveness test read the SAME item set --
|
|
588
|
+
# built once, so the two panels can never disagree about which PRs the
|
|
589
|
+
# newest pass still had in hand.
|
|
590
|
+
items = self._pipeline(latest)
|
|
591
|
+
inbox = self._inbox(
|
|
592
|
+
ledgers["escalations"],
|
|
593
|
+
ledgers["pings"],
|
|
594
|
+
ledgers.get("stability_pings", []),
|
|
595
|
+
live_prs=self._live_prs(latest, items),
|
|
596
|
+
)
|
|
545
597
|
sparklines = {
|
|
546
598
|
"poll_duration_ms": [s["duration_ms"] for s in chrono],
|
|
547
599
|
"active_sessions": [s["in_flight"] + s["deferred"] for s in chrono],
|
|
@@ -588,13 +640,23 @@ class Sources:
|
|
|
588
640
|
"snapshot_ts": latest["ts"] if latest else None,
|
|
589
641
|
"duration_ms": latest["duration_ms"] if latest else None,
|
|
590
642
|
"round_cap": self.config.round_cap,
|
|
591
|
-
"items":
|
|
643
|
+
"items": items,
|
|
592
644
|
},
|
|
593
|
-
"inbox":
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
645
|
+
"inbox": inbox["live"],
|
|
646
|
+
# Exhaust, not backlog: pages whose PR has left the poll's
|
|
647
|
+
# candidate set. Kept in the payload (the operator can still audit
|
|
648
|
+
# what was raised) but out of the list that means "you owe this".
|
|
649
|
+
"inbox_settled": inbox["settled"],
|
|
650
|
+
# Post-cap on purpose: the counter has to match the rows expanding
|
|
651
|
+
# the footer actually reveals. What the cap dropped rides beside
|
|
652
|
+
# it rather than inside it, so both numbers are true of the thing
|
|
653
|
+
# they label.
|
|
654
|
+
"inbox_settled_count": len(inbox["settled"]),
|
|
655
|
+
"inbox_settled_dropped": inbox["settled_dropped"],
|
|
656
|
+
# At least one ledger read came back at its bound, so there are
|
|
657
|
+
# older rows this payload never looked at. The panel refuses to
|
|
658
|
+
# claim `Inbox clear.` on a window it knows was truncated.
|
|
659
|
+
"inbox_truncated": inbox["truncated"],
|
|
598
660
|
"sessions": sessions,
|
|
599
661
|
# Host-wide, not per session: when the memory tile says the charge
|
|
600
662
|
# IS resident, this is what names the holder.
|
|
@@ -631,12 +693,51 @@ class Sources:
|
|
|
631
693
|
items.append(item)
|
|
632
694
|
return items
|
|
633
695
|
|
|
696
|
+
def _live_prs(
|
|
697
|
+
self, latest: "dict | None", items: "list[dict]"
|
|
698
|
+
) -> "set[tuple[str, int]] | None":
|
|
699
|
+
"""The `(repo, number)` set an inbox page must still be in to be worth
|
|
700
|
+
an operator's attention, or None when there is no evidence either way.
|
|
701
|
+
|
|
702
|
+
The daemon's newest poll pass is the oracle: every PR with a review
|
|
703
|
+
pending from the reviewer identity is a candidate, so a page whose PR
|
|
704
|
+
is absent from the latest snapshot is one that pass did not list.
|
|
705
|
+
Ordinarily that means the trigger cleared -- the PR merged, closed, or
|
|
706
|
+
the review request was withdrawn -- and nothing the console offers can
|
|
707
|
+
act on it any more. Those three are the ordinary causes, NOT the only
|
|
708
|
+
ones: `review_requests` issues a single unpaginated `search/issues`
|
|
709
|
+
call, so past its page size a pass sees an arbitrary subset, and one
|
|
710
|
+
ranked by relevance rather than age, so the subset is not even stable
|
|
711
|
+
between calls (TASK-1796886433 covers closing that ceiling), and a
|
|
712
|
+
repo dropped from `config.watches` stops being walked at all, which
|
|
713
|
+
settles its outstanding pages by operator action rather than by
|
|
714
|
+
anything the PR did. A capped
|
|
715
|
+
(or stability-held) PR that is still open keeps its review request and
|
|
716
|
+
so stays in the set: its page is exactly the one that needs a re-entry
|
|
717
|
+
ack, and it must not be filed away.
|
|
718
|
+
|
|
719
|
+
Derived from the rendered board rows rather than the raw stages, so
|
|
720
|
+
the inbox and the pipeline panel cannot disagree about what the pass
|
|
721
|
+
had in hand. None (no snapshot at all -- a fresh boot, an unreadable
|
|
722
|
+
state.db) means "no evidence", and the caller treats every row as
|
|
723
|
+
live: the same rule the daemon's liveness oracle uses for a failed
|
|
724
|
+
listing, because hiding a page on missing evidence is the one
|
|
725
|
+
unrecoverable direction.
|
|
726
|
+
"""
|
|
727
|
+
if not latest:
|
|
728
|
+
return None
|
|
729
|
+
keys = (
|
|
730
|
+
_pr_key(item.get("repo_slug"), item.get("number")) for item in items
|
|
731
|
+
)
|
|
732
|
+
return {key for key in keys if key is not None}
|
|
733
|
+
|
|
634
734
|
def _inbox(
|
|
635
735
|
self,
|
|
636
736
|
escalations: "list[dict]",
|
|
637
737
|
pings: "list[dict]",
|
|
638
738
|
stability_pings: "list[dict] | None" = None,
|
|
639
|
-
|
|
739
|
+
live_prs: "set[tuple[str, int]] | None" = None,
|
|
740
|
+
) -> dict:
|
|
640
741
|
"""The operator inbox: everything the daemon paged a human about, in
|
|
641
742
|
one list, newest first.
|
|
642
743
|
|
|
@@ -653,11 +754,44 @@ class Sources:
|
|
|
653
754
|
process -- a caller that passed unfiltered rows still gets an inbox of
|
|
654
755
|
pages only, just a shorter one.
|
|
655
756
|
|
|
656
|
-
Bounded twice over: each reader is already capped at
|
|
657
|
-
(newest first), and
|
|
658
|
-
cannot grow without bound as the
|
|
757
|
+
Bounded twice over: each reader is already capped at INBOX_READ_LIMIT
|
|
758
|
+
rows (newest first), and each returned list is capped again at
|
|
759
|
+
INBOX_LIMIT, so the payload cannot grow without bound as the
|
|
760
|
+
never-pruned tables accumulate.
|
|
761
|
+
|
|
762
|
+
Returns the rows split two ways -- `live` (what the operator still
|
|
763
|
+
owes) and `settled` (the PR has left the poll's candidate set, so the
|
|
764
|
+
page is exhaust). `escalations` and `pings` are dedupe key stores and
|
|
765
|
+
must never be pruned, so this read-time split is the only place the
|
|
766
|
+
distinction can be made, and it is made from the local snapshot alone:
|
|
767
|
+
a page load still costs the GitHub API nothing. A row raised less than
|
|
768
|
+
INBOX_LIVE_GRACE_INTERVALS poll intervals ago is live whatever the
|
|
769
|
+
snapshot says, and `live_prs` of None (no snapshot) means every row is
|
|
770
|
+
live -- see `_live_prs`.
|
|
771
|
+
|
|
772
|
+
`truncated` reports that at least one of the reads came back AT its
|
|
773
|
+
bound, so there are older rows the split never saw. The reason it
|
|
774
|
+
exists is the empty case: an empty `live` half off a truncated window
|
|
775
|
+
means "no live pages among the rows I read", which is not what `Inbox
|
|
776
|
+
clear.` claims. It qualifies the non-empty case too, though -- a
|
|
777
|
+
partial list of live pages reads as complete unless the panel says
|
|
778
|
+
otherwise -- so the page renders it both ways. Widening the read (see
|
|
779
|
+
INBOX_READ_LIMIT) makes truncation rare; it cannot make it impossible,
|
|
780
|
+
and a panel that positively asserts nothing is owed had better be
|
|
781
|
+
right.
|
|
782
|
+
|
|
783
|
+
`settled_dropped` is the settled rows the INBOX_LIMIT cap left out of
|
|
784
|
+
the payload. It is a second, quieter truncation than `truncated`: it
|
|
785
|
+
happens inside a read that may never have reached its bound, so the
|
|
786
|
+
flag says nothing about it, and `len(settled)` alone would understate
|
|
787
|
+
a real backlog with no signal anywhere.
|
|
659
788
|
"""
|
|
660
789
|
now = int(self._wall())
|
|
790
|
+
grace = INBOX_LIVE_GRACE_INTERVALS * self.config.poll_interval
|
|
791
|
+
truncated = any(
|
|
792
|
+
len(rows) >= INBOX_READ_LIMIT
|
|
793
|
+
for rows in (escalations, pings, stability_pings or [])
|
|
794
|
+
)
|
|
661
795
|
out: list[dict] = []
|
|
662
796
|
for row in escalations:
|
|
663
797
|
out.append(
|
|
@@ -707,4 +841,33 @@ class Sources:
|
|
|
707
841
|
}
|
|
708
842
|
)
|
|
709
843
|
out.sort(key=lambda item: item["age_seconds"])
|
|
710
|
-
|
|
844
|
+
live: list[dict] = []
|
|
845
|
+
settled: list[dict] = []
|
|
846
|
+
for item in out:
|
|
847
|
+
key = _pr_key(item["repo_slug"], item["number"])
|
|
848
|
+
if (
|
|
849
|
+
live_prs is None
|
|
850
|
+
or item["age_seconds"] < grace
|
|
851
|
+
# A row this cannot key is missing evidence ABOUT THE ROW, and
|
|
852
|
+
# missing evidence never hides a page -- the same rule the
|
|
853
|
+
# None snapshot gets one clause up. Filing an unkeyable row
|
|
854
|
+
# away would be the console asserting something it does not
|
|
855
|
+
# know. Low reachability (both `number` columns are INTEGER
|
|
856
|
+
# NOT NULL), so this rests on caller discipline rather than on
|
|
857
|
+
# the schema, which is exactly why it is written down here.
|
|
858
|
+
or key is None
|
|
859
|
+
or key in live_prs
|
|
860
|
+
):
|
|
861
|
+
live.append(item)
|
|
862
|
+
else:
|
|
863
|
+
settled.append(item)
|
|
864
|
+
return {
|
|
865
|
+
"live": live[:INBOX_LIMIT],
|
|
866
|
+
"settled": settled[:INBOX_LIMIT],
|
|
867
|
+
# The settled rows the per-half cap left out. A SECOND truncation,
|
|
868
|
+
# and a quieter one: it happens inside a read that may never have
|
|
869
|
+
# hit its bound, so `truncated` says nothing about it and the
|
|
870
|
+
# count alone would silently understate.
|
|
871
|
+
"settled_dropped": max(0, len(settled) - INBOX_LIMIT),
|
|
872
|
+
"truncated": truncated,
|
|
873
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.26.0
|
|
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
|