@andespindola/crow-skills 0.10.0 → 0.10.1

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.
@@ -8,15 +8,23 @@ memory pressure without the CLI package. The CLI uses the same functions for
8
8
 
9
9
  What is read, and nothing else:
10
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.
11
+ kern.memorystatus_vm_pressure_level, kern.memorystatus_level,
12
+ vm.compressor_bytes_used); ``vm_stat`` only when the kernel does not report
13
+ the available level.
14
+ - Linux: /proc/meminfo (MemAvailable, swap), /proc/pressure/memory and
15
+ /proc/pressure/cpu (PSI) when present, and the cgroup v2 memory and CPU limits
16
+ (with their pressure files) when the process runs under one.
17
+ - Load average (``os.getloadavg``) on both.
18
+ - Agent processes (full probe only): pid, parent pid, RSS, CPU% and the
19
+ executable name, from ``ps -axo pid=,ppid=,rss=,%cpu=,comm=`` (macOS) or
20
+ /proc/<pid>/stat (Linux). Names are matched against a fixed list and only the
21
+ agent kind, counts, RSS and CPU% are reported: no command line, argument or
22
+ process name is ever printed or stored.
23
+
24
+ Three axes, each with its own level (normal, warn, critical): memory (available
25
+ percent, OS level, PSI), swap (folded into the memory level) and CPU (load per
26
+ CPU over 1 and 5 minutes, CPU PSI on Linux). Every recommendation is monotonic:
27
+ more pressure on any axis never raises a number (tests walk a grid of inputs).
20
28
 
21
29
  Tests (and only tests) pin the reading with CROW_RESOURCES_FIXTURE, the path of a
22
30
  JSON raw reading (optionally with an "agents" object); the cache is then bypassed.
@@ -30,6 +38,8 @@ import math
30
38
  import os
31
39
  from pathlib import Path
32
40
  import re
41
+ import shlex
42
+ import shutil
33
43
  import subprocess
34
44
  import sys
35
45
  import time
@@ -62,7 +72,19 @@ DEFAULTS = {
62
72
  "criticalAvailablePercent": 7, # available/total below this -> critical
63
73
  "psiWarnSomeAvg10": 10.0, # Linux PSI "some avg10" (%) at or above this -> warn
64
74
  "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
75
+ # Swap (folded into the memory level). macOS grows its swap files on demand, so a high share
76
+ # of the swap total alone is routine (72% captured on a calm 18 GB machine); the warning also
77
+ # needs swap in use worth a quarter of the RAM. Critical: swap nearly full while available
78
+ # memory is also low, the state that ends in thrashing (macOS) or the OOM killer (Linux).
79
+ "swapWarnUsedPercent": 75, # swap used/total at or above this...
80
+ "swapWarnRamPercent": 25, # ...and swap used/RAM at or above this -> warn
81
+ "swapCriticalUsedPercent": 90, # swap used/total at or above this...
82
+ "swapCriticalAvailablePercent": 25, # ...and available/total below this -> critical
83
+ # CPU: load average per CPU (runnable threads per core; above 1 work queues).
84
+ "loadPerCpuReduce": 1.5, # 1- or 5-min load / cpus above this -> cpu warn (halves agents, jobs, workers)
85
+ "loadPerCpuCritical": 3.0, # 1-min load / cpus at or above this, sustained (5-min above warn) -> cpu critical
86
+ "psiCpuWarnSomeAvg10": 20.0, # Linux CPU PSI "some avg10" (%) at or above this -> cpu warn
87
+ "psiCpuCriticalSomeAvg10": 50.0, # ... at or above this -> cpu critical
66
88
  "cacheSeconds": 5, # hooks and status lines reuse a probe this recent
67
89
  }
