@andespindola/crow-skills 0.8.0 → 0.9.0
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.
- package/README.md +2 -2
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/cli.py +10 -0
- package/crow_skills/events.py +25 -3
- package/crow_skills/hook.py +57 -1
- package/crow_skills/integrations.py +35 -2
- package/crow_skills/progress.py +55 -5
- package/crow_skills/resources.py +713 -0
- package/docs/cli.md +51 -3
- package/package.json +1 -1
- package/release.json +40 -30
- package/skills/crow-accessibility/SKILL.md +2 -0
- package/skills/crow-api-test/SKILL.md +2 -0
- package/skills/crow-backend/SKILL.md +2 -0
- package/skills/crow-behaviour/SKILL.md +1 -0
- package/skills/crow-behaviour/references/orchestration.md +4 -0
- package/skills/crow-behaviour/references/progress-ledger.md +3 -0
- package/skills/crow-behaviour/references/resources.md +44 -0
- package/skills/crow-behaviour/scripts/crow_ledger.py +75 -2
- package/skills/crow-browser/SKILL.md +2 -0
- package/skills/crow-debug/SKILL.md +2 -0
- package/skills/crow-frontend/SKILL.md +2 -0
- package/skills/crow-infra/SKILL.md +2 -0
- package/skills/crow-interface/SKILL.md +2 -0
- package/skills/crow-media/SKILL.md +2 -0
- package/skills/crow-native-mobile/SKILL.md +2 -0
- package/skills/crow-performance/SKILL.md +2 -0
- package/skills/crow-qa/SKILL.md +2 -0
- package/skills/crow-release/SKILL.md +2 -0
- package/skills/crow-reverse/SKILL.md +2 -0
- package/skills/crow-security-scan/SKILL.md +2 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
"""Machine resource probe and parallelism recommendation (crow-resources/1).
|
|
2
|
+
|
|
3
|
+
Self-contained on purpose (standard library only, no package-relative
|
|
4
|
+
imports): the integrations copy this file next to the hook as
|
|
5
|
+
``crow_resources.py``, so the per-prompt hook and the status line can read
|
|
6
|
+
memory pressure without the CLI package. The CLI uses the same functions for
|
|
7
|
+
``crow-skills resources`` and the ``resources`` field of ``events snapshot``.
|
|
8
|
+
|
|
9
|
+
What is read, and nothing else:
|
|
10
|
+
- macOS: one ``sysctl`` call (hw.memsize, hw.ncpu, vm.swapusage,
|
|
11
|
+
kern.memorystatus_vm_pressure_level, kern.memorystatus_level); ``vm_stat``
|
|
12
|
+
only when the kernel does not report the available level.
|
|
13
|
+
- Linux: /proc/meminfo (MemAvailable, swap), /proc/pressure/memory (PSI) when
|
|
14
|
+
present, and the cgroup v2 memory limit when the process runs under one.
|
|
15
|
+
- Agent processes (full probe only): pid, parent pid, RSS and the executable
|
|
16
|
+
name, from ``ps -axo pid=,ppid=,rss=,comm=`` (macOS) or /proc/<pid>/stat
|
|
17
|
+
(Linux). Names are matched against a fixed list and only the agent kind,
|
|
18
|
+
counts and RSS are reported: no command line, argument or process name is
|
|
19
|
+
ever printed or stored.
|
|
20
|
+
|
|
21
|
+
Tests (and only tests) pin the reading with CROW_RESOURCES_FIXTURE, the path of a
|
|
22
|
+
JSON raw reading (optionally with an "agents" object); the cache is then bypassed.
|
|
23
|
+
|
|
24
|
+
The probe never changes a host limit. Thresholds come from DEFAULTS, merged
|
|
25
|
+
with ~/.config/crow-skills/resources.json when it exists.
|
|
26
|
+
"""
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
import json
|
|
29
|
+
import math
|
|
30
|
+
import os
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
import re
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import time
|
|
36
|
+
import uuid
|
|
37
|
+
|
|
38
|
+
SCHEMA = "crow-resources/1"
|
|
39
|
+
CACHE_SCHEMA = "crow-resources-cache/1"
|
|
40
|
+
LEVELS = ("normal", "warn", "critical")
|
|
41
|
+
MODES = ("parallel", "reduced", "sequential")
|
|
42
|
+
# Executable name (basename, lower case) -> agent kind. Node-based CLIs that keep the
|
|
43
|
+
# process name "node" are not detected: their command line is never read.
|
|
44
|
+
AGENT_NAMES = {"claude": "claude", "codex": "codex", "gemini": "gemini", "agy": "agy", "antigravity": "agy", "devin": "devin"}
|
|
45
|
+
|
|
46
|
+
# Defaults derived from measurements on a 18 GB / 12-core macOS machine (docs/cli.md,
|
|
47
|
+
# "crow-skills resources"): a Claude Code session with its MCP servers and shells used
|
|
48
|
+
# ~0.95 GB RSS as a process tree; a standalone Codex CLI 60-70 MB; the Codex desktop
|
|
49
|
+
# app-server tree ~2.1 GB. perAgentMb takes the heaviest common CLI session, since a
|
|
50
|
+
# fan-out cannot know which kind of worker the host starts. perHeavyJobMb and
|
|
51
|
+
# perWorkerMb are conservative estimates for a build/test suite/dev server and for one
|
|
52
|
+
# test or compiler worker; they were not measured on this machine.
|
|
53
|
+
DEFAULTS = {
|
|
54
|
+
"reservedMb": 2048, # kept free for the OS, the editor and the browser...
|
|
55
|
+
"reservedPercent": 15, # ...but never more than this share of the total (small machines)
|
|
56
|
+
"perAgentMb": 1024, # one more agent/subagent session
|
|
57
|
+
"perHeavyJobMb": 1536, # one build, test suite, dev server, emulator or container
|
|
58
|
+
"perWorkerMb": 512, # one worker inside a heavy job (-j, --maxWorkers, pytest -n)
|
|
59
|
+
"maxParallelAgents": 6, # hard cap, whatever the memory
|
|
60
|
+
"maxParallelHeavyJobs": 4, # hard cap; also limited to cpus // 4
|
|
61
|
+
"warnAvailablePercent": 15, # available/total below this -> warn
|
|
62
|
+
"criticalAvailablePercent": 7, # available/total below this -> critical
|
|
63
|
+
"psiWarnSomeAvg10": 10.0, # Linux PSI "some avg10" (%) at or above this -> warn
|
|
64
|
+
"psiCriticalFullAvg10": 10.0, # Linux PSI "full avg10" (%) at or above this -> critical
|
|
65
|
+
"loadPerCpuReduce": 1.5, # 1-min load / cpus above this halves heavy jobs and workers
|
|
66
|
+
"cacheSeconds": 5, # hooks and status lines reuse a probe this recent
|
|
67
|
+
}
|
|
68
|
+
LIMITS = { # key -> (minimum, maximum)
|
|
69
|
+
"reservedMb": (0, 1 << 22), "reservedPercent": (0, 90), "perAgentMb": (1, 1 << 22), "perHeavyJobMb": (1, 1 << 22), "perWorkerMb": (1, 1 << 22),
|
|
70
|
+
"maxParallelAgents": (1, 256), "maxParallelHeavyJobs": (1, 256),
|
|
71
|
+
"warnAvailablePercent": (0, 100), "criticalAvailablePercent": (0, 100),
|
|
72
|
+
"psiWarnSomeAvg10": (0, 100), "psiCriticalFullAvg10": (0, 100),
|
|
73
|
+
"loadPerCpuReduce": (0.1, 1000), "cacheSeconds": (0, 300),
|
|
74
|
+
}
|
|
75
|
+
INTEGER_KEYS = ("reservedMb", "perAgentMb", "perHeavyJobMb", "perWorkerMb", "maxParallelAgents", "maxParallelHeavyJobs")
|
|
76
|
+
MAX_CONFIG = 64 * 1024
|
|
77
|
+
MB = 1024 * 1024
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def config_path(home):
|
|
81
|
+
return Path(home) / ".config" / "crow-skills" / "resources.json"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cache_path(home):
|
|
85
|
+
return Path(home) / ".local" / "state" / "crow-skills" / "resources-cache.json"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def now_iso():
|
|
89
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --- configuration -----------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def load_config(home):
|
|
95
|
+
"""Effective thresholds and the problems found in the user's file (never raises)."""
|
|
96
|
+
path = config_path(home)
|
|
97
|
+
effective, errors, loaded = dict(DEFAULTS), [], False
|
|
98
|
+
try:
|
|
99
|
+
if path.is_symlink() or (path.exists() and not path.is_file()):
|
|
100
|
+
errors.append("resources.json não é um arquivo regular; padrões usados.")
|
|
101
|
+
elif path.is_file():
|
|
102
|
+
if path.stat().st_size > MAX_CONFIG:
|
|
103
|
+
errors.append("resources.json maior que 64 KiB; padrões usados.")
|
|
104
|
+
else:
|
|
105
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
106
|
+
if not isinstance(data, dict):
|
|
107
|
+
errors.append("resources.json não é um objeto JSON; padrões usados.")
|
|
108
|
+
else:
|
|
109
|
+
loaded = True
|
|
110
|
+
for key, value in data.items():
|
|
111
|
+
if key in ("$schema", "schema", "comment"):
|
|
112
|
+
continue
|
|
113
|
+
if key not in DEFAULTS:
|
|
114
|
+
errors.append("chave desconhecida ignorada: " + str(key)[:40])
|
|
115
|
+
continue
|
|
116
|
+
low, high = LIMITS[key]
|
|
117
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not low <= value <= high:
|
|
118
|
+
errors.append(key + " inválido (esperado número entre " + str(low) + " e " + str(high) + "); padrão mantido.")
|
|
119
|
+
continue
|
|
120
|
+
effective[key] = int(value) if key in INTEGER_KEYS else float(value)
|
|
121
|
+
except (OSError, ValueError, UnicodeDecodeError):
|
|
122
|
+
errors.append("resources.json ilegível ou JSON inválido; padrões usados.")
|
|
123
|
+
if effective["criticalAvailablePercent"] > effective["warnAvailablePercent"]:
|
|
124
|
+
errors.append("criticalAvailablePercent maior que warnAvailablePercent; os dois padrões foram restaurados.")
|
|
125
|
+
effective["criticalAvailablePercent"] = DEFAULTS["criticalAvailablePercent"]
|
|
126
|
+
effective["warnAvailablePercent"] = DEFAULTS["warnAvailablePercent"]
|
|
127
|
+
return effective, {"path": str(path), "loaded": loaded, "errors": errors}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- parsing (pure, covered by fixtures) ------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def _size_mb(text):
|
|
133
|
+
"""'4464.19M' / '6.00G' / '512K' -> MB (float)."""
|
|
134
|
+
match = re.fullmatch(r"\s*([0-9.]+)\s*([KMGT]?)B?\s*", text or "", re.IGNORECASE)
|
|
135
|
+
if not match:
|
|
136
|
+
return None
|
|
137
|
+
value = float(match.group(1))
|
|
138
|
+
return value * {"": 1 / MB, "K": 1 / 1024, "M": 1, "G": 1024, "T": 1024 * 1024}[match.group(2).upper()]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse_sysctl(text):
|
|
142
|
+
"""``sysctl name ...`` output (``name: value`` lines) -> dict of raw strings."""
|
|
143
|
+
values = {}
|
|
144
|
+
for line in (text or "").splitlines():
|
|
145
|
+
name, sep, value = line.partition(":")
|
|
146
|
+
if sep and re.fullmatch(r"[a-z0-9_.]+", name.strip()):
|
|
147
|
+
values[name.strip()] = value.strip()
|
|
148
|
+
return values
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_swapusage(text):
|
|
152
|
+
"""'total = 6144.00M used = 4464.19M free = 1679.81M (encrypted)' -> (total, used) MB."""
|
|
153
|
+
found = dict(re.findall(r"(total|used|free)\s*=\s*([0-9.]+[KMGT]?)", text or ""))
|
|
154
|
+
total, used = _size_mb(found.get("total")), _size_mb(found.get("used"))
|
|
155
|
+
return total, used
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_vm_stat(text):
|
|
159
|
+
"""vm_stat output -> available MB (free + inactive + speculative + purgeable), or None."""
|
|
160
|
+
page = re.search(r"page size of (\d+) bytes", text or "")
|
|
161
|
+
if not page:
|
|
162
|
+
return None
|
|
163
|
+
counts = {}
|
|
164
|
+
for name, value in re.findall(r"^Pages ([a-z ]+):\s+(\d+)\.", text, re.MULTILINE):
|
|
165
|
+
counts[name.strip()] = int(value)
|
|
166
|
+
if "free" not in counts:
|
|
167
|
+
return None
|
|
168
|
+
pages = sum(counts.get(k, 0) for k in ("free", "inactive", "speculative", "purgeable"))
|
|
169
|
+
return pages * int(page.group(1)) / MB
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def macos_reading(sysctl_text, vm_stat_text=None):
|
|
173
|
+
"""Raw macOS reading from sysctl (and vm_stat when the kernel level is missing)."""
|
|
174
|
+
values = parse_sysctl(sysctl_text)
|
|
175
|
+
try:
|
|
176
|
+
total = int(values["hw.memsize"]) / MB
|
|
177
|
+
except (KeyError, ValueError):
|
|
178
|
+
return None
|
|
179
|
+
reading = {"platform": "darwin", "source": "sysctl", "totalMb": total, "availableMb": None, "swapTotalMb": None,
|
|
180
|
+
"swapUsedMb": None, "osLevel": None, "psi": None, "cgroupLimitMb": None}
|
|
181
|
+
try:
|
|
182
|
+
level = int(values["kern.memorystatus_level"]) # % of memory the kernel counts as available
|
|
183
|
+
if 0 <= level <= 100:
|
|
184
|
+
reading["availableMb"] = total * level / 100
|
|
185
|
+
except (KeyError, ValueError):
|
|
186
|
+
pass
|
|
187
|
+
if reading["availableMb"] is None and vm_stat_text:
|
|
188
|
+
reading["availableMb"] = parse_vm_stat(vm_stat_text)
|
|
189
|
+
reading["source"] = "sysctl+vm_stat"
|
|
190
|
+
reading["swapTotalMb"], reading["swapUsedMb"] = parse_swapusage(values.get("vm.swapusage", ""))
|
|
191
|
+
# kern.memorystatus_vm_pressure_level: 1 normal, 2 warning, 4 critical (the level memory_pressure shows).
|
|
192
|
+
reading["osLevel"] = {"1": "normal", "2": "warn", "4": "critical"}.get(values.get("kern.memorystatus_vm_pressure_level"))
|
|
193
|
+
try:
|
|
194
|
+
reading["cpus"] = int(values["hw.ncpu"])
|
|
195
|
+
except (KeyError, ValueError):
|
|
196
|
+
pass
|
|
197
|
+
return reading
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse_meminfo(text):
|
|
201
|
+
values = {}
|
|
202
|
+
for line in (text or "").splitlines():
|
|
203
|
+
name, sep, rest = line.partition(":")
|
|
204
|
+
parts = rest.split()
|
|
205
|
+
if sep and parts and parts[0].isdigit():
|
|
206
|
+
values[name.strip()] = int(parts[0]) / 1024 # kB -> MB
|
|
207
|
+
return values
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def parse_psi(text):
|
|
211
|
+
"""/proc/pressure/memory -> {'someAvg10': float, 'fullAvg10': float} (missing lines omitted)."""
|
|
212
|
+
result = {}
|
|
213
|
+
for line in (text or "").splitlines():
|
|
214
|
+
kind, _, rest = line.partition(" ")
|
|
215
|
+
match = re.search(r"avg10=([0-9.]+)", rest)
|
|
216
|
+
if kind in ("some", "full") and match:
|
|
217
|
+
result[kind + "Avg10"] = float(match.group(1))
|
|
218
|
+
return result or None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _cgroup_int(text):
|
|
222
|
+
value = (text or "").strip()
|
|
223
|
+
if not value or value == "max":
|
|
224
|
+
return None
|
|
225
|
+
try:
|
|
226
|
+
return int(value)
|
|
227
|
+
except ValueError:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def parse_cpu_max(text):
|
|
232
|
+
"""cgroup v2 cpu.max ('200000 100000' or 'max 100000') -> CPUs (ceil), or None."""
|
|
233
|
+
parts = (text or "").split()
|
|
234
|
+
if len(parts) != 2 or parts[0] == "max":
|
|
235
|
+
return None
|
|
236
|
+
try:
|
|
237
|
+
quota, period = int(parts[0]), int(parts[1])
|
|
238
|
+
except ValueError:
|
|
239
|
+
return None
|
|
240
|
+
return max(1, -(-quota // period)) if quota > 0 and period > 0 else None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def linux_reading(meminfo_text, psi_text=None, cgroup=None):
|
|
244
|
+
"""cgroup: optional dict with the raw cgroup v2 files of the tightest limit:
|
|
245
|
+
max, current, stat (memory.stat), pressure (memory.pressure) and cpuMax (cpu.max)."""
|
|
246
|
+
info = parse_meminfo(meminfo_text)
|
|
247
|
+
if "MemTotal" not in info:
|
|
248
|
+
return None
|
|
249
|
+
total = info["MemTotal"]
|
|
250
|
+
available = info.get("MemAvailable")
|
|
251
|
+
if available is None: # kernels before 3.14
|
|
252
|
+
available = info.get("MemFree", 0) + info.get("Buffers", 0) + info.get("Cached", 0)
|
|
253
|
+
reading = {"platform": "linux", "source": "proc", "totalMb": total, "availableMb": available,
|
|
254
|
+
"swapTotalMb": info.get("SwapTotal"), "swapUsedMb": None, "osLevel": None,
|
|
255
|
+
"psi": parse_psi(psi_text) if psi_text else None, "cgroupLimitMb": None}
|
|
256
|
+
if info.get("SwapTotal") is not None and info.get("SwapFree") is not None:
|
|
257
|
+
reading["swapUsedMb"] = max(0.0, info["SwapTotal"] - info["SwapFree"])
|
|
258
|
+
cgroup = cgroup or {}
|
|
259
|
+
limit = _cgroup_int(cgroup.get("max"))
|
|
260
|
+
if limit and limit / MB < total:
|
|
261
|
+
# Inside a container: the cgroup limit is the machine this process really has. Usage is the
|
|
262
|
+
# working set (memory.current minus inactive_file, as the kubelet counts it): page cache the
|
|
263
|
+
# kernel can reclaim is not memory in use.
|
|
264
|
+
used = _cgroup_int(cgroup.get("current")) or 0
|
|
265
|
+
stat = dict(re.findall(r"^(\w+) (\d+)$", cgroup.get("stat") or "", re.MULTILINE))
|
|
266
|
+
used = max(0, used - int(stat.get("inactive_file", 0)))
|
|
267
|
+
reading["cgroupLimitMb"] = limit / MB
|
|
268
|
+
reading["totalMb"] = limit / MB
|
|
269
|
+
reading["availableMb"] = max(0.0, min(available, (limit - used) / MB))
|
|
270
|
+
reading["source"] = "proc+cgroup"
|
|
271
|
+
if cgroup.get("pressure"):
|
|
272
|
+
reading["psi"] = parse_psi(cgroup["pressure"]) or reading["psi"]
|
|
273
|
+
cpus = parse_cpu_max(cgroup.get("cpuMax"))
|
|
274
|
+
if cpus:
|
|
275
|
+
reading["cpuQuota"] = cpus
|
|
276
|
+
return reading
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def parse_ps(text):
|
|
280
|
+
"""``ps -axo pid=,ppid=,rss=,comm=`` -> [(pid, ppid, rss_kb, basename)]."""
|
|
281
|
+
rows = []
|
|
282
|
+
for line in (text or "").splitlines():
|
|
283
|
+
parts = line.split(None, 3)
|
|
284
|
+
if len(parts) < 4 or not (parts[0].isdigit() and parts[1].isdigit() and parts[2].isdigit()):
|
|
285
|
+
continue
|
|
286
|
+
rows.append((int(parts[0]), int(parts[1]), int(parts[2]), os.path.basename(parts[3].strip())))
|
|
287
|
+
return rows
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def agent_trees(rows):
|
|
291
|
+
"""Agent CLI process trees: roots are agent processes with no agent ancestor; each tree's
|
|
292
|
+
RSS includes every descendant (node workers, MCP servers, shells). Only kinds, counts and
|
|
293
|
+
MB are returned."""
|
|
294
|
+
parent = {pid: ppid for pid, ppid, _, _ in rows}
|
|
295
|
+
rss = {pid: kb for pid, _, kb, _ in rows}
|
|
296
|
+
kind = {pid: AGENT_NAMES.get(name.lower()) for pid, _, _, name in rows}
|
|
297
|
+
children = {}
|
|
298
|
+
for pid, ppid, _, _ in rows:
|
|
299
|
+
children.setdefault(ppid, []).append(pid)
|
|
300
|
+
|
|
301
|
+
def has_agent_ancestor(pid):
|
|
302
|
+
seen, current = set(), parent.get(pid)
|
|
303
|
+
while current and current not in seen:
|
|
304
|
+
if kind.get(current):
|
|
305
|
+
return True
|
|
306
|
+
seen.add(current)
|
|
307
|
+
current = parent.get(current)
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
by_kind, total = {}, 0
|
|
311
|
+
for pid, _, _, _ in rows:
|
|
312
|
+
if not kind.get(pid) or has_agent_ancestor(pid):
|
|
313
|
+
continue
|
|
314
|
+
stack, seen, tree_kb = [pid], set(), 0
|
|
315
|
+
while stack:
|
|
316
|
+
current = stack.pop()
|
|
317
|
+
if current in seen:
|
|
318
|
+
continue
|
|
319
|
+
seen.add(current)
|
|
320
|
+
tree_kb += rss.get(current, 0)
|
|
321
|
+
stack.extend(children.get(current, []))
|
|
322
|
+
entry = by_kind.setdefault(kind[pid], {"count": 0, "rssMb": 0})
|
|
323
|
+
entry["count"] += 1
|
|
324
|
+
entry["rssMb"] += round(tree_kb / 1024)
|
|
325
|
+
total += tree_kb
|
|
326
|
+
return {"count": sum(e["count"] for e in by_kind.values()), "rssMb": round(total / 1024), "byKind": by_kind}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# --- readers (side effects: one subprocess or a few /proc reads) ----------------------------
|
|
330
|
+
|
|
331
|
+
def _run(argv, timeout=2.0):
|
|
332
|
+
try:
|
|
333
|
+
done = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LC_ALL": "C"})
|
|
334
|
+
return done.stdout
|
|
335
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
336
|
+
return ""
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _read(path, limit=256 * 1024):
|
|
340
|
+
try:
|
|
341
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
342
|
+
return handle.read(limit)
|
|
343
|
+
except OSError:
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def cgroup_files():
|
|
348
|
+
"""cgroup v2 files of this process: the tightest memory.max walking up to the root (with
|
|
349
|
+
that level's memory.current and memory.stat), the leaf memory.pressure and the tightest
|
|
350
|
+
cpu.max. Empty dict outside cgroup v2."""
|
|
351
|
+
text = _read("/proc/self/cgroup") or ""
|
|
352
|
+
leaf = next((line[3:].strip() for line in text.splitlines() if line.startswith("0::")), None)
|
|
353
|
+
if leaf is None:
|
|
354
|
+
return {}
|
|
355
|
+
root = Path("/sys/fs/cgroup")
|
|
356
|
+
path = Path(os.path.normpath(str(root / leaf.lstrip("/"))))
|
|
357
|
+
if path != root and root not in path.parents:
|
|
358
|
+
return {}
|
|
359
|
+
result, best, best_cpu = {}, None, None
|
|
360
|
+
for directory in (path, *[p for p in path.parents if p == root or root in p.parents]):
|
|
361
|
+
limit = _cgroup_int(_read(directory / "memory.max", 64))
|
|
362
|
+
if limit and (best is None or limit < best):
|
|
363
|
+
best = limit
|
|
364
|
+
result.update(max=str(limit), current=_read(directory / "memory.current", 64),
|
|
365
|
+
stat=_read(directory / "memory.stat", 16 * 1024),
|
|
366
|
+
pressure=_read(directory / "memory.pressure", 512))
|
|
367
|
+
cpu_text = _read(directory / "cpu.max", 64)
|
|
368
|
+
cpus = parse_cpu_max(cpu_text)
|
|
369
|
+
if cpus and (best_cpu is None or cpus < best_cpu):
|
|
370
|
+
best_cpu, result["cpuMax"] = cpus, cpu_text
|
|
371
|
+
if directory == root:
|
|
372
|
+
break
|
|
373
|
+
return result
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def read_machine(platform=None):
|
|
377
|
+
"""One raw reading of the machine (no processes)."""
|
|
378
|
+
platform = platform or sys.platform
|
|
379
|
+
reading = None
|
|
380
|
+
if platform == "darwin":
|
|
381
|
+
text = _run(["sysctl", "hw.memsize", "hw.ncpu", "vm.swapusage", "kern.memorystatus_vm_pressure_level", "kern.memorystatus_level"])
|
|
382
|
+
values = parse_sysctl(text)
|
|
383
|
+
reading = macos_reading(text, _run(["vm_stat"]) if "kern.memorystatus_level" not in values else None)
|
|
384
|
+
elif platform.startswith("linux"):
|
|
385
|
+
reading = linux_reading(_read("/proc/meminfo"), _read("/proc/pressure/memory"), cgroup_files())
|
|
386
|
+
if reading is None:
|
|
387
|
+
reading = {"platform": platform if platform in ("darwin",) or platform.startswith("linux") else "other",
|
|
388
|
+
"source": "unavailable", "totalMb": None, "availableMb": None, "swapTotalMb": None, "swapUsedMb": None,
|
|
389
|
+
"osLevel": None, "psi": None, "cgroupLimitMb": None}
|
|
390
|
+
if not reading.get("cpus"):
|
|
391
|
+
try:
|
|
392
|
+
reading["cpus"] = len(os.sched_getaffinity(0)) # type: ignore[attr-defined]
|
|
393
|
+
except (AttributeError, OSError):
|
|
394
|
+
reading["cpus"] = os.cpu_count() or 1
|
|
395
|
+
if reading.get("cpuQuota"):
|
|
396
|
+
reading["cpus"] = min(reading["cpus"], reading.pop("cpuQuota"))
|
|
397
|
+
try:
|
|
398
|
+
reading["load"] = [round(x, 2) for x in os.getloadavg()]
|
|
399
|
+
except (AttributeError, OSError):
|
|
400
|
+
reading["load"] = None
|
|
401
|
+
return reading
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def read_processes(platform=None):
|
|
405
|
+
platform = platform or sys.platform
|
|
406
|
+
if platform.startswith("linux"):
|
|
407
|
+
rows, page_kb = [], (os.sysconf("SC_PAGE_SIZE") // 1024 if hasattr(os, "sysconf") else 4)
|
|
408
|
+
try:
|
|
409
|
+
pids = [p for p in os.listdir("/proc") if p.isdigit()]
|
|
410
|
+
except OSError:
|
|
411
|
+
return None
|
|
412
|
+
for pid in pids:
|
|
413
|
+
stat = _read("/proc/" + pid + "/stat", 4096)
|
|
414
|
+
statm = _read("/proc/" + pid + "/statm", 512)
|
|
415
|
+
if not stat or not statm or ")" not in stat:
|
|
416
|
+
continue
|
|
417
|
+
name = stat[stat.find("(") + 1:stat.rfind(")")]
|
|
418
|
+
fields = stat[stat.rfind(")") + 2:].split()
|
|
419
|
+
try:
|
|
420
|
+
rows.append((int(pid), int(fields[1]), int(statm.split()[1]) * page_kb, name))
|
|
421
|
+
except (IndexError, ValueError):
|
|
422
|
+
continue
|
|
423
|
+
return agent_trees(rows)
|
|
424
|
+
if platform == "darwin" or "bsd" in platform:
|
|
425
|
+
text = _run(["ps", "-axo", "pid=,ppid=,rss=,comm="], timeout=3.0)
|
|
426
|
+
return agent_trees(parse_ps(text)) if text else None
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# --- assessment ------------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
def worst(*levels):
|
|
433
|
+
order = {"normal": 0, "warn": 1, "critical": 2}
|
|
434
|
+
known = [l for l in levels if l in order]
|
|
435
|
+
return max(known, key=order.get) if known else None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def assess(reading, cfg):
|
|
439
|
+
"""Pressure level for a raw reading: the worst of the OS level, PSI and available percent."""
|
|
440
|
+
total, available = reading.get("totalMb"), reading.get("availableMb")
|
|
441
|
+
reasons = []
|
|
442
|
+
if not total or available is None:
|
|
443
|
+
return {"level": "unknown", "os": reading.get("osLevel"), "psi": reading.get("psi"),
|
|
444
|
+
"reasons": ["memória não medida nesta plataforma; recomendação conservadora"]}
|
|
445
|
+
percent = 100.0 * available / total
|
|
446
|
+
by_percent = "critical" if percent < cfg["criticalAvailablePercent"] else "warn" if percent < cfg["warnAvailablePercent"] else "normal"
|
|
447
|
+
if by_percent != "normal":
|
|
448
|
+
reasons.append("disponível " + str(round(percent)) + "% < " + str(round(cfg["warnAvailablePercent"] if by_percent == "warn"
|
|
449
|
+
else cfg["criticalAvailablePercent"])) + "%")
|
|
450
|
+
psi_level = None
|
|
451
|
+
psi = reading.get("psi")
|
|
452
|
+
if psi:
|
|
453
|
+
if psi.get("fullAvg10", 0) >= cfg["psiCriticalFullAvg10"]:
|
|
454
|
+
psi_level = "critical"
|
|
455
|
+
reasons.append("PSI full avg10 " + str(psi["fullAvg10"]))
|
|
456
|
+
elif psi.get("someAvg10", 0) >= cfg["psiWarnSomeAvg10"]:
|
|
457
|
+
psi_level = "warn"
|
|
458
|
+
reasons.append("PSI some avg10 " + str(psi["someAvg10"]))
|
|
459
|
+
else:
|
|
460
|
+
psi_level = "normal"
|
|
461
|
+
if reading.get("osLevel") in ("warn", "critical"):
|
|
462
|
+
reasons.append("pressão do sistema " + reading["osLevel"])
|
|
463
|
+
return {"level": worst(by_percent, psi_level, reading.get("osLevel")), "os": reading.get("osLevel"), "psi": psi,
|
|
464
|
+
"availablePercent": round(percent, 1), "reasons": reasons}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def recommend(reading, pressure, cfg):
|
|
468
|
+
"""maxParallelAgents, maxParallelHeavyJobs, maxWorkersPerJob and mode for this reading."""
|
|
469
|
+
cpus = max(1, int(reading.get("cpus") or 1))
|
|
470
|
+
level = pressure["level"]
|
|
471
|
+
reasons = []
|
|
472
|
+
if level == "unknown":
|
|
473
|
+
agents, heavy = min(2, cfg["maxParallelAgents"]), 1
|
|
474
|
+
workers = max(1, cpus // 2)
|
|
475
|
+
return {"mode": "reduced" if agents > 1 else "sequential", "maxParallelAgents": agents, "maxParallelHeavyJobs": heavy,
|
|
476
|
+
"maxWorkersPerJob": workers, "budgetMb": None, "reasons": ["sem medição: assuma reduced"]}
|
|
477
|
+
reserve = min(cfg["reservedMb"], reading["totalMb"] * cfg["reservedPercent"] / 100.0)
|
|
478
|
+
budget = max(0.0, reading["availableMb"] - reserve)
|
|
479
|
+
if level == "critical":
|
|
480
|
+
return {"mode": "sequential", "maxParallelAgents": 1, "maxParallelHeavyJobs": 0, "maxWorkersPerJob": 1,
|
|
481
|
+
"budgetMb": round(budget), "reasons": ["pressão crítica: sequencial, sem novos jobs pesados"] + pressure["reasons"]}
|
|
482
|
+
memory_agents = int(budget // cfg["perAgentMb"])
|
|
483
|
+
agents = max(1, min(memory_agents, cfg["maxParallelAgents"]))
|
|
484
|
+
memory_heavy = int(budget // cfg["perHeavyJobMb"])
|
|
485
|
+
heavy = max(1, min(memory_heavy, max(1, cpus // 4), cfg["maxParallelHeavyJobs"]))
|
|
486
|
+
if memory_agents < cfg["maxParallelAgents"]:
|
|
487
|
+
reasons.append("memória: " + str(round(budget)) + " MB livres além da reserva, " + str(cfg["perAgentMb"]) + " MB por agente")
|
|
488
|
+
if memory_heavy < 1:
|
|
489
|
+
reasons.append("memória abaixo de um job pesado (" + str(cfg["perHeavyJobMb"]) + " MB): no máximo um, sem nada pesado em paralelo")
|
|
490
|
+
if level == "warn":
|
|
491
|
+
agents, heavy = max(1, agents // 2), max(1, heavy // 2)
|
|
492
|
+
reasons += ["pressão warn: paralelismo pela metade"] + pressure["reasons"]
|
|
493
|
+
load = reading.get("load")
|
|
494
|
+
load_high = bool(load) and load[0] / cpus > cfg["loadPerCpuReduce"]
|
|
495
|
+
if load_high:
|
|
496
|
+
heavy = max(1, heavy // 2)
|
|
497
|
+
reasons.append("carga " + str(load[0]) + " em " + str(cpus) + " CPUs")
|
|
498
|
+
workers = min(max(1, cpus // heavy), max(1, int((budget / heavy) // cfg["perWorkerMb"])))
|
|
499
|
+
if level == "warn" or load_high:
|
|
500
|
+
workers = max(1, workers // 2)
|
|
501
|
+
# Sizing by free memory at normal pressure is already in the numbers; "reduced" means the
|
|
502
|
+
# machine is struggling (pressure or load), "sequential" that there is room for one agent only.
|
|
503
|
+
mode = "sequential" if agents <= 1 else "reduced" if level == "warn" or load_high else "parallel"
|
|
504
|
+
return {"mode": mode, "maxParallelAgents": agents, "maxParallelHeavyJobs": heavy, "maxWorkersPerJob": workers,
|
|
505
|
+
"budgetMb": round(budget), "reasons": reasons}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def build(reading, cfg, meta, agents=None, cached=False, age=0):
|
|
509
|
+
pressure = assess(reading, cfg)
|
|
510
|
+
total, available = reading.get("totalMb"), reading.get("availableMb")
|
|
511
|
+
memory = {"totalMb": round(total) if total else None, "availableMb": round(available) if available is not None else None,
|
|
512
|
+
"usedPercent": round(100.0 * (1 - available / total), 1) if total and available is not None else None,
|
|
513
|
+
"swapTotalMb": round(reading["swapTotalMb"]) if reading.get("swapTotalMb") is not None else None,
|
|
514
|
+
"swapUsedMb": round(reading["swapUsedMb"]) if reading.get("swapUsedMb") is not None else None,
|
|
515
|
+
"cgroupLimitMb": round(reading["cgroupLimitMb"]) if reading.get("cgroupLimitMb") else None}
|
|
516
|
+
load = reading.get("load")
|
|
517
|
+
return {"schema": SCHEMA, "generatedAt": now_iso(), "platform": reading.get("platform"), "source": reading.get("source"),
|
|
518
|
+
"cached": cached, "ageSeconds": int(age), "memory": memory, "pressure": pressure,
|
|
519
|
+
"cpu": {"count": reading.get("cpus"), "load1": load[0] if load else None, "load5": load[1] if load else None,
|
|
520
|
+
"load15": load[2] if load else None},
|
|
521
|
+
"agents": agents, "recommendation": recommend(reading, pressure, cfg), "thresholds": cfg, "config": meta}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# --- cache ----------------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
def read_cache(home, max_age, now=None):
|
|
527
|
+
path = cache_path(home)
|
|
528
|
+
try:
|
|
529
|
+
if path.is_symlink() or not path.is_file() or path.stat().st_size > 64 * 1024:
|
|
530
|
+
return None, 0
|
|
531
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
532
|
+
except (OSError, ValueError):
|
|
533
|
+
return None, 0
|
|
534
|
+
if not isinstance(data, dict) or data.get("schema") != CACHE_SCHEMA or not isinstance(data.get("reading"), dict):
|
|
535
|
+
return None, 0
|
|
536
|
+
try:
|
|
537
|
+
age = (now or time.time()) - float(data.get("at") or 0)
|
|
538
|
+
valid = math.isfinite(age) and valid_reading(data["reading"])
|
|
539
|
+
except (TypeError, ValueError):
|
|
540
|
+
return None, 0
|
|
541
|
+
if not valid or age < 0 or age > max_age:
|
|
542
|
+
return None, 0 # a corrupt or old cache is a miss; the next probe rewrites it
|
|
543
|
+
return data["reading"], age
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def valid_reading(reading):
|
|
547
|
+
"""A raw reading has the numeric fields build() uses (None allowed where unmeasured)."""
|
|
548
|
+
def number(value, allow_none=True):
|
|
549
|
+
return (value is None and allow_none) or (isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
550
|
+
and math.isfinite(value) and value >= 0)
|
|
551
|
+
if not isinstance(reading, dict):
|
|
552
|
+
return False
|
|
553
|
+
if not all(number(reading.get(k)) for k in ("totalMb", "availableMb", "swapTotalMb", "swapUsedMb", "cgroupLimitMb")):
|
|
554
|
+
return False
|
|
555
|
+
if reading.get("totalMb") == 0 or (reading.get("totalMb") is None and reading.get("availableMb") is not None):
|
|
556
|
+
return False # availableMb may be unmeasured (None) while totalMb is known: assess() reports unknown
|
|
557
|
+
if not number(reading.get("cpus"), allow_none=False) or reading.get("osLevel") not in (None, "normal", "warn", "critical"):
|
|
558
|
+
return False
|
|
559
|
+
load, psi = reading.get("load"), reading.get("psi")
|
|
560
|
+
if load is not None and not (isinstance(load, list) and len(load) == 3 and all(number(x, False) for x in load)):
|
|
561
|
+
return False
|
|
562
|
+
if psi is not None and not (isinstance(psi, dict) and all(number(v, False) for v in psi.values())):
|
|
563
|
+
return False
|
|
564
|
+
return True
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def write_cache(home, reading):
|
|
568
|
+
"""Atomic, private, never through a symlink; a failure only means no cache."""
|
|
569
|
+
# The home itself may be a symlink (/home -> /var/home on Fedora Atomic); only the
|
|
570
|
+
# directories this CLI creates below it must not be.
|
|
571
|
+
base = Path(os.path.realpath(home))
|
|
572
|
+
path = cache_path(base)
|
|
573
|
+
try:
|
|
574
|
+
directory = path.parent
|
|
575
|
+
for part in (base / ".local", base / ".local" / "state", base / ".local" / "state" / "crow-skills"):
|
|
576
|
+
if part.is_symlink():
|
|
577
|
+
return
|
|
578
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
579
|
+
temp = directory / ("." + path.name + "." + uuid.uuid4().hex)
|
|
580
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
581
|
+
try:
|
|
582
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
583
|
+
json.dump({"schema": CACHE_SCHEMA, "at": time.time(), "reading": reading}, handle)
|
|
584
|
+
os.replace(temp, path)
|
|
585
|
+
finally:
|
|
586
|
+
if os.path.lexists(temp):
|
|
587
|
+
os.unlink(temp)
|
|
588
|
+
except OSError:
|
|
589
|
+
pass
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def fixture_reading():
|
|
593
|
+
"""The pinned reading of CROW_RESOURCES_FIXTURE (tests), or None when unset or invalid:
|
|
594
|
+
a bad fixture falls back to the real machine instead of failing."""
|
|
595
|
+
fixture = os.environ.get("CROW_RESOURCES_FIXTURE")
|
|
596
|
+
if not fixture:
|
|
597
|
+
return None
|
|
598
|
+
try:
|
|
599
|
+
path = Path(fixture)
|
|
600
|
+
if not path.is_file() or path.stat().st_size > 64 * 1024:
|
|
601
|
+
return None
|
|
602
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
603
|
+
except (OSError, ValueError):
|
|
604
|
+
return None
|
|
605
|
+
if not isinstance(data, dict):
|
|
606
|
+
return None
|
|
607
|
+
agents = data.pop("agents", None)
|
|
608
|
+
if not valid_reading(data):
|
|
609
|
+
return None
|
|
610
|
+
if isinstance(agents, dict):
|
|
611
|
+
data["agents"] = agents
|
|
612
|
+
return data
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def probe(home, processes=True, use_cache=False, platform=None):
|
|
616
|
+
"""The crow-resources/1 document. use_cache: reuse a reading younger than cacheSeconds
|
|
617
|
+
(hooks, status lines, snapshot); processes: also scan agent CLI process trees."""
|
|
618
|
+
cfg, meta = load_config(home)
|
|
619
|
+
reading, age, cached = None, 0, False
|
|
620
|
+
data = fixture_reading()
|
|
621
|
+
if data is not None:
|
|
622
|
+
agents = data.pop("agents", None) if processes else None
|
|
623
|
+
return build(data, cfg, meta, agents, False, 0)
|
|
624
|
+
if use_cache:
|
|
625
|
+
reading, age = read_cache(home, cfg["cacheSeconds"])
|
|
626
|
+
cached = reading is not None
|
|
627
|
+
if reading is None:
|
|
628
|
+
reading = read_machine(platform)
|
|
629
|
+
write_cache(home, reading)
|
|
630
|
+
agents = read_processes(platform) if processes else None
|
|
631
|
+
return build(reading, cfg, meta, agents, cached, age)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# --- presentation ---------------------------------------------------------------------------
|
|
635
|
+
|
|
636
|
+
def alert(data):
|
|
637
|
+
"""True when the machine needs attention: pressure warn/critical or a non-parallel recommendation."""
|
|
638
|
+
return data["pressure"]["level"] in ("warn", "critical") or data["recommendation"]["mode"] != "parallel"
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def gb(mb):
|
|
642
|
+
return "?" if mb is None else ("%.1f" % (mb / 1024)).replace(".", ",")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def hint(data):
|
|
646
|
+
"""One line for a prompt hook's additionalContext, or None when there is nothing to say."""
|
|
647
|
+
if not alert(data):
|
|
648
|
+
return None
|
|
649
|
+
rec, memory = data["recommendation"], data["memory"]
|
|
650
|
+
level = data["pressure"]["level"]
|
|
651
|
+
if level == "unknown":
|
|
652
|
+
head = "[crow-skills] memória: não medida nesta plataforma."
|
|
653
|
+
else:
|
|
654
|
+
head = ("[crow-skills] memória: pressão " + level + ", " + gb(memory["availableMb"]) + " GB livres de " +
|
|
655
|
+
gb(memory["totalMb"]) + " GB.")
|
|
656
|
+
if rec["mode"] == "sequential" and rec["maxParallelHeavyJobs"] == 0:
|
|
657
|
+
body = " Recomendação sequential: um agente por vez, não inicie builds/testes/dev servers novos e encerre os que esta sessão abriu e não usa."
|
|
658
|
+
else:
|
|
659
|
+
body = (" Recomendação " + rec["mode"] + ": até " + str(rec["maxParallelAgents"]) + " agente(s) e " +
|
|
660
|
+
str(rec["maxParallelHeavyJobs"]) + " job(s) pesado(s) em paralelo, " + str(rec["maxWorkersPerJob"]) + " worker(s) por job.")
|
|
661
|
+
return head + body + " Detalhe: crow-skills resources --json."
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def segment(data, unicode=True):
|
|
665
|
+
"""Compact status line/tmux segment, empty when the machine is fine (e.g. 'mem ▲ 85% ×2')."""
|
|
666
|
+
if not alert(data):
|
|
667
|
+
return ""
|
|
668
|
+
used = data["memory"]["usedPercent"]
|
|
669
|
+
level = data["pressure"]["level"]
|
|
670
|
+
# The arrow is for real pressure only; "sequential" at normal pressure shows just the numbers.
|
|
671
|
+
arrow = {"warn": "▲" if unicode else "^", "critical": "▲▲" if unicode else "^^"}.get(level)
|
|
672
|
+
times = "×" if unicode else "x"
|
|
673
|
+
text = "mem " + (arrow + " " if arrow else "") + (str(int(round(used))) + "%" if used is not None else "?")
|
|
674
|
+
return text + " " + times + str(data["recommendation"]["maxParallelAgents"])
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def render(data):
|
|
678
|
+
memory, pressure, rec, cpu = data["memory"], data["pressure"], data["recommendation"], data["cpu"]
|
|
679
|
+
lines = ["memória: " + gb(memory["availableMb"]) + " GB livres de " + gb(memory["totalMb"]) + " GB" +
|
|
680
|
+
(" (" + str(int(round(memory["usedPercent"]))) + "% em uso)" if memory["usedPercent"] is not None else "") +
|
|
681
|
+
(" · swap " + gb(memory["swapUsedMb"]) + "/" + gb(memory["swapTotalMb"]) + " GB" if memory["swapTotalMb"] else "") +
|
|
682
|
+
(" · limite cgroup " + gb(memory["cgroupLimitMb"]) + " GB" if memory["cgroupLimitMb"] else ""),
|
|
683
|
+
"pressão: " + pressure["level"] + (" (" + "; ".join(pressure["reasons"]) + ")" if pressure["reasons"] else ""),
|
|
684
|
+
"cpu: " + str(cpu["count"]) + (" · carga " + str(cpu["load1"]) + " " + str(cpu["load5"]) + " " + str(cpu["load15"])
|
|
685
|
+
if cpu["load1"] is not None else "")]
|
|
686
|
+
agents = data.get("agents")
|
|
687
|
+
if agents is not None:
|
|
688
|
+
kinds = ", ".join(k + " " + str(v["count"]) + " (" + gb(v["rssMb"]) + " GB)" for k, v in sorted(agents["byKind"].items()))
|
|
689
|
+
lines.append("agentes: " + str(agents["count"]) + " CLI(s), " + gb(agents["rssMb"]) + " GB RSS" + (" · " + kinds if kinds else ""))
|
|
690
|
+
lines.append("recomendação: " + rec["mode"] + " · até " + str(rec["maxParallelAgents"]) + " agente(s), " +
|
|
691
|
+
str(rec["maxParallelHeavyJobs"]) + " job(s) pesado(s), " + str(rec["maxWorkersPerJob"]) + " worker(s) por job")
|
|
692
|
+
for reason in rec["reasons"]:
|
|
693
|
+
lines.append(" - " + reason)
|
|
694
|
+
for error in data["config"]["errors"]:
|
|
695
|
+
lines.append("config: " + error)
|
|
696
|
+
return "\n".join(lines)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def main(argv=None):
|
|
700
|
+
import argparse
|
|
701
|
+
p = argparse.ArgumentParser(prog="crow_resources", description="Memória, pressão e paralelismo recomendado.")
|
|
702
|
+
p.add_argument("--home")
|
|
703
|
+
p.add_argument("--json", action="store_true")
|
|
704
|
+
p.add_argument("--no-processes", action="store_true")
|
|
705
|
+
args = p.parse_args(argv)
|
|
706
|
+
home = Path(args.home).expanduser() if args.home else Path.home()
|
|
707
|
+
data = probe(home, processes=not args.no_processes)
|
|
708
|
+
print(json.dumps(data, ensure_ascii=False) if args.json else render(data))
|
|
709
|
+
return 0
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
if __name__ == "__main__":
|
|
713
|
+
raise SystemExit(main())
|