alissa-tools-github-revloop 0.16.13__tar.gz → 0.16.15__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.13/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.15}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/loop.py +12 -4
- alissa_tools_github_revloop-0.16.15/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/webui/page.py +44 -1
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/webui/sources.py +41 -6
- alissa_tools_github_revloop-0.16.15/src/main/alissa/tools/github/revloop/webui/sysinfo.py +328 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.16.13/src/main/alissa/tools/github/revloop/version +0 -1
- alissa_tools_github_revloop-0.16.13/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -174
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/README.md +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/setup.py +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/config.py +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/state.py +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -166,12 +166,20 @@ DEFERRAL_SUMMARY = (
|
|
|
166
166
|
# those against the shipped defaults (limit 4, alarm 6) is a fleet-wide review
|
|
167
167
|
# outage that no other channel reports: the gated rounds write no ledger row,
|
|
168
168
|
# so the stale-round probe cannot see them either.
|
|
169
|
+
#
|
|
170
|
+
# It states the OBSERVATION and hands the diagnosis to the operator (PR #71
|
|
171
|
+
# round-2 [nit]): the predicate is "deferred and started nothing for half an
|
|
172
|
+
# hour", which four legitimately slow reviews satisfy exactly as well as a
|
|
173
|
+
# wedged session. Both are worth an operator's eyes at that duration, but only
|
|
174
|
+
# the survivor list tells them which they have -- so the line points there
|
|
175
|
+
# instead of naming a cause it cannot know.
|
|
169
176
|
DEFERRAL_STALLED = (
|
|
170
177
|
"spawn gate: %d round(s) deferred — %d/%d reviewer sessions live, and "
|
|
171
|
-
"NOTHING has spawned for %.0f min. %s
|
|
172
|
-
"doing its job: the reap sweep
|
|
173
|
-
"frees a
|
|
174
|
-
"
|
|
178
|
+
"NOTHING has spawned for %.0f min. %s Nothing has started for long enough "
|
|
179
|
+
"that this may no longer be back-pressure doing its job: the reap sweep "
|
|
180
|
+
"never frees a busy session, and never frees a hand-spawned "
|
|
181
|
+
"review-pr-<n> on an open PR, so a wedged session holds its slot "
|
|
182
|
+
"indefinitely — check the sweep's survivors above."
|
|
175
183
|
)
|
|
176
184
|
|
|
177
185
|
# The recovery line for an ESCALATED stall: the gate started something again
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.16.15
|
|
@@ -165,7 +165,7 @@ header.top h1 {
|
|
|
165
165
|
|
|
166
166
|
/* stat tiles: seamless 1px-gap grid */
|
|
167
167
|
.tiles {
|
|
168
|
-
display: grid; grid-template-columns: repeat(
|
|
168
|
+
display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px;
|
|
169
169
|
background: var(--surface-border); border: 1px solid var(--surface-border);
|
|
170
170
|
border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 2rem;
|
|
171
171
|
}
|
|
@@ -182,6 +182,9 @@ header.top h1 {
|
|
|
182
182
|
.meter.crit > span { background: var(--status-cancelled); }
|
|
183
183
|
|
|
184
184
|
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
|
185
|
+
/* Five tiles do not fit a laptop viewport at the tile's own type scale; the
|
|
186
|
+
900px rule below must stay LAST so the narrow case still wins. */
|
|
187
|
+
@media (max-width: 1100px) { .tiles { grid-template-columns: repeat(3, 1fr); } }
|
|
185
188
|
@media (max-width: 900px) { .grid2 { grid-template-columns: 1fr; }
|
|
186
189
|
.tiles { grid-template-columns: repeat(2, 1fr); } }
|
|
187
190
|
|
|
@@ -331,6 +334,11 @@ _DASHBOARD = """<!doctype html>
|
|
|
331
334
|
|
|
332
335
|
<section class="panel"><p class="overline">Sessions</p><div id="sessions"></div></section>
|
|
333
336
|
|
|
337
|
+
<section class="panel">
|
|
338
|
+
<p class="overline">Top Processes · by RSS, host-wide</p>
|
|
339
|
+
<div id="topprocs"></div>
|
|
340
|
+
</section>
|
|
341
|
+
|
|
334
342
|
<section class="panel">
|
|
335
343
|
<p class="overline">Daemon Log · <span id="log-path" class="mono muted"></span></p>
|
|
336
344
|
<div class="log" id="log"></div>
|
|
@@ -422,6 +430,21 @@ _JS = r"""
|
|
|
422
430
|
var vsub = bytes(t.volume.used_bytes) + ' / ' + bytes(t.volume.total_bytes);
|
|
423
431
|
out += tile('Volume', t.volume.percent + '%', vsub, t.volume.percent);
|
|
424
432
|
} else { out += tile('Volume', '--', 'unavailable'); }
|
|
433
|
+
// The tile a platform memory graph sends you here to read: the headline is
|
|
434
|
+
// what the container is CHARGED, the sub splits it into what is really
|
|
435
|
+
// process memory and what is cache the kernel would drop under pressure --
|
|
436
|
+
// so a "6 GB" plateau reads as "72 MB real + 5.9 GB cache" at a glance.
|
|
437
|
+
// The meter is the RESIDENT share of the charge, deliberately: it is the
|
|
438
|
+
// only part of a rising charge that a limit can actually kill for, so the
|
|
439
|
+
// shared warn/crit thresholds mean the same thing here as on the others.
|
|
440
|
+
var mem = t.memory;
|
|
441
|
+
if (mem && mem.charged != null) {
|
|
442
|
+
var msub = (mem.resident == null ? '--' : bytes(mem.resident)) + ' resident · ' +
|
|
443
|
+
(mem.reclaimable == null ? '--' : bytes(mem.reclaimable)) + ' reclaimable';
|
|
444
|
+
var mpct = (mem.resident == null || !mem.charged) ? null :
|
|
445
|
+
Math.round(100 * mem.resident / mem.charged);
|
|
446
|
+
out += tile('Container Memory', bytes(mem.charged), msub, mpct);
|
|
447
|
+
} else { out += tile('Container Memory', '--', 'no cgroup v2'); }
|
|
425
448
|
out += tile('Review Queue', t.queue_depth, 'PRs awaiting me, last poll');
|
|
426
449
|
el('tiles').innerHTML = out;
|
|
427
450
|
}
|
|
@@ -524,6 +547,25 @@ _JS = r"""
|
|
|
524
547
|
});
|
|
525
548
|
}
|
|
526
549
|
|
|
550
|
+
// Host-wide, not per session: it names whatever holds a resident charge,
|
|
551
|
+
// which is routinely not a reviewer at all. Read-only -- no kill button
|
|
552
|
+
// here, because these PIDs are unmanaged and the sessions panel above is
|
|
553
|
+
// the only place a process should be killed from.
|
|
554
|
+
function renderTopProcs(rows) {
|
|
555
|
+
rows = rows || [];
|
|
556
|
+
if (!rows.length) {
|
|
557
|
+
el('topprocs').innerHTML = '<div class="empty">No process data (/proc unreadable).</div>';
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
var head = '<table><thead><tr><th class="num">PID</th><th>Process</th>' +
|
|
561
|
+
'<th class="num">RSS</th></tr></thead><tbody>';
|
|
562
|
+
el('topprocs').innerHTML = head + rows.map(function (p) {
|
|
563
|
+
return '<tr><td class="num mono">' + esc(p.pid) + '</td>' +
|
|
564
|
+
'<td class="mono">' + esc(p.comm) + '</td>' +
|
|
565
|
+
'<td class="num">' + bytes(p.rss_bytes) + '</td></tr>';
|
|
566
|
+
}).join('') + '</tbody></table>';
|
|
567
|
+
}
|
|
568
|
+
|
|
527
569
|
function renderLog(log) {
|
|
528
570
|
el('log-path').textContent = log.path || '(no log configured)';
|
|
529
571
|
el('log').textContent = log.lines.length ? log.lines.join('\n') : '(log empty or unavailable)';
|
|
@@ -558,6 +600,7 @@ _JS = r"""
|
|
|
558
600
|
renderPipeline(d.pipeline);
|
|
559
601
|
renderInbox(d.inbox);
|
|
560
602
|
renderSessions(d.sessions);
|
|
603
|
+
renderTopProcs(d.top_procs);
|
|
561
604
|
renderLog(d.log);
|
|
562
605
|
var when = new Date(d.generated_at * 1000).toLocaleTimeString();
|
|
563
606
|
el('status-line').textContent = 'updated ' + when;
|
|
@@ -7,7 +7,10 @@ in strict budget order:
|
|
|
7
7
|
UI-1 reader), the spawn ledger, the escalation table and the ping ledger. No
|
|
8
8
|
GitHub call: the daemon already wrote everything down.
|
|
9
9
|
2. **Local process state** -- `alissa tmux ls --json` for the session list, and
|
|
10
|
-
a `/proc` walk (sysinfo) of each session's pane-PID tree for CPU%/RSS
|
|
10
|
+
a `/proc` walk (sysinfo) of each session's pane-PID tree for CPU%/RSS, plus
|
|
11
|
+
the two container-wide reads the per-session sums cannot answer: the cgroup
|
|
12
|
+
memory charge split into resident vs reclaimable, and the top processes by
|
|
13
|
+
RSS across the whole host. One `/proc` scan serves both.
|
|
11
14
|
3. **Two cached remote checks** -- `gh api rate_limit` (60s cache) for the rate
|
|
12
15
|
meter, and the PyPI version JSON (10m cache) for the running-vs-latest drift
|
|
13
16
|
chip. These are the *only* network calls, and both are cached so a room full
|
|
@@ -61,6 +64,10 @@ from . import sysinfo
|
|
|
61
64
|
|
|
62
65
|
# How many recent snapshots feed the sparklines / pipeline board.
|
|
63
66
|
SPARK_POINTS = 60
|
|
67
|
+
# How many processes the host-wide top-by-RSS list carries. Five is the whole
|
|
68
|
+
# point of the panel: it names what is holding a resident charge, it is not a
|
|
69
|
+
# process browser, and every extra row is payload on a ~10s poll.
|
|
70
|
+
TOP_PROCS = 5
|
|
64
71
|
# Cache lifetimes for the remote checks (seconds).
|
|
65
72
|
RATE_CACHE_TTL = 60.0
|
|
66
73
|
VERSION_CACHE_TTL = 600.0
|
|
@@ -156,6 +163,7 @@ class Sources:
|
|
|
156
163
|
run: "Callable[..., str]" = proc_run,
|
|
157
164
|
http_get: "Callable[[str, float], bytes | None]" = _default_http_get,
|
|
158
165
|
proc_root: str = "/proc",
|
|
166
|
+
cgroup_root: str = "/sys/fs/cgroup",
|
|
159
167
|
clock: Callable[[], float] = time.monotonic,
|
|
160
168
|
wall_clock: Callable[[], float] = time.time,
|
|
161
169
|
) -> None:
|
|
@@ -165,6 +173,7 @@ class Sources:
|
|
|
165
173
|
self._run = run
|
|
166
174
|
self._http_get = http_get
|
|
167
175
|
self._proc_root = proc_root
|
|
176
|
+
self._cgroup_root = cgroup_root
|
|
168
177
|
self._clock = clock
|
|
169
178
|
self._wall = wall_clock
|
|
170
179
|
self._rate_cache = _Cache(RATE_CACHE_TTL, clock)
|
|
@@ -283,7 +292,11 @@ class Sources:
|
|
|
283
292
|
rows = self._read_state([], lambda st: st.read_spawns(sessions=names))
|
|
284
293
|
return {row["session"]: row for row in rows}
|
|
285
294
|
|
|
286
|
-
def sessions(
|
|
295
|
+
def sessions(
|
|
296
|
+
self,
|
|
297
|
+
spawns: "list[dict] | None" = None,
|
|
298
|
+
index: "tuple[dict[int, list[int]], dict[int, dict]] | None" = None,
|
|
299
|
+
) -> "list[dict]":
|
|
287
300
|
"""The managed-session table: liveness from `alissa tmux ls`, footprint
|
|
288
301
|
from /proc, and the PR round each session is reviewing.
|
|
289
302
|
|
|
@@ -296,6 +309,12 @@ class Sources:
|
|
|
296
309
|
`spawns` supplies the ledger rows directly; when it is None (the
|
|
297
310
|
dashboard's path) they are read here, keyed by the names tmux just
|
|
298
311
|
returned -- which is why the session list is fetched first.
|
|
312
|
+
|
|
313
|
+
`index` supplies the `/proc` snapshot. The dashboard now needs one
|
|
314
|
+
anyway for the host-wide top-process list, so it builds the index once
|
|
315
|
+
and hands the same one here; passed None (a caller that only wants the
|
|
316
|
+
table) the old lazy build is unchanged and a table with no live pane
|
|
317
|
+
still never scans `/proc`.
|
|
299
318
|
"""
|
|
300
319
|
raw = self._safe_json(["alissa", "tmux", "ls", "--json"]) or []
|
|
301
320
|
if not isinstance(raw, list):
|
|
@@ -311,9 +330,8 @@ class Sources:
|
|
|
311
330
|
out: list[dict] = []
|
|
312
331
|
# ONE /proc snapshot for the whole table: the index is identical for
|
|
313
332
|
# every session in this build, so rebuilding it per session would make
|
|
314
|
-
# the walk O(sessions x processes). Built lazily
|
|
315
|
-
# live pane never scans /proc at all.
|
|
316
|
-
index: "tuple[dict[int, list[int]], dict[int, dict]] | None" = None
|
|
333
|
+
# the walk O(sessions x processes). Built lazily when the caller did
|
|
334
|
+
# not supply one -- a table with no live pane never scans /proc at all.
|
|
317
335
|
for entry in raw:
|
|
318
336
|
if not isinstance(entry, dict):
|
|
319
337
|
continue
|
|
@@ -479,9 +497,18 @@ class Sources:
|
|
|
479
497
|
snaps = self.snapshots(SPARK_POINTS)
|
|
480
498
|
latest = snaps[0] if snaps else None
|
|
481
499
|
ledgers = self.ledgers()
|
|
482
|
-
|
|
500
|
+
# ONE /proc scan for this whole build: the session table walks pane
|
|
501
|
+
# trees out of it and the top-process list ranks the same snapshot, so
|
|
502
|
+
# the two panels can never disagree about a process that exited between
|
|
503
|
+
# them -- and the host pays for one walk per poll, not two.
|
|
504
|
+
proc_index = sysinfo.build_index(self._proc_root)
|
|
505
|
+
sessions = self.sessions(index=proc_index)
|
|
483
506
|
rate = self.rate_limit()
|
|
484
507
|
disk = sysinfo.disk_usage(self.config.workspace_root)
|
|
508
|
+
memory = sysinfo.cgroup_memory(self._cgroup_root)
|
|
509
|
+
top_procs = sysinfo.top_procs(
|
|
510
|
+
TOP_PROCS, proc_root=self._proc_root, index=proc_index
|
|
511
|
+
)
|
|
485
512
|
|
|
486
513
|
# Sparklines want oldest -> newest for left-to-right drawing. "Active"
|
|
487
514
|
# counts both buckets a live reviewer session sits in: a round enqueued
|
|
@@ -522,6 +549,11 @@ class Sources:
|
|
|
522
549
|
"live_sessions": sum(1 for s in sessions if s["live"]),
|
|
523
550
|
"rate": rate,
|
|
524
551
|
"volume": disk,
|
|
552
|
+
# The container's own charge, split three ways. Every field is
|
|
553
|
+
# None on a host without cgroup v2 (dev laptop, macOS) and the
|
|
554
|
+
# tile renders "unavailable" -- the console must not require
|
|
555
|
+
# Linux to load.
|
|
556
|
+
"memory": memory,
|
|
525
557
|
"queue_depth": latest["candidates"] if latest else 0,
|
|
526
558
|
},
|
|
527
559
|
"sparklines": sparklines,
|
|
@@ -533,6 +565,9 @@ class Sources:
|
|
|
533
565
|
},
|
|
534
566
|
"inbox": self._inbox(ledgers["escalations"], ledgers["pings"]),
|
|
535
567
|
"sessions": sessions,
|
|
568
|
+
# Host-wide, not per session: when the memory tile says the charge
|
|
569
|
+
# IS resident, this is what names the holder.
|
|
570
|
+
"top_procs": top_procs,
|
|
536
571
|
"log": self.log_tail(),
|
|
537
572
|
}
|
|
538
573
|
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Per-session resource accounting off `/proc`, plus workspace disk and
|
|
2
|
+
container-memory usage.
|
|
3
|
+
|
|
4
|
+
The sessions panel wants CPU% and RSS for each managed tmux session. tmux hands
|
|
5
|
+
us a pane PID; the real work runs in that pane's child tree (a shell, the agent,
|
|
6
|
+
its subprocesses), so we sum over the whole tree rooted at the pane PID.
|
|
7
|
+
|
|
8
|
+
Two deliberate properties:
|
|
9
|
+
|
|
10
|
+
* **Sample-free CPU.** Instantaneous CPU% needs two `/proc` reads spaced apart;
|
|
11
|
+
a dashboard endpoint polled every ~10s should not block sampling. Instead we
|
|
12
|
+
report the *cumulative average* since the pane started:
|
|
13
|
+
`100 * (utime+stime)/CLK_TCK / (uptime - starttime/CLK_TCK)`. It answers "how
|
|
14
|
+
hot has this session run over its life", which is the operator question, and
|
|
15
|
+
needs a single read.
|
|
16
|
+
* **Vanished-PID tolerant.** Between listing `/proc` and reading a PID's `stat`,
|
|
17
|
+
the process can exit. Every read that fails (gone, permission, malformed) is
|
|
18
|
+
skipped -- a disappearing reviewer contributes nothing and never raises.
|
|
19
|
+
|
|
20
|
+
The `/proc` root is a parameter so the parsing is testable against a crafted
|
|
21
|
+
fixture directory (including a PID that is indexed but whose `stat` was deleted
|
|
22
|
+
mid-walk -- the vanished-PID case the mutation bar pins).
|
|
23
|
+
|
|
24
|
+
Per-session accounting cannot answer the question a platform memory graph
|
|
25
|
+
asks. A Railway-style flat multi-GB plateau with zero reviewer sessions is
|
|
26
|
+
CONTAINER-wide, and the per-pane sums (tens of MB) say nothing about it, so
|
|
27
|
+
`cgroup_memory` reads the container's own cgroup v2 charge and splits it into
|
|
28
|
+
the three numbers that settle "is that a leak?": what the container is charged
|
|
29
|
+
for, what is really process memory, and what is page cache the kernel would
|
|
30
|
+
drop the moment anything asked for it. `top_procs` is the same question one
|
|
31
|
+
level down -- the whole `/proc`, not one pane tree -- so a charge that IS
|
|
32
|
+
resident can be attributed to a process without shelling into the container.
|
|
33
|
+
Both follow the module's conventions: roots are parameters, every read
|
|
34
|
+
degrades to None instead of raising (a dev laptop or macOS has no cgroup v2
|
|
35
|
+
and must still render the console), and neither samples.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import os
|
|
41
|
+
import shutil
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
_CLK_TCK = os.sysconf("SC_CLK_TCK") if hasattr(os, "sysconf") else 100
|
|
45
|
+
_PAGE_SIZE = os.sysconf("SC_PAGE_SIZE") if hasattr(os, "sysconf") else 4096
|
|
46
|
+
|
|
47
|
+
# Field offsets INTO the tail of /proc/<pid>/stat (everything after the
|
|
48
|
+
# "(comm)" field, which itself is field 3 == index 0 of the tail). See
|
|
49
|
+
# proc(5): utime=14, stime=15, starttime=22, rss=24 -> tail indices below.
|
|
50
|
+
_TAIL_PPID = 1
|
|
51
|
+
_TAIL_UTIME = 11
|
|
52
|
+
_TAIL_STIME = 12
|
|
53
|
+
_TAIL_STARTTIME = 19
|
|
54
|
+
_TAIL_RSS = 21
|
|
55
|
+
_TAIL_MIN_LEN = 22
|
|
56
|
+
|
|
57
|
+
# The cgroup v2 files the memory tile reads, and the `memory.stat` keys it
|
|
58
|
+
# keeps. Deliberately a fixed, small set: `memory.stat` carries ~40 keys and
|
|
59
|
+
# the tile answers one question, so parsing the rest would be payload we never
|
|
60
|
+
# render. `anon` is process memory, `file` is page cache, `slab_reclaimable` is
|
|
61
|
+
# kernel cache the shrinkers can free -- together they are the split between a
|
|
62
|
+
# real footprint and a charge the kernel would give back under pressure.
|
|
63
|
+
_CGROUP_CURRENT = "memory.current"
|
|
64
|
+
_CGROUP_STAT = "memory.stat"
|
|
65
|
+
_CGROUP_STAT_KEYS = (
|
|
66
|
+
"anon",
|
|
67
|
+
"file",
|
|
68
|
+
"inactive_file",
|
|
69
|
+
"shmem",
|
|
70
|
+
"slab_reclaimable",
|
|
71
|
+
"slab_unreclaimable",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_stat(proc_root: "str | os.PathLike[str]", pid: int) -> "dict | None":
|
|
76
|
+
"""Parse one `/proc/<pid>/stat`, or None if the PID has vanished or the
|
|
77
|
+
line is malformed. The `comm` field can contain spaces and parentheses, so
|
|
78
|
+
we split on the LAST ')' before tokenising -- the canonical safe parse.
|
|
79
|
+
|
|
80
|
+
`comm` is carried in the row (between the FIRST '(' and that last ')', the
|
|
81
|
+
mirror of the same parse) purely for the top-process list: the tree sums
|
|
82
|
+
need only the numbers, but a top-5 by RSS that shows bare PIDs tells an
|
|
83
|
+
operator nothing about what is holding the memory.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
data = (Path(proc_root) / str(pid) / "stat").read_text()
|
|
87
|
+
except (OSError, ValueError):
|
|
88
|
+
return None
|
|
89
|
+
rparen = data.rfind(")")
|
|
90
|
+
lparen = data.find("(")
|
|
91
|
+
if rparen == -1 or lparen == -1 or lparen > rparen:
|
|
92
|
+
return None
|
|
93
|
+
tail = data[rparen + 1:].split()
|
|
94
|
+
if len(tail) < _TAIL_MIN_LEN:
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
return {
|
|
98
|
+
"pid": int(pid),
|
|
99
|
+
"comm": data[lparen + 1:rparen],
|
|
100
|
+
"ppid": int(tail[_TAIL_PPID]),
|
|
101
|
+
"cpu_ticks": int(tail[_TAIL_UTIME]) + int(tail[_TAIL_STIME]),
|
|
102
|
+
"starttime": int(tail[_TAIL_STARTTIME]),
|
|
103
|
+
"rss_pages": int(tail[_TAIL_RSS]),
|
|
104
|
+
}
|
|
105
|
+
except (ValueError, IndexError):
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def read_uptime(proc_root: "str | os.PathLike[str]") -> "float | None":
|
|
110
|
+
try:
|
|
111
|
+
return float((Path(proc_root) / "uptime").read_text().split()[0])
|
|
112
|
+
except (OSError, ValueError, IndexError):
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_index(
|
|
117
|
+
proc_root: "str | os.PathLike[str]",
|
|
118
|
+
) -> "tuple[dict[int, list[int]], dict[int, dict]]":
|
|
119
|
+
"""Scan `/proc` once: return (ppid -> [child pids], pid -> stat). Numeric
|
|
120
|
+
entries only; anything that vanishes mid-scan is silently skipped."""
|
|
121
|
+
children: dict[int, list[int]] = {}
|
|
122
|
+
stats: dict[int, dict] = {}
|
|
123
|
+
try:
|
|
124
|
+
entries = os.listdir(proc_root)
|
|
125
|
+
except OSError:
|
|
126
|
+
return children, stats
|
|
127
|
+
for name in entries:
|
|
128
|
+
if not name.isdigit():
|
|
129
|
+
continue
|
|
130
|
+
st = read_stat(proc_root, int(name))
|
|
131
|
+
if st is None:
|
|
132
|
+
continue
|
|
133
|
+
stats[st["pid"]] = st
|
|
134
|
+
children.setdefault(st["ppid"], []).append(st["pid"])
|
|
135
|
+
return children, stats
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def tree_pids(root_pid: int, children: "dict[int, list[int]]") -> "set[int]":
|
|
139
|
+
"""Every PID in the subtree rooted at `root_pid` (inclusive). Iterative and
|
|
140
|
+
cycle-guarded -- a corrupt ppid loop can never spin forever."""
|
|
141
|
+
seen: set[int] = set()
|
|
142
|
+
stack = [root_pid]
|
|
143
|
+
while stack:
|
|
144
|
+
pid = stack.pop()
|
|
145
|
+
if pid in seen:
|
|
146
|
+
continue
|
|
147
|
+
seen.add(pid)
|
|
148
|
+
stack.extend(children.get(pid, ()))
|
|
149
|
+
return seen
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def tree_usage(
|
|
153
|
+
root_pid: "int | None",
|
|
154
|
+
*,
|
|
155
|
+
proc_root: "str | os.PathLike[str]" = "/proc",
|
|
156
|
+
index: "tuple[dict[int, list[int]], dict[int, dict]] | None" = None,
|
|
157
|
+
) -> dict:
|
|
158
|
+
"""CPU% (cumulative average since the pane started) and summed RSS for the
|
|
159
|
+
whole process tree under `root_pid`. Returns None/None when the root has
|
|
160
|
+
already vanished -- "unknown", the same thing the caller shows for a
|
|
161
|
+
session with no pane at all, so the column reads consistently.
|
|
162
|
+
|
|
163
|
+
`index` is the (children, stats) pair from `build_index`. Pass it when
|
|
164
|
+
accounting SEVERAL trees from one snapshot of `/proc`: the index is
|
|
165
|
+
identical for every session in one dashboard build, and rebuilding it per
|
|
166
|
+
session makes the walk O(sessions x processes) -- on a busy reviewer host
|
|
167
|
+
thousands of `stat` reads every poll, stolen from the reviewer agents the
|
|
168
|
+
console exists to watch. Omitted, it is built once for this call.
|
|
169
|
+
"""
|
|
170
|
+
empty = {"pids": 0, "rss_bytes": None, "cpu_percent": None}
|
|
171
|
+
if root_pid is None:
|
|
172
|
+
return empty
|
|
173
|
+
children, stats = index if index is not None else build_index(proc_root)
|
|
174
|
+
if root_pid not in stats:
|
|
175
|
+
return empty
|
|
176
|
+
uptime = read_uptime(proc_root)
|
|
177
|
+
rss_pages = 0
|
|
178
|
+
cpu_ticks = 0
|
|
179
|
+
counted = 0
|
|
180
|
+
for pid in tree_pids(root_pid, children):
|
|
181
|
+
st = stats.get(pid)
|
|
182
|
+
if st is None:
|
|
183
|
+
continue
|
|
184
|
+
counted += 1
|
|
185
|
+
rss_pages += st["rss_pages"]
|
|
186
|
+
cpu_ticks += st["cpu_ticks"]
|
|
187
|
+
cpu_percent = None
|
|
188
|
+
if uptime is not None and _CLK_TCK:
|
|
189
|
+
wall = uptime - stats[root_pid]["starttime"] / _CLK_TCK
|
|
190
|
+
if wall > 0:
|
|
191
|
+
cpu_seconds = cpu_ticks / _CLK_TCK
|
|
192
|
+
cpu_percent = round(100.0 * cpu_seconds / wall, 1)
|
|
193
|
+
return {
|
|
194
|
+
"pids": counted,
|
|
195
|
+
"rss_bytes": rss_pages * _PAGE_SIZE,
|
|
196
|
+
"cpu_percent": cpu_percent,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def top_procs(
|
|
201
|
+
n: int = 5,
|
|
202
|
+
*,
|
|
203
|
+
proc_root: "str | os.PathLike[str]" = "/proc",
|
|
204
|
+
index: "tuple[dict[int, list[int]], dict[int, dict]] | None" = None,
|
|
205
|
+
) -> "list[dict]":
|
|
206
|
+
"""The n biggest processes on the HOST by RSS: [{pid, comm, rss_bytes}],
|
|
207
|
+
largest first.
|
|
208
|
+
|
|
209
|
+
Deliberately the whole `/proc` and not a pane tree: the memory tile's
|
|
210
|
+
question is container-wide, so when the charge really is resident the
|
|
211
|
+
operator needs to see whatever is holding it -- which is routinely not a
|
|
212
|
+
reviewer session at all (the daemon itself, a stray build, the shell that
|
|
213
|
+
started everything).
|
|
214
|
+
|
|
215
|
+
`index` is the same (children, stats) pair `tree_usage` takes, so a
|
|
216
|
+
dashboard build that already scanned `/proc` for its session table ranks
|
|
217
|
+
processes off that ONE snapshot instead of walking every PID twice.
|
|
218
|
+
Vanished PIDs never appear: `build_index` has already dropped them, and a
|
|
219
|
+
process that exits after the scan simply ranks with its last-known RSS.
|
|
220
|
+
|
|
221
|
+
Ties break on PID so the order is total -- an equal-RSS pair must not
|
|
222
|
+
reshuffle between polls and make a still list look like it is churning.
|
|
223
|
+
"""
|
|
224
|
+
if n <= 0:
|
|
225
|
+
return []
|
|
226
|
+
_, stats = index if index is not None else build_index(proc_root)
|
|
227
|
+
ranked = sorted(stats.values(), key=lambda st: (-st["rss_pages"], st["pid"]))
|
|
228
|
+
return [
|
|
229
|
+
{
|
|
230
|
+
"pid": st["pid"],
|
|
231
|
+
"comm": st.get("comm") or "?",
|
|
232
|
+
"rss_bytes": st["rss_pages"] * _PAGE_SIZE,
|
|
233
|
+
}
|
|
234
|
+
for st in ranked[:n]
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _read_int_file(path: Path) -> "int | None":
|
|
239
|
+
"""One cgroup scalar file as an int, or None when it is absent, unreadable
|
|
240
|
+
or not a number. cgroup v2 writes the literal `max` in some files, which is
|
|
241
|
+
exactly the malformed case this returns None for."""
|
|
242
|
+
try:
|
|
243
|
+
return int(path.read_text().strip())
|
|
244
|
+
except (OSError, ValueError):
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _read_stat_keys(path: Path, keys: "tuple[str, ...]") -> "dict[str, int | None]":
|
|
249
|
+
"""The requested `key value` lines of a cgroup `*.stat` file. Every key is
|
|
250
|
+
present in the result whether or not the file had it, so the payload shape
|
|
251
|
+
never depends on the kernel version -- a missing or unparseable key is
|
|
252
|
+
None, the same thing the whole-file failure produces."""
|
|
253
|
+
out: "dict[str, int | None]" = {key: None for key in keys}
|
|
254
|
+
try:
|
|
255
|
+
text = path.read_text()
|
|
256
|
+
except (OSError, ValueError):
|
|
257
|
+
return out
|
|
258
|
+
wanted = set(keys)
|
|
259
|
+
for line in text.splitlines():
|
|
260
|
+
name, _, value = line.partition(" ")
|
|
261
|
+
if name not in wanted:
|
|
262
|
+
continue
|
|
263
|
+
try:
|
|
264
|
+
out[name] = int(value.strip())
|
|
265
|
+
except ValueError:
|
|
266
|
+
out[name] = None
|
|
267
|
+
return out
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _sum_or_none(*values: "int | None") -> "int | None":
|
|
271
|
+
"""Sum only when EVERY part is known. A partial sum would be reported as a
|
|
272
|
+
whole bucket and read as a smaller cache than the container really holds --
|
|
273
|
+
an unknown number must stay unknown rather than become a wrong one."""
|
|
274
|
+
if any(value is None for value in values):
|
|
275
|
+
return None
|
|
276
|
+
return sum(value for value in values if value is not None)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def cgroup_memory(
|
|
280
|
+
cgroup_root: "str | os.PathLike[str]" = "/sys/fs/cgroup",
|
|
281
|
+
) -> dict:
|
|
282
|
+
"""The container's own memory charge, split for the operator.
|
|
283
|
+
|
|
284
|
+
Answers the question a platform memory graph raises and cannot settle: a
|
|
285
|
+
flat multi-GB plateau is `charged` (cgroup v2 `memory.current`), but most
|
|
286
|
+
of it is routinely cache the kernel would drop under pressure, not a leak.
|
|
287
|
+
So we also report:
|
|
288
|
+
|
|
289
|
+
* `resident` -- `anon`, the process memory that is really in use.
|
|
290
|
+
* `reclaimable` -- `file` + `slab_reclaimable`, the page cache plus the
|
|
291
|
+
shrinkable kernel caches: the "not really in use" bucket.
|
|
292
|
+
|
|
293
|
+
The raw keys ride along for a spot check against an in-container
|
|
294
|
+
`memory.stat`. Note the three summary numbers do NOT partition the charge
|
|
295
|
+
(kernel stacks, pagetables, sockets are charged too, and `shmem` is counted
|
|
296
|
+
inside `file` while behaving like anon memory) -- they are the three
|
|
297
|
+
magnitudes an operator compares, not a balance sheet.
|
|
298
|
+
|
|
299
|
+
Never raises. On a host without cgroup v2 -- a dev laptop, macOS, a v1
|
|
300
|
+
cgroup tree -- every field is None and the console renders "unavailable";
|
|
301
|
+
the root is a parameter so all of that is testable off a fixture dir.
|
|
302
|
+
"""
|
|
303
|
+
root = Path(cgroup_root)
|
|
304
|
+
charged = _read_int_file(root / _CGROUP_CURRENT)
|
|
305
|
+
stat = _read_stat_keys(root / _CGROUP_STAT, _CGROUP_STAT_KEYS)
|
|
306
|
+
out: "dict[str, int | None]" = {
|
|
307
|
+
"charged": charged,
|
|
308
|
+
"resident": stat["anon"],
|
|
309
|
+
"reclaimable": _sum_or_none(stat["file"], stat["slab_reclaimable"]),
|
|
310
|
+
}
|
|
311
|
+
out.update(stat)
|
|
312
|
+
return out
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def disk_usage(path: "str | os.PathLike[str]") -> "dict | None":
|
|
316
|
+
"""Workspace volume usage for the stat tile, or None if the path is
|
|
317
|
+
unreadable. Percent is used/total, rounded -- the meter the console fills."""
|
|
318
|
+
try:
|
|
319
|
+
usage = shutil.disk_usage(os.fspath(path))
|
|
320
|
+
except OSError:
|
|
321
|
+
return None
|
|
322
|
+
percent = round(100.0 * usage.used / usage.total, 1) if usage.total else None
|
|
323
|
+
return {
|
|
324
|
+
"total_bytes": usage.total,
|
|
325
|
+
"used_bytes": usage.used,
|
|
326
|
+
"free_bytes": usage.free,
|
|
327
|
+
"percent": percent,
|
|
328
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.16.13
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
"""Per-session resource accounting off `/proc`, plus workspace disk usage.
|
|
2
|
-
|
|
3
|
-
The sessions panel wants CPU% and RSS for each managed tmux session. tmux hands
|
|
4
|
-
us a pane PID; the real work runs in that pane's child tree (a shell, the agent,
|
|
5
|
-
its subprocesses), so we sum over the whole tree rooted at the pane PID.
|
|
6
|
-
|
|
7
|
-
Two deliberate properties:
|
|
8
|
-
|
|
9
|
-
* **Sample-free CPU.** Instantaneous CPU% needs two `/proc` reads spaced apart;
|
|
10
|
-
a dashboard endpoint polled every ~10s should not block sampling. Instead we
|
|
11
|
-
report the *cumulative average* since the pane started:
|
|
12
|
-
`100 * (utime+stime)/CLK_TCK / (uptime - starttime/CLK_TCK)`. It answers "how
|
|
13
|
-
hot has this session run over its life", which is the operator question, and
|
|
14
|
-
needs a single read.
|
|
15
|
-
* **Vanished-PID tolerant.** Between listing `/proc` and reading a PID's `stat`,
|
|
16
|
-
the process can exit. Every read that fails (gone, permission, malformed) is
|
|
17
|
-
skipped -- a disappearing reviewer contributes nothing and never raises.
|
|
18
|
-
|
|
19
|
-
The `/proc` root is a parameter so the parsing is testable against a crafted
|
|
20
|
-
fixture directory (including a PID that is indexed but whose `stat` was deleted
|
|
21
|
-
mid-walk -- the vanished-PID case the mutation bar pins).
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
from __future__ import annotations
|
|
25
|
-
|
|
26
|
-
import os
|
|
27
|
-
import shutil
|
|
28
|
-
from pathlib import Path
|
|
29
|
-
|
|
30
|
-
_CLK_TCK = os.sysconf("SC_CLK_TCK") if hasattr(os, "sysconf") else 100
|
|
31
|
-
_PAGE_SIZE = os.sysconf("SC_PAGE_SIZE") if hasattr(os, "sysconf") else 4096
|
|
32
|
-
|
|
33
|
-
# Field offsets INTO the tail of /proc/<pid>/stat (everything after the
|
|
34
|
-
# "(comm)" field, which itself is field 3 == index 0 of the tail). See
|
|
35
|
-
# proc(5): utime=14, stime=15, starttime=22, rss=24 -> tail indices below.
|
|
36
|
-
_TAIL_PPID = 1
|
|
37
|
-
_TAIL_UTIME = 11
|
|
38
|
-
_TAIL_STIME = 12
|
|
39
|
-
_TAIL_STARTTIME = 19
|
|
40
|
-
_TAIL_RSS = 21
|
|
41
|
-
_TAIL_MIN_LEN = 22
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def read_stat(proc_root: "str | os.PathLike[str]", pid: int) -> "dict | None":
|
|
45
|
-
"""Parse one `/proc/<pid>/stat`, or None if the PID has vanished or the
|
|
46
|
-
line is malformed. The `comm` field can contain spaces and parentheses, so
|
|
47
|
-
we split on the LAST ')' before tokenising -- the canonical safe parse."""
|
|
48
|
-
try:
|
|
49
|
-
data = (Path(proc_root) / str(pid) / "stat").read_text()
|
|
50
|
-
except (OSError, ValueError):
|
|
51
|
-
return None
|
|
52
|
-
rparen = data.rfind(")")
|
|
53
|
-
if rparen == -1:
|
|
54
|
-
return None
|
|
55
|
-
tail = data[rparen + 1:].split()
|
|
56
|
-
if len(tail) < _TAIL_MIN_LEN:
|
|
57
|
-
return None
|
|
58
|
-
try:
|
|
59
|
-
return {
|
|
60
|
-
"pid": int(pid),
|
|
61
|
-
"ppid": int(tail[_TAIL_PPID]),
|
|
62
|
-
"cpu_ticks": int(tail[_TAIL_UTIME]) + int(tail[_TAIL_STIME]),
|
|
63
|
-
"starttime": int(tail[_TAIL_STARTTIME]),
|
|
64
|
-
"rss_pages": int(tail[_TAIL_RSS]),
|
|
65
|
-
}
|
|
66
|
-
except (ValueError, IndexError):
|
|
67
|
-
return None
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def read_uptime(proc_root: "str | os.PathLike[str]") -> "float | None":
|
|
71
|
-
try:
|
|
72
|
-
return float((Path(proc_root) / "uptime").read_text().split()[0])
|
|
73
|
-
except (OSError, ValueError, IndexError):
|
|
74
|
-
return None
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def build_index(
|
|
78
|
-
proc_root: "str | os.PathLike[str]",
|
|
79
|
-
) -> "tuple[dict[int, list[int]], dict[int, dict]]":
|
|
80
|
-
"""Scan `/proc` once: return (ppid -> [child pids], pid -> stat). Numeric
|
|
81
|
-
entries only; anything that vanishes mid-scan is silently skipped."""
|
|
82
|
-
children: dict[int, list[int]] = {}
|
|
83
|
-
stats: dict[int, dict] = {}
|
|
84
|
-
try:
|
|
85
|
-
entries = os.listdir(proc_root)
|
|
86
|
-
except OSError:
|
|
87
|
-
return children, stats
|
|
88
|
-
for name in entries:
|
|
89
|
-
if not name.isdigit():
|
|
90
|
-
continue
|
|
91
|
-
st = read_stat(proc_root, int(name))
|
|
92
|
-
if st is None:
|
|
93
|
-
continue
|
|
94
|
-
stats[st["pid"]] = st
|
|
95
|
-
children.setdefault(st["ppid"], []).append(st["pid"])
|
|
96
|
-
return children, stats
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def tree_pids(root_pid: int, children: "dict[int, list[int]]") -> "set[int]":
|
|
100
|
-
"""Every PID in the subtree rooted at `root_pid` (inclusive). Iterative and
|
|
101
|
-
cycle-guarded -- a corrupt ppid loop can never spin forever."""
|
|
102
|
-
seen: set[int] = set()
|
|
103
|
-
stack = [root_pid]
|
|
104
|
-
while stack:
|
|
105
|
-
pid = stack.pop()
|
|
106
|
-
if pid in seen:
|
|
107
|
-
continue
|
|
108
|
-
seen.add(pid)
|
|
109
|
-
stack.extend(children.get(pid, ()))
|
|
110
|
-
return seen
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def tree_usage(
|
|
114
|
-
root_pid: "int | None",
|
|
115
|
-
*,
|
|
116
|
-
proc_root: "str | os.PathLike[str]" = "/proc",
|
|
117
|
-
index: "tuple[dict[int, list[int]], dict[int, dict]] | None" = None,
|
|
118
|
-
) -> dict:
|
|
119
|
-
"""CPU% (cumulative average since the pane started) and summed RSS for the
|
|
120
|
-
whole process tree under `root_pid`. Returns None/None when the root has
|
|
121
|
-
already vanished -- "unknown", the same thing the caller shows for a
|
|
122
|
-
session with no pane at all, so the column reads consistently.
|
|
123
|
-
|
|
124
|
-
`index` is the (children, stats) pair from `build_index`. Pass it when
|
|
125
|
-
accounting SEVERAL trees from one snapshot of `/proc`: the index is
|
|
126
|
-
identical for every session in one dashboard build, and rebuilding it per
|
|
127
|
-
session makes the walk O(sessions x processes) -- on a busy reviewer host
|
|
128
|
-
thousands of `stat` reads every poll, stolen from the reviewer agents the
|
|
129
|
-
console exists to watch. Omitted, it is built once for this call.
|
|
130
|
-
"""
|
|
131
|
-
empty = {"pids": 0, "rss_bytes": None, "cpu_percent": None}
|
|
132
|
-
if root_pid is None:
|
|
133
|
-
return empty
|
|
134
|
-
children, stats = index if index is not None else build_index(proc_root)
|
|
135
|
-
if root_pid not in stats:
|
|
136
|
-
return empty
|
|
137
|
-
uptime = read_uptime(proc_root)
|
|
138
|
-
rss_pages = 0
|
|
139
|
-
cpu_ticks = 0
|
|
140
|
-
counted = 0
|
|
141
|
-
for pid in tree_pids(root_pid, children):
|
|
142
|
-
st = stats.get(pid)
|
|
143
|
-
if st is None:
|
|
144
|
-
continue
|
|
145
|
-
counted += 1
|
|
146
|
-
rss_pages += st["rss_pages"]
|
|
147
|
-
cpu_ticks += st["cpu_ticks"]
|
|
148
|
-
cpu_percent = None
|
|
149
|
-
if uptime is not None and _CLK_TCK:
|
|
150
|
-
wall = uptime - stats[root_pid]["starttime"] / _CLK_TCK
|
|
151
|
-
if wall > 0:
|
|
152
|
-
cpu_seconds = cpu_ticks / _CLK_TCK
|
|
153
|
-
cpu_percent = round(100.0 * cpu_seconds / wall, 1)
|
|
154
|
-
return {
|
|
155
|
-
"pids": counted,
|
|
156
|
-
"rss_bytes": rss_pages * _PAGE_SIZE,
|
|
157
|
-
"cpu_percent": cpu_percent,
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def disk_usage(path: "str | os.PathLike[str]") -> "dict | None":
|
|
162
|
-
"""Workspace volume usage for the stat tile, or None if the path is
|
|
163
|
-
unreadable. Percent is used/total, rounded -- the meter the console fills."""
|
|
164
|
-
try:
|
|
165
|
-
usage = shutil.disk_usage(os.fspath(path))
|
|
166
|
-
except OSError:
|
|
167
|
-
return None
|
|
168
|
-
percent = round(100.0 * usage.used / usage.total, 1) if usage.total else None
|
|
169
|
-
return {
|
|
170
|
-
"total_bytes": usage.total,
|
|
171
|
-
"used_bytes": usage.used,
|
|
172
|
-
"free_bytes": usage.free,
|
|
173
|
-
"percent": percent,
|
|
174
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{alissa_tools_github_revloop-0.16.13 → alissa_tools_github_revloop-0.16.15}/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
|
|
File without changes
|