fix(monitor): measure descendant CPU; untrack config; retune disk thresholds - #16
Conversation
The file holds machine-specific absolute paths and was tracked despite .gitignore's `config/*` intent, so every local path change churned in the diff. Untracked with `git rm --cached`; the file stays on disk and the default `--config` path is unchanged, so no invocation changes. Only config/config.yaml.example is version-controlled now, which means a fresh clone must copy it before first use. README and CLAUDE.md say so explicitly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018hHYmK3AS1NjyeSJiJK4uj
Three related corrections to the bottleneck verdict. monitor: psutil records the CPU-times baseline on the Process *instance*, and children(recursive=True) constructs fresh objects on every sample, so each descendant only ever produced its first (always 0.0) reading. Process instances are now cached by PID and evicted when the process exits. This reproduces whenever the real work runs in a child rather than in the launched process -- a Windows venv python.exe shim, or EddyPro spawning workers -- and yielded exactly the all-zero CPU column the process-tree fix was meant to eliminate. Verified against a real subprocess: 0.0% to ~85% of one core. analysis: a metrics file with none of the canonical process-tree columns is now classified UNKNOWN with an explanation, rather than summarising absent data as "no clear bottleneck, CPU 0.0%". Every pre-v2 file on disk hits this path. analysis: default disk thresholds raised from mechanical-disk to SATA SSD scale (450/250 MB/s, 20000 IOPS), since ordinary SSD runs were being reported as disk-bound. Adds a regression test in which the launched process idles and a child does the work, so the descendant-CPU defect cannot return unnoticed on platforms where the root happens to do the work itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018hHYmK3AS1NjyeSJiJK4uj
- CONFIG.md gains a suggested-limits table for NVMe, SATA SSD and mechanical drives, plus the PowerShell one-liner to find out which medium a drive letter is on. The defaults table is updated to the new SATA-scale numbers. - CONFIG.md states that config/config.yaml is untracked and must be copied from the example on a fresh clone. - config.yaml.example and examples/multi_year_config.yaml carry the concrete threshold blocks rather than a bare mention. - CHANGELOG entries under [Unreleased] for the monitor fix, the pre-v2 metrics handling, the threshold retune and the config untracking. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018hHYmK3AS1NjyeSJiJK4uj
CI failed on all four Python versions after config/config.yaml was untracked: seven tests invoked the CLI with no --config and relied on the repo shipping that file. It passed locally only because the file is still on disk here. Two separate defects behind it. A real CLI bug: main() validated the config file before dispatching, so `eddypro-batch` with no subcommand reported "Configuration file not found" instead of printing usage. On a fresh clone that is the very first command anyone runs. The no-command case now short-circuits to print_help(), and the dispatch chain becomes a table since argparse already restricts the remaining values. A test-design flaw the untracking exposed: tests must not depend on a machine-specific file. tests/test_cli.py and tests/test_cli_functions.py now pass config/config.yaml.example explicitly via a shared EXAMPLE_CONFIG constant. Verified by moving config/config.yaml aside and running the full suite, which is the state CI actually sees: 199 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018hHYmK3AS1NjyeSJiJK4uj
CI failed at `assert 19.8 > 20.0` and `assert 15.8 > 20.0`. Both figures are real, non-zero readings -- the monitor fix works -- but a throttled 2-vCPU shared runner does not reach 20% of a core on this workload, so the threshold was measuring the runner rather than the code. The defect being guarded against produces exactly 0.0 on every sample, so magnitude is the wrong discriminator. Assert instead that CPU registered at all (> 1.0, a 15x margin below the slowest observed real reading) and, for the descendant test, that at least a third of samples recorded child CPU -- the defect records none. Verified by reintroducing the defect (appending the fresh child object rather than the cached instance): both tests still fail, at `assert 0.0 > 1.0`. The relaxed bounds lose no discriminating power. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018hHYmK3AS1NjyeSJiJK4uj
|
CI is green on 3.10, 3.11, 3.12, 3.13 and the security job. Two rounds of CI failures were fixed after the PR was opened; both were real, neither was a flake. 1. Seven tests invoked the CLI with no
Verified by moving 2. CI failed at Since the defect produces exactly This was checked for lost discriminating power: the defect was reintroduced (appending the fresh Final state: 199 passing, coverage 75.8%, ruff / black / mypy / bandit clean. The one item from the PR description that remains outstanding is verification against a real EddyPro run — still the only thing that would exercise the descendant-CPU path against actual |
Summary
Untracks
config/config.yaml, retunes the bottleneck thresholds for SSDstorage, refuses pre-v2 metrics files instead of misreading them — and fixes a
second, still-live instance of the original "monitor reports 0.0" bug that
survived PR #15 and was found while verifying the above.
Problem
Three requested items, plus one found along the way.
config/config.yamlwas tracked despite.gitignore'sconfig/*intent, so machine-specific absolute paths churned in every diff.
Disk thresholds assumed a mechanical disk (
disk_high_mb_per_s: 100,disk_high_iops: 1000). The data actually lives on a Samsung 870 EVO — aSATA SSD — so ordinary runs would be reported as disk-bound.
Pre-v2 metrics files were misread, not rejected. They lack every
canonical process-tree column, so
analysis.pysummarised absent data aszeros and reported, with full confidence:
Confirmed against a real file on disk (
GL-NuF/2024/metrics_fcc.csv, 2283rows). That is worse than a crash — it is a wrong answer that looks right.
The monitor still reported 0.0% CPU for every child process. Found when
tests/test_analysis.py::TestRealWorkloadMonitoringfailed duringverification; it fails on
maintoo, so this is not a regression from thechanges here.
Approach
The descendant-CPU defect (item 4)
psutil records the CPU-times baseline on the
Processinstance.children(recursive=True)constructs fresh objects on every sample, soeach descendant only ever produced its first — by definition
0.0— reading:PR #15 fixed which processes were walked; it did not fix the fact that the
walk discarded the state the CPU reading depends on. Where the root process
happens to do the work itself the aggregate still looked right, which is why
CI stayed green.
It surfaces when the launched process is a stub that re-execs into a child —
here, a Windows venv
python.exeshim. That is the same shape aseddypro_rp.exespawning workers, which is the case this monitor exists tomeasure.
Fixed by caching
psutil.Processinstances by PID in_iter_tracked(), witheviction when a process exits (cumulative I/O is already retained separately in
_io_by_pid, so nothing is lost).Measured on a real subprocess, before and after:
cpu_percent_of_coreread_mb_per_s/write_mb_per_snum_processesChanges Made
monitor.py:_proc_cache: dict[int, psutil.Process];_iter_tracked()reuses cached instances via
setdefaultand evicts exited PIDs from both thecache and
_primed_pids.analysis.py:CANONICAL_COLUMNS/LEGACY_COLUMNS;analyze_rows()returns
UNKNOWNwith a schema-mismatch explanation when no canonical columnis present, distinguishing "written by a pre-v2 monitor" from "missing every
process-tree column".
analysis.py:DEFAULT_THRESHOLDSdisk limits raised to SATA SSD scale—
disk_high_mb_per_s100 → 450,disk_moderate_mb_per_s50 → 250,disk_high_iops1000 → 20000 — with inline NVMe and mechanical figures.config/config.yamluntracked viagit rm --cached. The file stays ondisk at the same path and the default
--configvalue is unchanged, so noinvocation changes; only
config.yaml.exampleis version-controlled now.CONFIG.mdgains a per-medium threshold table (NVMe / SATA /mechanical) and the
Get-PhysicalDiskone-liner to identify a drive; bothCONFIG.mdandREADME.mdstate that a fresh clone must copy the exampleconfig;
CLAUDE.mdrecords that the file is untracked and must not bere-added with
git add -f.Tests
199 passing, up from 196; coverage 75.9%.
TestLegacySchema— a real-shaped pre-v2 CSV must classify asUNKNOWNwithits 10 rows still counted, and the current schema must not be mistaken for it.
test_cpu_is_measured_for_descendants_not_just_the_root— the launchedprocess deliberately idles while a child does all the work, so the
aggregate CPU can only be non-zero if descendant instances are cached. This
is the gap that let the defect through CI: the existing real-workload test
passes on any platform where the root does the work itself.
run is no longer disk-bound; the NVMe override case now uses 600 MB/s against
3000/1500 limits).
Manual verification:
validatepasses against both the default config path andexamples/multi_year_config.yaml;run --dry-runcompletes and writes itsmanifest and report;
config/config.yamlconfirmed byte-identical to apre-change backup and absent from
git ls-files.Risks
as
DISK_THROUGHPUTon SSD storage will now likely reportNONEorCPU.This is the intended correction, but past HTML reports and the new ones will
disagree. Override per-machine via
performance_thresholds:if the data sitson a mechanical drive.
UNKNOWNwill appear for historical runs. Any report regenerated from apre-v2
metrics_*.csvnow says it cannot classify. Correct, but it is avisible change from the previous (wrong) confident answer.
config/config.yaml. Anyone with an existingcheckout is unaffected — the file is already on disk and untouched — but a new
clone must
cp config/config.yaml.example config/config.yamlbefore thedefault
--configpath resolves. Documented in README and CONFIG.md.psutil.Processobjects for the run's duration.Bounded by the number of live descendants and evicted on exit, so it does not
grow with run length.
Rollback Plan
Each commit is independently revertable and scoped to one concern:
2ce019b— config untracking. Reverting re-adds the file to the index; theworking copy is unaffected either way.
0233df7— monitor and analysis fixes. Reverting restores the all-zerodescendant CPU column, so prefer overriding thresholds in config over
reverting this commit.
2a70924— docs and CHANGELOG only.Threshold behaviour can be restored without touching code by setting the old
values under
performance_thresholds:in config.Checklist
Notes
Two things worth raising separately from the diff:
The local
.venvhad black 25.9.0 whilepyproject.tomlpins>=26.5.1,<27— the same local/CI drift PR fix: repair performance monitoring, run manifest, and multi-year docs #15 bounded, just never syncedlocally. Resolved with
pip install -e ".[dev]"(now black 26.5.1, ruff0.16.4). No repo change needed; noting it so the next stale-venv confusion
is quick to diagnose.
A
--dry-runused for verification wroterun_manifest.jsonandrun_report.htmlunderD:\L1_processed\GL-Dsk\2025\ec_rflux_sc26\reports\.No raw or processed data was touched, but
CLAUDE.mdsays not to write underD:/L1_processedat all; a scratch output dir should have been used.Item 2 from the previous round — verification against a real EddyPro run —
is still outstanding, and matters more now: a genuine multi-process run is
exactly what would have exposed the descendant-CPU defect.
🤖 Generated with Claude Code
https://claude.ai/code/session_018hHYmK3AS1NjyeSJiJK4uj