This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady
There is a particular kind of silence that falls over an old laptop. The fan spins up, the cursor starts to stutter, and you already know what you'll find when you open top: one process, sitting at 100%, wearing an entire CPU core like a hat.
The usual answers are all a little violent. kill -9 and hope nothing important was in there. nice and hope it helps. Close the lid and sigh.
This week's theme is slow and steady, and it gave me an idea for a fourth option: don't kill the greedy process. Slow it down. Give it a smaller slice of the CPU, let it keep working, and hand the slice back once it calms down.
So I built a sloth.
What I Built
Sloth Daemon is a small Linux daemon, written in Rust, that:
-
Watches per-process CPU time using an eBPF probe inside the kernel (no polling
/procin a loop). - Gathers evidence about every process that stays busy: how long, how spiky, what kind of program it looks like, how much pressure the whole system is under.
- Judges the process, either with a built-in rules engine or with a tiny local LLM (via Ollama) that reads the evidence and gives a second opinion.
-
Slows it down with cgroup v2
cpu.max, and puts it back when it calms down or a timer runs out.
It never kills, signals or renices anything. The only things it ever touches are a CPU quota file and the list of processes in a cgroup.
It's for anyone with a Linux machine that can't afford to lose cores to a runaway job: old laptops (mine is a decade-old Lenovo G50-80 running Arch), home servers, dev boxes where a forgotten build loop likes to eat the afternoon.
Here's the whole thing in one picture:
KERNEL sched_switch tracepoint --> per-task CPU time map (eBPF, aya)
|
USER SPACE v
sampler --> CPU% per process, every 2 s
|
evidence broker --> ProcessBrief (trend, burstiness, category, system pressure, rule verdict)
|
classifier --> rules only (default) OR local LLM reads the brief (Ollama)
|
policy gate --> deterministic: protected list, thresholds, hysteresis, caps, TTL
|
cgroup manager --> /sys/fs/cgroup/sloth-daemon/pid-N/cpu.max (and back again)
A word on the AI, because I want to be honest about it. In the default mode the "judge" is the rules engine, so Sloth Daemon works on a machine with no model at all. In LLM mode, a small open-weight model (I tested gemma3:1b, qwen2.5:0.5b, about 850 MB & 400 MB respectively) reads the evidence brief and returns a typed verdict. Crucially, the model can confirm a throttle or veto one, but it can never raise one on its own. More on why in a moment.
Demo
If you can't watch a video, here is what the real logs looked like when I ran Sloth Daemon as a systemd service. A yes > /dev/null loop pinned a core. Thirty seconds of sustained load later:
Policy decision pid=268011 comm=yes cpu_percent=101.2 classification=Runaway confidence=0.85
action=Throttle { pid: 268011, quota_pct: 20 } reason=valid runaway workload justifies throttle
Process throttled successfully pid=268011 comm="yes" quota_pct=20
leaf=/sys/fs/cgroup/sloth-daemon/pid-268011
And the kernel agreeing with it:
$ cat /sys/fs/cgroup/sloth-daemon/pid-<PID>/cpu.max
20000 100000
$ top -b -n 2 -d 2 -p <PID> | tail -2 # from an earlier manual run
184056 user 25 5 6108 2436 2188 R 20.0 0.0 0:55.22 yes
One core's worth of fury, politely reduced to a fifth. When I killed the loop, the process went idle, and after a cool-down the daemon released it and removed the cgroup:
Policy decision pid=268011 comm=yes cpu_percent=0.0 classification=Unknown confidence=0.0
action=Release { pid: 268011 } reason=calm for cool-down period below 50% quota
Process released from throttle pid=268011 comm=yes
Code
Moeen-19
/
sloth-daemon
eBPF-powered, AI-assisted CPU governor for Linux. Gracefully throttles runaway processes using cgroup v2 without killing them. Built in Rust.
Sloth Daemon: The AI-Assisted CPU Governor
Make greedy processes sloth-like, not dead. Sloth Daemon watches CPU usage with an eBPF probe, gathers local evidence about each busy process, optionally asks a small local LLM (Ollama) to judge it, and gently slows genuine runaways with cgroup v2 CPU limits. It never kills anything.
What is Sloth Daemon
A runaway browser helper, a stuck script, or a forgotten background job can pin a CPU core and make your machine crawl. Sloth Daemon observes per-process CPU time directly in the kernel (no /proc polling), builds a compact evidence brief for every process that stays busy, and decides whether it is a runaway or legitimate heavy work like a compile or a video encode. Runaways get a CPU cap. When the process calms down or a time limit expires, the cap is lifted.
Everything runs on your machine. In the default mode there is…
To try it safely (dry-run is the default, so nothing is written to your cgroups):
git clone https://github.com/Moeen-19/sloth-daemon.git
cd sloth-daemon
cargo +nightly build --package sloth-ebpf --target bpfel-unknown-none -Z build-std=core --release
cargo build --release -p sloth-daemon
sudo ./target/release/sloth-daemon --check
sudo ./target/release/sloth-daemon --config sloth.toml --debug
The README has the full setup, the config reference, and the one-command end-to-end test scripts.
How I Built It
Chapter 1: the kernel counts, user space decides
The probe is deliberately tiny. It hooks the sched:sched_switch tracepoint, and every time the scheduler swaps a task off a CPU, it adds the time that task just spent running to a per-task counter in an eBPF map. A second tracepoint on process exit cleans the entry up. That's all. No thresholds, no strings, no decisions, just counting. Everything smart lives in user space, where I can test it without a kernel.
The first small drama: tracepoint fields live at kernel-specific byte offsets. My coding agent proposed prev_pid at offset 24 and next_pid at 56 "from memory". They were right, but a wrong offset still loads and runs and silently reports garbage, so I made it read the real format file on my machine and checked the numbers myself. (They were right. I still check.)
There was a second trap hiding in the probe. When it attaches, no CPU has a recorded "start time" yet, so the very first switch on each CPU could charge a bogus enormous duration to some innocent process. The fix was a rule: no valid start time, no accounting.
Chapter 2: slowing a process without hurting it
cgroup v2 lets you cap a group of processes with one line in one file. Writing 20000 100000 to cpu.max means: 20 milliseconds of CPU per 100 millisecond period, i.e. 20% of one core. Moving a process into the group is a write of its PID to cgroup.procs.
Simple, and easy to get dangerously wrong, so the throttle procedure is strict about order:
- Check the protected list first (PID 1, the daemon itself, kernel threads,
systemd,sshd, display servers, audio,ollama...). No amount of AI enthusiasm can override it. - Record the process's original cgroup before moving it, so release is exact.
- Write
cpu.maxbefore moving the process, so there is never a moment where it sits in the new group unthrottled. - If anything fails halfway, roll back.
- Every throttle has a floor (never below 5% of a core), a cap on how many processes can be throttled at once, and a time-to-live.
-
dry_run = trueis the default. Nothing is written until you say so.
If the daemon is killed with kill -9 mid-throttle, the next start cleans up the leftover cgroups and moves the orphaned process to the root cgroup. (Its original cgroup was only remembered in memory, so it goes to the root. I tested this with a script that crashes the daemon on purpose.)
Chapter 3: the brain (and why the model only gets a veto)
A process hitting 100% isn't automatically a villain. A compiler at 100% for ten minutes is doing its job. A browser helper stuck in a loop is not. CPU percentage alone can't tell them apart, so before anything is classified, a deterministic evidence broker assembles a compact ProcessBrief:
- CPU now, one-minute average, peak, trend (rising, flat, falling) and burstiness (steady or spiky)
- How long it's been busy, and how old the process is
- Name and parent category (compiler, encoder, browser helper, shell...)
- Thread count, memory size bucket, whether it belongs to a user session or a system service
- System-wide CPU pressure and whether this process is the top consumer
- A preliminary verdict from your allow and deny lists
Privacy is part of the design: the brief contains only these derived facts. No command lines, no environment variables, no file paths, no usernames. A test fails the build if any of those leak into the prompt.
In rules mode, the preliminary verdict is the answer. In LLM mode, the brief goes to a small local model as compact JSON, and the reply must match a strict JSON schema. Anything malformed, out of range, or late becomes unknown, and unknown means "do nothing".
To power this LLM mode, the daemon uses Google’s Gemma (gemma3:1b) running locally via Ollama. Because the daemon needs to parse a compact JSON evidence brief and return a strict, typed verdict in seconds—even on an aging, resource-constrained CPU—lightweight open-weight models are essential. Gemma delivers fast, low-overhead inference entirely on-device, ensuring that your process names, performance metrics, and system data never leave your machine while still giving the rules engine a smart, second-opinion veto.
Then comes the policy gate, a pure function with table-driven tests. A throttle needs all of: busy above the threshold for the sustained window, not protected, a runaway or background verdict at 0.8 confidence or more, and room under the cap.
The golden rule I designed around: the AI proposes, the gate disposes. A 0.5B model will sometimes be confidently wrong. If it could start throttles, a hallucination would be a real action on a real process. Instead it can only agree with the rules or overrule them toward "leave it alone". The worst case for a bad answer is a runaway process stays a runaway a little longer.
How fast is a tiny model on a decade-old dual-core? In my live test, Ollama's own log shows two chat requests answered in 11.4 s and 9.9 s. That's slow for a chatbot and completely fine for a daemon that asks once per program name and caches the answer.
Chapter 4: building it with a coding agent and a pile of Markdown
I built Sloth Daemon with a coding agent CLI, and the project's real source of truth is five Markdown files:
-
blueprint.md: the architecture, data contracts, six phases with exit checks, and a Definition of Done checklist. -
instructions.md: the rules of engagement. One phase at a time, stay in scope, never kill, neversudowithout asking, nounwrap()on runtime paths, keep your reports short. - Three skill files (
ebpf-probe-builder,cgroup-enforcer,ollama-bridge): focused how-to guides for the three risky areas. Each one lists the "do not"s I'd least like to discover at runtime.
(I drafted these with the help of an AI assistant, then edited them as the project taught me what they were missing.)
Every phase followed the same ritual: the agent reads the files, posts a short plan, and waits for my "go". Reviewing five-line plans caught real design bugs before they became code. The best one: the plan released a throttled process once its CPU dropped below a threshold. But a process throttled to 20% measures about 20%, so it would have been "calm" the instant it was throttled and released immediately, forever flapping. The fix was to define calm relative to the quota (below half of it for five samples in a row).
Midway through, my agent's weekly quota ran out and I had to switch accounts. Because the project's memory lived in the repo (the blueprint, the instructions, the code), the new session picked up from files, not from a chat history. Boring Markdown turned out to be the best save file I've ever had.
Chapter 5: trust nothing that says "success"
Slow and steady, it turns out, mostly means slow and suspicious. These are the bugs that tests and scripts caught:
The silent hog. My first real run: yes burned 100% of a core for two and a half minutes and the daemon did nothing. Worse, its decision logs just stopped. As far as I could tell, a single missed sampling tick was wiping the "busy for 30 seconds" counter, so it kept restarting. The fix was a one-tick grace period. After it, the same test throttled the process 32 seconds in.
The test script that lied. The first end-to-end script I ran reported two failures that weren't real: tracing writes ANSI colour codes even into a file, which broke my grep, and the last line of top output is blank. Another time, a test script printed "completed successfully" while the actual check above it said "Cgroup path not found". Lesson learned: scripts must fail on the thing they check, not on the thing they ran.
The model that may or may not have been called. My live LLM test passed, but the "verdict" looked identical to what the rules would give. Did the model actually answer? I didn't trust the daemon's own logs; I asked the server. journalctl -u ollama showed two POST "/api/chat" requests with status 200. Only then did I write "tested live" in the README.
The service I thought I'd installed. I rewrote the systemd unit to run with only two capabilities (CAP_BPF and CAP_PERFMON), tested it, got a pass, and then ran systemctl show sloth-daemon -p CapabilityBoundingSet. It listed four. The test had run against the old file. I installed the new unit, reran, checked again (cap_perfmon cap_bpf, no parse errors), and only then believed the result. The journal also showed a unit-file mistake: systemd refuses to parse a setting with a trailing comment on the same line, logs a warning, and ignores it.
None of these were exotic. All of them would have shipped if I'd taken a green checkmark at face value.
Why Does Open Innovation Matter?
A daemon that decides which of your processes to slow down is an uncomfortable thing to trust, and open source is the reason I trust it:
-
Everything that touches your machine can be read. eBPF programs, cgroup writes, the policy gate: it's all plain Rust, with a rule file and tests that say exactly what it will and won't do. A closed binary asking for
CAP_BPFis a hard sell; a short repo is an easy one. - Your process list stays yours. Process names reveal a lot: which editor, which games, which work tools. With a local open-weight model, no process data ever leaves the machine. In rules mode there's no network at all.
-
Swappable models. The model is one line in
sloth.toml. Try a different one with./scripts/llm-check.sh <model>, and nothing else changes. When a better small model is released next month, the daemon doesn't care. - No recurring cost, and it works offline. It runs on a decade-old laptop. No API key, no usage meter.
- Built on a stack of open work. The Linux scheduler's tracepoints, eBPF, aya, cgroup v2, Ollama, an open-weight Qwen model, Rust. None of this project exists without people sharing their work for free.
My Agent Session
Here's the shape of the prompt I used to start each phase, trimmed down. Short prompts and a plan-first rule kept both the agent's output and my review time small:
Sloth-daemon, Phase N: <name>. Earlier phases are done and verified; don't redo them.
Be brief: short messages, no recaps.
1. Read blueprint.md (Phase N), instructions.md and the relevant skill file. Check repo state.
2. Send me a short plan (5 lines max) plus anything ambiguous. Wait for my "go".
3. Build only the Phase N tasks. No new crates, no sudo, no dry_run = false without asking.
4. Run cargo fmt, clippy -D warnings and test. Fix problems yourself.
5. Final reply, 10 lines max: what works, what failed, and the exact commands I must run.
Then stop.
You can check out the commit history on GitHub to see how the agent built the project phase by phase.
The Lazy Payoff
The point of a sloth is that it saves you effort. The effort I want to stop spending is the one where I notice the fan noise, alt-tab to a terminal, run top, work out which process is the problem, decide whether it's safe to touch, and deal with it by hand.
With Sloth Daemon in the background that loop becomes: a noisy process is caught after 30 seconds, judged, slowed to a fifth of a core, and released when it calms down. The decision shows up as one readable log line. I can carry on working; the machine stays responsive; nothing got killed.
I should be straight about how far this has been proven. I've run it through scripted tests (a busy loop, a crash, a service restart, a model call), not through weeks of daily use. The default is dry-run for exactly that reason: watch what it would do for a while before you let it act.
What It Doesn't Do (Yet)
- CPU only. No memory, disk or network throttling.
- Pure cgroup v2 Linux only; no cgroup v1 or hybrid setups.
- A 0.5B model has weak judgement. The evidence brief and the veto-only design are what make it usable, and bigger models are a config change away.
- Tested on one machine (Arch Linux, kernel 7.2.11, a decade-old laptop). If your setup behaves differently, the journal will say which operation failed.
One last thing
A sloth survives by not spending energy it doesn't need to. It moves slowly, steadily, and mostly stays out of trouble. I think that's a good design principle for system software too: don't be dramatic, don't kill what you can calm down, and check your own work twice.
Thanks for reading. If you've got an old laptop and a runaway process, I'd love to hear whether it works for you.
Prize Categories
This project is submitted for the Best Use of Gemma partner category, utilizing Google's open-weight gemma3:1b model running entirely locally via Ollama to power privacy-first, on-device CPU process classification.
Top comments (0)