68
90
  LIMITS = { # key -> (minimum, maximum)
@@ -71,7 +93,13 @@ LIMITS = { # key -> (minimum, maximum)
71
93
  "warnAvailablePercent": (0, 100), "criticalAvailablePercent": (0, 100),
72
94
  "psiWarnSomeAvg10": (0, 100), "psiCriticalFullAvg10": (0, 100),
73
95
  "loadPerCpuReduce": (0.1, 1000), "cacheSeconds": (0, 300),
96
+ "swapWarnUsedPercent": (0, 100), "swapWarnRamPercent": (0, 1000), "swapCriticalUsedPercent": (0, 100),
97
+ "swapCriticalAvailablePercent": (0, 100), "loadPerCpuCritical": (0.1, 1000),
98
+ "psiCpuWarnSomeAvg10": (0, 100), "psiCpuCriticalSomeAvg10": (0, 100),
74
99
  }
100
+ # (warn key, critical key, critical must be <= warn): an inverted pair falls back to both defaults.
101
+ PAIRS = (("warnAvailablePercent", "criticalAvailablePercent", True), ("swapWarnUsedPercent", "swapCriticalUsedPercent", False),
102
+ ("loadPerCpuReduce", "loadPerCpuCritical", False), ("psiCpuWarnSomeAvg10", "psiCpuCriticalSomeAvg10", False))
75
103
  INTEGER_KEYS = ("reservedMb", "perAgentMb", "perHeavyJobMb", "perWorkerMb", "maxParallelAgents", "maxParallelHeavyJobs")
76
104
  MAX_CONFIG = 64 * 1024
77
105
  MB = 1024 * 1024
@@ -120,10 +148,10 @@ def load_config(home):
120
148
  effective[key] = int(value) if key in INTEGER_KEYS else float(value)
121
149
  except (OSError, ValueError, UnicodeDecodeError):
122
150
  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"]
151
+ for warn_key, critical_key, below in PAIRS:
152
+ if (effective[critical_key] > effective[warn_key]) if below else (effective[critical_key] < effective[warn_key]):
153
+ errors.append(critical_key + (" maior que " if below else " menor que ") + warn_key + "; os dois padrões foram restaurados.")
154
+ effective[critical_key], effective[warn_key] = DEFAULTS[critical_key], DEFAULTS[warn_key]
127
155
  return effective, {"path": str(path), "loaded": loaded, "errors": errors}
128
156
 
129
157
 
@@ -190,6 +218,10 @@ def macos_reading(sysctl_text, vm_stat_text=None):
190
218
  reading["swapTotalMb"], reading["swapUsedMb"] = parse_swapusage(values.get("vm.swapusage", ""))
191
219
  # kern.memorystatus_vm_pressure_level: 1 normal, 2 warning, 4 critical (the level memory_pressure shows).
192
220
  reading["osLevel"] = {"1": "normal", "2": "warn", "4": "critical"}.get(values.get("kern.memorystatus_vm_pressure_level"))
221
+ try:
222
+ reading["compressedMb"] = int(values["vm.compressor_bytes_used"]) / MB # memory held by the compressor (report only)
223
+ except (KeyError, ValueError):
224
+ pass
193
225
  try:
194
226
  reading["cpus"] = int(values["hw.ncpu"])
195
227
  except (KeyError, ValueError):
@@ -240,9 +272,10 @@ def parse_cpu_max(text):
240
272
  return max(1, -(-quota // period)) if quota > 0 and period > 0 else None
241
273
 
242
274
 
243
- def linux_reading(meminfo_text, psi_text=None, cgroup=None):
275
+ def linux_reading(meminfo_text, psi_text=None, cgroup=None, cpu_psi_text=None):
244
276
  """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)."""
277
+ max, current, stat (memory.stat), pressure (memory.pressure), cpuMax (cpu.max) and
278
+ cpuPressure (cpu.pressure of the level that sets cpu.max). cpu_psi_text: /proc/pressure/cpu."""
246
279
  info = parse_meminfo(meminfo_text)
247
280
  if "MemTotal" not in info:
248
281
  return None
@@ -273,30 +306,48 @@ def linux_reading(meminfo_text, psi_text=None, cgroup=None):
273
306
  cpus = parse_cpu_max(cgroup.get("cpuMax"))
274
307
  if cpus:
275
308
  reading["cpuQuota"] = cpus
309
+ cpu_psi = parse_psi(cgroup.get("cpuPressure") or cpu_psi_text) if (cgroup.get("cpuPressure") or cpu_psi_text) else None
310
+ if cpu_psi and "someAvg10" in cpu_psi:
311
+ reading["cpuPsi"] = {"someAvg10": cpu_psi["someAvg10"]} # "full" is not meaningful for CPU system-wide
276
312
  return reading
277
313
 
278
314
 
279
- def parse_ps(text):
280
- """``ps -axo pid=,ppid=,rss=,comm=`` -> [(pid, ppid, rss_kb, basename)]."""
315
+ def _percent(text):
316
+ try:
317
+ value = float(text.replace(",", "."))
318
+ except ValueError:
319
+ return None
320
+ return value if math.isfinite(value) and value >= 0 else None
321
+
322
+
323
+ def parse_ps(text, cpu=False):
324
+ """``ps -axo pid=,ppid=,rss=,comm=`` -> [(pid, ppid, rss_kb, basename, None)]; with cpu,
325
+ ``ps -axo pid=,ppid=,rss=,%cpu=,comm=`` -> [(pid, ppid, rss_kb, basename, cpu_percent)]."""
281
326
  rows = []
282
327
  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()):
328
+ parts = line.split(None, 4 if cpu else 3)
329
+ if len(parts) < (5 if cpu else 4) or not (parts[0].isdigit() and parts[1].isdigit() and parts[2].isdigit()):
330
+ continue
331
+ percent = _percent(parts[3]) if cpu else None
332
+ if cpu and percent is None:
285
333
  continue
286
- rows.append((int(parts[0]), int(parts[1]), int(parts[2]), os.path.basename(parts[3].strip())))
334
+ rows.append((int(parts[0]), int(parts[1]), int(parts[2]), os.path.basename(parts[-1].strip()), percent))
287
335
  return rows
288
336
 
289
337
 
290
338
  def agent_trees(rows):
291
339
  """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}
340
+ RSS and CPU% include every descendant (node workers, MCP servers, shells). Only kinds,
341
+ counts, MB and CPU% are returned. Rows: (pid, ppid, rss_kb, name[, cpu_percent or None])."""
342
+ rows = [tuple(r) + (None,) * (5 - len(r)) for r in rows]
343
+ parent = {r[0]: r[1] for r in rows}
344
+ rss = {r[0]: r[2] for r in rows}
345
+ cpu = {r[0]: r[4] for r in rows}
346
+ has_cpu = any(r[4] is not None for r in rows)
347
+ kind = {r[0]: AGENT_NAMES.get(r[3].lower()) for r in rows}
297
348
  children = {}
298
- for pid, ppid, _, _ in rows:
299
- children.setdefault(ppid, []).append(pid)
349
+ for r in rows:
350
+ children.setdefault(r[1], []).append(r[0])
300
351
 
301
352
  def has_agent_ancestor(pid):
302
353
  seen, current = set(), parent.get(pid)
@@ -307,23 +358,31 @@ def agent_trees(rows):
307
358
  current = parent.get(current)
308
359
  return False
309
360
 
310
- by_kind, total = {}, 0
311
- for pid, _, _, _ in rows:
361
+ by_kind, total, total_cpu = {}, 0, 0.0
362
+ for r in rows:
363
+ pid = r[0]
312
364
  if not kind.get(pid) or has_agent_ancestor(pid):
313
365
  continue
314
- stack, seen, tree_kb = [pid], set(), 0
366
+ stack, seen, tree_kb, tree_cpu = [pid], set(), 0, 0.0
315
367
  while stack:
316
368
  current = stack.pop()
317
369
  if current in seen:
318
370
  continue
319
371
  seen.add(current)
320
372
  tree_kb += rss.get(current, 0)
373
+ tree_cpu += cpu.get(current) or 0.0
321
374
  stack.extend(children.get(current, []))
322
- entry = by_kind.setdefault(kind[pid], {"count": 0, "rssMb": 0})
375
+ entry = by_kind.setdefault(kind[pid], dict({"count": 0, "rssMb": 0}, **({"cpuPercent": 0.0} if has_cpu else {})))
323
376
  entry["count"] += 1
324
377
  entry["rssMb"] += round(tree_kb / 1024)
378
+ if has_cpu:
379
+ entry["cpuPercent"] = round(entry["cpuPercent"] + tree_cpu, 1)
325
380
  total += tree_kb
326
- return {"count": sum(e["count"] for e in by_kind.values()), "rssMb": round(total / 1024), "byKind": by_kind}
381
+ total_cpu += tree_cpu
382
+ result = {"count": sum(e["count"] for e in by_kind.values()), "rssMb": round(total / 1024), "byKind": by_kind}
383
+ if has_cpu:
384
+ result["cpuPercent"] = round(total_cpu, 1) # % of one CPU, summed (ps convention): 250 = 2.5 CPUs busy
385
+ return result
327
386
 
328
387
 
329
388
  # --- readers (side effects: one subprocess or a few /proc reads) ----------------------------
@@ -368,6 +427,7 @@ def cgroup_files():
368
427
  cpus = parse_cpu_max(cpu_text)
369
428
  if cpus and (best_cpu is None or cpus < best_cpu):
370
429
  best_cpu, result["cpuMax"] = cpus, cpu_text
430
+ result["cpuPressure"] = _read(directory / "cpu.pressure", 512)
371
431
  if directory == root:
372
432
  break
373
433
  return result
@@ -378,11 +438,12 @@ def read_machine(platform=None):
378
438
  platform = platform or sys.platform
379
439
  reading = None
380
440
  if platform == "darwin":
381
- text = _run(["sysctl", "hw.memsize", "hw.ncpu", "vm.swapusage", "kern.memorystatus_vm_pressure_level", "kern.memorystatus_level"])
441
+ text = _run(["sysctl", "hw.memsize", "hw.ncpu", "vm.swapusage", "kern.memorystatus_vm_pressure_level", "kern.memorystatus_level",
442
+ "vm.compressor_bytes_used"])
382
443
  values = parse_sysctl(text)
383
444
  reading = macos_reading(text, _run(["vm_stat"]) if "kern.memorystatus_level" not in values else None)
384
445
  elif platform.startswith("linux"):
385
- reading = linux_reading(_read("/proc/meminfo"), _read("/proc/pressure/memory"), cgroup_files())
446
+ reading = linux_reading(_read("/proc/meminfo"), _read("/proc/pressure/memory"), cgroup_files(), _read("/proc/pressure/cpu", 512))
386
447
  if reading is None:
387
448
  reading = {"platform": platform if platform in ("darwin",) or platform.startswith("linux") else "other",
388
449
  "source": "unavailable", "totalMb": None, "availableMb": None, "swapTotalMb": None, "swapUsedMb": None,
@@ -393,7 +454,10 @@ def read_machine(platform=None):
393
454
  except (AttributeError, OSError):
394
455
  reading["cpus"] = os.cpu_count() or 1
395
456
  if reading.get("cpuQuota"):
396
- reading["cpus"] = min(reading["cpus"], reading.pop("cpuQuota"))
457
+ quota = reading.pop("cpuQuota")
458
+ if quota < reading["cpus"]:
459
+ reading["loadCpus"] = os.cpu_count() or reading["cpus"] # load average is host-wide
460
+ reading["cpus"] = quota
397
461
  try:
398
462
  reading["load"] = [round(x, 2) for x in os.getloadavg()]
399
463
  except (AttributeError, OSError):
@@ -407,7 +471,9 @@ def read_processes(platform=None):
407
471
  rows, page_kb = [], (os.sysconf("SC_PAGE_SIZE") // 1024 if hasattr(os, "sysconf") else 4)
408
472
  try:
409
473
  pids = [p for p in os.listdir("/proc") if p.isdigit()]
410
- except OSError:
474
+ ticks = os.sysconf("SC_CLK_TCK")
475
+ uptime = float((_read("/proc/uptime", 128) or "").split()[0])
476
+ except (OSError, ValueError, IndexError, AttributeError):
411
477
  return None
412
478
  for pid in pids:
413
479
  stat = _read("/proc/" + pid + "/stat", 4096)
@@ -417,13 +483,18 @@ def read_processes(platform=None):
417
483
  name = stat[stat.find("(") + 1:stat.rfind(")")]
418
484
  fields = stat[stat.rfind(")") + 2:].split()
419
485
  try:
420
- rows.append((int(pid), int(fields[1]), int(statm.split()[1]) * page_kb, name))
421
- except (IndexError, ValueError):
486
+ # CPU%: time used over the process lifetime (what Linux ps reports as %cpu).
487
+ elapsed = uptime - int(fields[19]) / ticks
488
+ busy = (int(fields[11]) + int(fields[12])) / ticks
489
+ rows.append((int(pid), int(fields[1]), int(statm.split()[1]) * page_kb, name,
490
+ round(100.0 * busy / elapsed, 1) if elapsed > 0 else 0.0))
491
+ except (IndexError, ValueError, ZeroDivisionError):
422
492
  continue
423
493
  return agent_trees(rows)
424
494
  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
495
+ # %cpu: macOS ps reports a decaying average over roughly the last minute.
496
+ text = _run(["ps", "-axo", "pid=,ppid=,rss=,%cpu=,comm="], timeout=3.0)
497
+ return agent_trees(parse_ps(text, cpu=True)) if text else None
427
498
  return None
428
499
 
429
500
 
@@ -435,12 +506,31 @@ def worst(*levels):
435
506
  return max(known, key=order.get) if known else None
436
507
 
437
508
 
509
+ def swap_level(reading, cfg, available_percent):
510
+ """Swap axis: {'level', 'usedPercent', 'ramPercent'}, or None without swap."""
511
+ total, used, ram = reading.get("swapTotalMb"), reading.get("swapUsedMb"), reading.get("totalMb")
512
+ if not total or used is None or not ram:
513
+ return None
514
+ used_percent, ram_percent = 100.0 * used / total, 100.0 * used / ram
515
+ # Critical also needs swap worth swapWarnRamPercent of the RAM: a small swap full on a machine
516
+ # with plenty of RAM (common on Linux servers) is not the thrashing state.
517
+ if used_percent >= cfg["swapCriticalUsedPercent"] and ram_percent >= cfg["swapWarnRamPercent"] \
518
+ and available_percent is not None and available_percent < cfg["swapCriticalAvailablePercent"]:
519
+ level = "critical"
520
+ elif used_percent >= cfg["swapWarnUsedPercent"] and ram_percent >= cfg["swapWarnRamPercent"]:
521
+ level = "warn"
522
+ else:
523
+ level = "normal"
524
+ return {"level": level, "usedPercent": round(used_percent, 1), "ramPercent": round(ram_percent, 1)}
525
+
526
+
438
527
  def assess(reading, cfg):
439
- """Pressure level for a raw reading: the worst of the OS level, PSI and available percent."""
528
+ """Memory pressure for a raw reading: the worst of the OS level, PSI, available percent and
529
+ swap. ``ram`` is the same without swap (the status line shows swap on its own)."""
440
530
  total, available = reading.get("totalMb"), reading.get("availableMb")
441
531
  reasons = []
442
532
  if not total or available is None:
443
- return {"level": "unknown", "os": reading.get("osLevel"), "psi": reading.get("psi"),
533
+ return {"level": "unknown", "ram": "unknown", "os": reading.get("osLevel"), "psi": reading.get("psi"), "swap": None,
444
534
  "reasons": ["memória não medida nesta plataforma; recomendação conservadora"]}
445
535
  percent = 100.0 * available / total
446
536
  by_percent = "critical" if percent < cfg["criticalAvailablePercent"] else "warn" if percent < cfg["warnAvailablePercent"] else "normal"
@@ -460,65 +550,136 @@ def assess(reading, cfg):
460
550
  psi_level = "normal"
461
551
  if reading.get("osLevel") in ("warn", "critical"):
462
552
  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."""
553
+ ram = worst(by_percent, psi_level, reading.get("osLevel"))
554
+ swap = swap_level(reading, cfg, percent)
555
+ if swap and swap["level"] != "normal":
556
+ reasons.append("swap " + str(round(swap["usedPercent"])) + "% em uso (" + str(round(swap["ramPercent"])) + "% da RAM)")
557
+ return {"level": worst(ram, swap["level"] if swap else None), "ram": ram, "os": reading.get("osLevel"), "psi": psi,
558
+ "swap": swap, "availablePercent": round(percent, 1), "reasons": reasons}
559
+
560
+
561
+ def assess_cpu(reading, cfg):
562
+ """CPU pressure: load average per CPU (1 and 5 min) and, on Linux, CPU PSI."""
563
+ # The load average counts the whole host: inside a container with a CPU quota it is divided
564
+ # by the host CPUs (loadCpus); the quota's own contention shows in the cgroup cpu.pressure.
565
+ cpus = max(1, int(reading.get("loadCpus") or reading.get("cpus") or 1))
566
+ load, psi = reading.get("load"), reading.get("cpuPsi")
567
+ per1 = per5 = None
568
+ level, reasons = "normal", []
569
+ if load:
570
+ per1, per5 = load[0] / cpus, load[1] / cpus
571
+ if per1 >= cfg["loadPerCpuCritical"] and per5 > cfg["loadPerCpuReduce"]:
572
+ level = "critical"
573
+ elif per1 > cfg["loadPerCpuReduce"] or per5 > cfg["loadPerCpuReduce"]:
574
+ level = "warn"
575
+ if level != "normal":
576
+ reasons.append("carga " + str(load[0]) + "/" + str(load[1]) + " em " + str(cpus) + " CPUs (" + ("%.1f" % per1) + "×/" +
577
+ ("%.1f" % per5) + "× por CPU em 1/5 min)")
578
+ if psi and psi.get("someAvg10") is not None:
579
+ by_psi = "critical" if psi["someAvg10"] >= cfg["psiCpuCriticalSomeAvg10"] else \
580
+ "warn" if psi["someAvg10"] >= cfg["psiCpuWarnSomeAvg10"] else "normal"
581
+ if by_psi != "normal":
582
+ reasons.append("PSI cpu some avg10 " + str(psi["someAvg10"]))
583
+ level = worst(level, by_psi)
584
+ if not load and not psi:
585
+ return {"level": "unknown", "loadPerCpu1": None, "loadPerCpu5": None, "psi": None, "reasons": []}
586
+ return {"level": level, "loadPerCpu1": round(per1, 2) if per1 is not None else None,
587
+ "loadPerCpu5": round(per5, 2) if per5 is not None else None, "psi": psi, "reasons": reasons}
588
+
589
+
590
+ def recommend(reading, pressure, cfg, cpu_pressure=None):
591
+ """maxParallelAgents, maxParallelHeavyJobs, maxWorkersPerJob and mode for this reading.
592
+
593
+ Monotonic by construction: every number starts from a base that does not depend on load,
594
+ and pressure only divides it (warn on memory/swap or CPU halves once; memory critical: 1 agent,
595
+ no new heavy job; CPU critical: 1 agent, 1 job, 1 worker)."""
469
596
  cpus = max(1, int(reading.get("cpus") or 1))
470
597
  level = pressure["level"]
598
+ cpu_level = (cpu_pressure or assess_cpu(reading, cfg))["level"]
471
599
  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)
600
+ # Reference job count: the CPU-shaped cap, independent of memory and load. Workers per job
601
+ # come from it, so fewer jobs under pressure never mean more workers each.
602
+ ref = max(1, min(cpus // 4, cfg["maxParallelHeavyJobs"]))
479
603
  if level == "critical":
604
+ budget = max(0.0, reading["availableMb"] - min(cfg["reservedMb"], reading["totalMb"] * cfg["reservedPercent"] / 100.0))
480
605
  return {"mode": "sequential", "maxParallelAgents": 1, "maxParallelHeavyJobs": 0, "maxWorkersPerJob": 1,
481
606
  "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)
607
+ if level == "unknown":
608
+ # Never above what a measured calm machine gets (cpus // ref), halved: unknown is conservative.
609
+ agents, heavy, workers, budget = min(2, cfg["maxParallelAgents"]), 1, max(1, (cpus // ref) // 2), None
610
+ reasons.append("sem medição: assuma reduced")
611
+ else:
612
+ reserve = min(cfg["reservedMb"], reading["totalMb"] * cfg["reservedPercent"] / 100.0)
613
+ budget = max(0.0, reading["availableMb"] - reserve)
614
+ memory_agents = int(budget // cfg["perAgentMb"])
615
+ agents = max(1, min(memory_agents, cfg["maxParallelAgents"]))
616
+ memory_heavy = int(budget // cfg["perHeavyJobMb"])
617
+ heavy = max(1, min(memory_heavy, ref))
618
+ workers = max(1, min(cpus // ref, int((budget / ref) // cfg["perWorkerMb"])))
619
+ if memory_agents < cfg["maxParallelAgents"]:
620
+ reasons.append("memória: " + str(round(budget)) + " MB livres além da reserva, " + str(cfg["perAgentMb"]) + " MB por agente")
621
+ if memory_heavy < 1:
622
+ reasons.append("memória abaixo de um job pesado (" + str(cfg["perHeavyJobMb"]) + " MB): no máximo um, sem nada pesado em paralelo")
623
+ if level == "warn":
624
+ reasons += ["pressão de memória warn: paralelismo pela metade"] + pressure["reasons"]
625
+ cpu_reasons = (cpu_pressure or {}).get("reasons") or []
626
+ if cpu_level == "warn":
627
+ reasons += ["pressão de CPU warn: agentes, jobs e workers pela metade"] + cpu_reasons
628
+ if level == "warn" or cpu_level == "warn": # once, whichever axes are in warn
629
+ agents, heavy, workers = max(1, agents // 2), max(1, heavy // 2), max(1, workers // 2)
630
+ if cpu_level == "critical":
631
+ agents, heavy, workers = 1, 1, 1
632
+ reasons += ["pressão de CPU crítica: um agente, um job pesado e um worker por vez"] + cpu_reasons
501
633
  # 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"
634
+ # machine is struggling (memory, swap or CPU), "sequential" that there is room for one agent only.
635
+ struggling = level in ("warn", "unknown") or cpu_level in ("warn", "critical")
636
+ mode = "sequential" if agents <= 1 else "reduced" if struggling else "parallel"
504
637
  return {"mode": mode, "maxParallelAgents": agents, "maxParallelHeavyJobs": heavy, "maxWorkersPerJob": workers,
505
- "budgetMb": round(budget), "reasons": reasons}
638
+ "budgetMb": round(budget) if budget is not None else None, "reasons": reasons}
639
+
640
+
641
+ def low_priority(platform=None, which=None):
642
+ """The command prefix that runs a heavy job at low priority on this OS: (argv, text).
643
+
644
+ macOS: QoS clamp "utility" (taskpolicy -c utility) plus nice 10; ``taskpolicy -b`` is the
645
+ stronger option for jobs that can wait (efficiency cores and throttled I/O on Apple silicon).
646
+ Linux: nice 10 plus the lowest best-effort I/O class (ionice -c 2 -n 7) when ionice exists.
647
+ Windows: ``start "" /low /b /wait`` inside cmd. It changes only the job it prefixes, never a
648
+ host limit, and is empty when no known tool exists."""
649
+ platform = platform or sys.platform
650
+ which = which or shutil.which
651
+ if platform.startswith("win"):
652
+ argv = ["cmd", "/c", "start", '""', "/low", "/b", "/wait"]
653
+ return argv, 'cmd /c start "" /low /b /wait'
654
+ argv = []
655
+ if platform == "darwin" and which("taskpolicy"):
656
+ argv += ["taskpolicy", "-c", "utility"]
657
+ if which("nice"):
658
+ argv += ["nice", "-n", "10"]
659
+ if platform.startswith("linux") and which("ionice"):
660
+ argv += ["ionice", "-c", "2", "-n", "7"]
661
+ return argv, shlex.join(argv)
506
662
 
507
663
 
508
664
  def build(reading, cfg, meta, agents=None, cached=False, age=0):
509
665
  pressure = assess(reading, cfg)
666
+ cpu_pressure = assess_cpu(reading, cfg)
510
667
  total, available = reading.get("totalMb"), reading.get("availableMb")
511
668
  memory = {"totalMb": round(total) if total else None, "availableMb": round(available) if available is not None else None,
512
669
  "usedPercent": round(100.0 * (1 - available / total), 1) if total and available is not None else None,
513
670
  "swapTotalMb": round(reading["swapTotalMb"]) if reading.get("swapTotalMb") is not None else None,
514
671
  "swapUsedMb": round(reading["swapUsedMb"]) if reading.get("swapUsedMb") is not None else None,
515
672
  "cgroupLimitMb": round(reading["cgroupLimitMb"]) if reading.get("cgroupLimitMb") else None}
673
+ if reading.get("compressedMb") is not None:
674
+ memory["compressedMb"] = round(reading["compressedMb"])
516
675
  load = reading.get("load")
676
+ argv, prefix = low_priority(reading.get("platform") if reading.get("platform") in ("darwin", "linux") else None)
517
677
  return {"schema": SCHEMA, "generatedAt": now_iso(), "platform": reading.get("platform"), "source": reading.get("source"),
518
678
  "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}
679
+ "cpu": dict({"count": reading.get("cpus"), "load1": load[0] if load else None, "load5": load[1] if load else None,
680
+ "load15": load[2] if load else None}, **cpu_pressure),
681
+ "agents": agents, "recommendation": recommend(reading, pressure, cfg, cpu_pressure),
682
+ "lowPriorityPrefix": prefix, "lowPriorityArgv": argv, "thresholds": cfg, "config": meta}
522
683
 
523
684
 
524
685
  # --- cache ----------------------------------------------------------------------------------
@@ -556,12 +717,14 @@ def valid_reading(reading):
556
717
  return False # availableMb may be unmeasured (None) while totalMb is known: assess() reports unknown
557
718
  if not number(reading.get("cpus"), allow_none=False) or reading.get("osLevel") not in (None, "normal", "warn", "critical"):
558
719
  return False
559
- load, psi = reading.get("load"), reading.get("psi")
720
+ load = reading.get("load")
560
721
  if load is not None and not (isinstance(load, list) and len(load) == 3 and all(number(x, False) for x in load)):
561
722
  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
723
+ for key in ("psi", "cpuPsi"):
724
+ psi = reading.get(key)
725
+ if psi is not None and not (isinstance(psi, dict) and all(number(v, False) for v in psi.values())):
726
+ return False
727
+ return number(reading.get("compressedMb")) and number(reading.get("loadCpus"))
565
728
 
566
729
 
567
730
  def write_cache(home, reading):
@@ -633,76 +796,137 @@ def probe(home, processes=True, use_cache=False, platform=None):
633
796
 
634
797
  # --- presentation ---------------------------------------------------------------------------
635
798
 
799
+ def cpu_level(data):
800
+ return (data.get("cpu") or {}).get("level") or "normal"
801
+
802
+
636
803
  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"
804
+ """True when the machine needs attention: memory, swap or CPU pressure warn/critical, or a
805
+ non-parallel recommendation."""
806
+ return data["pressure"]["level"] in ("warn", "critical") or cpu_level(data) in ("warn", "critical") \
807
+ or data["recommendation"]["mode"] != "parallel"
639
808
 
640
809
 
641
810
  def gb(mb):
642
811
  return "?" if mb is None else ("%.1f" % (mb / 1024)).replace(".", ",")
643
812
 
644
813
 
814
+ def times_cpu(value):
815
+ return ("%.1f" % value).replace(".", ",") + "×"
816
+
817
+
818
+ # Under pressure the hint also carries the conduct rules (crow-behaviour references/resources.md),
819
+ # so a session without the skill loaded still reduces load the same way.
820
+ CONDUCT = (" Reduza a concorrência já, inclusive a planejada; um job pesado por vez, sem iniciar outro enquanto um roda "
821
+ "(deixe terminar o que está perto do fim), em prioridade baixa ({prefix}); não repita suíte/build já verificado "
822
+ "nesta revisão; validação ampla no CI do repositório quando houver, lendo o resultado antes de declarar verificado.")
823
+
824
+
645
825
  def hint(data):
646
826
  """One line for a prompt hook's additionalContext, or None when there is nothing to say."""
647
827
  if not alert(data):
648
828
  return None
649
- rec, memory = data["recommendation"], data["memory"]
650
- level = data["pressure"]["level"]
829
+ rec, memory, pressure = data["recommendation"], data["memory"], data["pressure"]
830
+ level = pressure["level"]
651
831
  if level == "unknown":
652
832
  head = "[crow-skills] memória: não medida nesta plataforma."
653
833
  else:
654
834
  head = ("[crow-skills] memória: pressão " + level + ", " + gb(memory["availableMb"]) + " GB livres de " +
655
- gb(memory["totalMb"]) + " GB.")
835
+ gb(memory["totalMb"]) + " GB")
836
+ swap = pressure.get("swap")
837
+ if swap and swap["level"] != "normal":
838
+ head += "; swap " + str(int(round(swap["usedPercent"]))) + "% em uso (" + swap["level"] + ")"
839
+ head += "."
840
+ cpu = data.get("cpu") or {}
841
+ if cpu_level(data) in ("warn", "critical"):
842
+ head += " CPU: pressão " + cpu["level"] + (", carga " + times_cpu(cpu["loadPerCpu1"]) + " por CPU"
843
+ if cpu.get("loadPerCpu1") is not None else "") + "."
656
844
  if rec["mode"] == "sequential" and rec["maxParallelHeavyJobs"] == 0:
657
845
  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
846
  else:
659
847
  body = (" Recomendação " + rec["mode"] + ": até " + str(rec["maxParallelAgents"]) + " agente(s) e " +
660
848
  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."
849
+ prefix = data.get("lowPriorityPrefix")
850
+ conduct = CONDUCT.format(prefix=("`" + prefix + "`") if prefix else "crow-skills resources --exec-prefix")
851
+ return head + body + conduct + " Detalhe: crow-skills resources --json."
662
852
 
663
853
 
664
854
  def segment(data, unicode=True):
665
- """Compact status line/tmux segment, empty when the machine is fine (e.g. 'mem ▲ 85% ×2')."""
855
+ """Compact status line/tmux segment, empty when the machine is fine (e.g. 'mem ▲ 85% ×2',
856
+ 'cpu ▲ 1.9× swap ▲ 92% ×3'): one part per axis under pressure, then the agents."""
666
857
  if not alert(data):
667
858
  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)
859
+ pressure = data["pressure"]
860
+ arrows = {"warn": "▲" if unicode else "^", "critical": "▲▲" if unicode else "^^"}
672
861
  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"])
862
+ parts = []
863
+ swap = pressure.get("swap") or {}
864
+ cpu = data.get("cpu") or {}
865
+ swap_on = swap.get("level") in arrows
866
+ cpu_on = cpu_level(data) in arrows
867
+ ram = pressure.get("ram", pressure["level"])
868
+ # Memory shows when it is the axis under pressure, or when nothing else explains the alert
869
+ # ("sequential" at normal pressure shows just the numbers, without an arrow).
870
+ if ram in arrows or not (swap_on or cpu_on):
871
+ used = data["memory"]["usedPercent"]
872
+ parts.append("mem " + (arrows[ram] + " " if ram in arrows else "") + (str(int(round(used))) + "%" if used is not None else "?"))
873
+ if cpu_on:
874
+ parts.append("cpu " + arrows[cpu["level"]] + (" " + ("%.1f" % cpu["loadPerCpu1"]) + times if cpu.get("loadPerCpu1") is not None else ""))
875
+ if swap_on:
876
+ parts.append("swap " + arrows[swap["level"]] + " " + str(int(round(swap["usedPercent"]))) + "%")
877
+ return " ".join(parts) + " " + times + str(data["recommendation"]["maxParallelAgents"])
675
878
 
676
879
 
677
880
  def render(data):
678
881
  memory, pressure, rec, cpu = data["memory"], data["pressure"], data["recommendation"], data["cpu"]
882
+ swap = pressure.get("swap")
679
883
  lines = ["memória: " + gb(memory["availableMb"]) + " GB livres de " + gb(memory["totalMb"]) + " GB" +
680
884
  (" (" + 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 "") +
885
+ (" · swap " + gb(memory["swapUsedMb"]) + "/" + gb(memory["swapTotalMb"]) + " GB" +
886
+ (" (" + str(int(round(swap["usedPercent"]))) + "%)" if swap else "") if memory["swapTotalMb"] else "") +
887
+ (" · comprimida " + gb(memory["compressedMb"]) + " GB" if memory.get("compressedMb") else "") +
682
888
  (" · limite cgroup " + gb(memory["cgroupLimitMb"]) + " GB" if memory["cgroupLimitMb"] else ""),
683
889
  "pressão: " + pressure["level"] + (" (" + "; ".join(pressure["reasons"]) + ")" if pressure["reasons"] else ""),
684
890
  "cpu: " + str(cpu["count"]) + (" · carga " + str(cpu["load1"]) + " " + str(cpu["load5"]) + " " + str(cpu["load15"])
685
- if cpu["load1"] is not None else "")]
891
+ if cpu["load1"] is not None else "") +
892
+ (" (" + times_cpu(cpu["loadPerCpu1"]) + " por CPU)" if cpu.get("loadPerCpu1") is not None else "") +
893
+ (" · PSI some " + str(cpu["psi"]["someAvg10"]) if cpu.get("psi") else "") +
894
+ " · pressão " + cpu_level(data)]
686
895
  agents = data.get("agents")
687
896
  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 ""))
897
+ def cpu_text(entry):
898
+ return (", " + ("%.0f" % entry["cpuPercent"]) + "% CPU") if entry.get("cpuPercent") is not None else ""
899
+ kinds = ", ".join(k + " " + str(v["count"]) + " (" + gb(v["rssMb"]) + " GB" + cpu_text(v) + ")" for k, v in sorted(agents["byKind"].items()))
900
+ lines.append("agentes: " + str(agents["count"]) + " CLI(s), " + gb(agents["rssMb"]) + " GB RSS" + cpu_text(agents) +
901
+ (" · " + kinds if kinds else ""))
690
902
  lines.append("recomendação: " + rec["mode"] + " · até " + str(rec["maxParallelAgents"]) + " agente(s), " +
691
903
  str(rec["maxParallelHeavyJobs"]) + " job(s) pesado(s), " + str(rec["maxWorkersPerJob"]) + " worker(s) por job")
692
904
  for reason in rec["reasons"]:
693
905
  lines.append(" - " + reason)
906
+ if data.get("lowPriorityPrefix"):
907
+ lines.append("prioridade baixa: " + data["lowPriorityPrefix"] + " <comando>")
694
908
  for error in data["config"]["errors"]:
695
909
  lines.append("config: " + error)
696
910
  return "\n".join(lines)
697
911
 
698
912
 
913
+ def exec_prefix_data(platform=None):
914
+ argv, prefix = low_priority(platform)
915
+ return {"platform": platform or sys.platform, "lowPriorityPrefix": prefix, "lowPriorityArgv": argv, "text": prefix}
916
+
917
+
699
918
  def main(argv=None):
700
919
  import argparse
701
920
  p = argparse.ArgumentParser(prog="crow_resources", description="Memória, pressão e paralelismo recomendado.")
702
921
  p.add_argument("--home")
703
922
  p.add_argument("--json", action="store_true")
704
923
  p.add_argument("--no-processes", action="store_true")
924
+ p.add_argument("--exec-prefix", action="store_true")
705
925
  args = p.parse_args(argv)
926
+ if args.exec_prefix:
927
+ data = exec_prefix_data()
928
+ print(json.dumps(data, ensure_ascii=False) if args.json else data["text"])
929
+ return 0
706
930
  home = Path(args.home).expanduser() if args.home else Path.home()
707
931
  data = probe(home, processes=not args.no_processes)
708
932
  print(json.dumps(data, ensure_ascii=False) if args.json else render(data))