./run dipartimento-it-agenti-che-dibattono
The IT department that argues with itself: three layers and a human in the loop
An IT department made of AI agents — NOC, SOC, SRE, Architecture Board — that watch a k8s cluster, rebut each other and prepare decisions for a…
- status
- wip
- project
- IT-department
- updated
- 2026-09-28
- tags
There’s a precise moment when a single operator realizes they’re too small for enterprise tools and too big to handle everything by hand: when, facing a “slow OSD” alert, they have to decide alone whether to buy a disk, move a workload or do nothing — and they have no one to argue with before deciding. IT-Department is born from there: an IT department made of AI agents that rebut each other, so the human receives an already-debated decision instead of a raw alarm.
The idea: not a script, an org chart
The system manages a hybrid self-hosted Kubernetes cluster — workers and Ceph storage on bare metal, control-plane on Hetzner VMs and services inside and outside the cluster. But the point isn’t what it manages — it’s how it thinks. Instead of a single omniscient agent, I modeled six teams with distinct mandates:
- NOC — eyes and ears: metrics, health checks, first response.
- SOC/CERT — security: CVEs, hardening, certificates, incident response.
- SRE — capacity planning, Ceph, DR, drafting of technical tickets.
- Architecture Board — the decision table: plays devil’s advocate on every proposal.
- Procurement — hardware market scouting.
- Project Office — migrations and change management.
The rule that holds it all together is a single one: the teams feed and contradict each other. If the NOC says “we need more RAM”, the SRE can retort “no, the bottleneck is the disks, look at Ceph’s latencies”. The Board weighs both positions. The human decides, informed.
Architecture: three layers, one clean separation
The backbone is a three-layer model, repeated obsessively in every instruction file:
- Layer 1 — Directive (what to do): SOPs in Markdown in
directives/. They’re natural-language instructions, the way you’d give them to a mid-level employee. Each team has its own. - Layer 2 — Orchestration (deciding): the LLM. It reads the directive, calls the right tools in the right order, handles errors. It’s the glue between intent and execution.
- Layer 3 — Execution (doing): deterministic Python scripts in
execution/. APIs, parsing, kubectl, files. Reliable, testable, fast.
The rationale is written in black and white in the CLAUDE.md, and it’s the sentence that justifies the whole structure:
If you do everything yourself, errors multiply. 90% accuracy per step = 59% success over 5 steps. The solution is to push complexity into deterministic code.
Translated: the LLM shouldn’t do the work, it should decide which deterministic script to invoke. A collect_ceph_status.py that parses ceph osd perf doesn’t get it wrong one time in ten the way a model “looking at” the output would. Intelligence is added on top of the deterministic data, it doesn’t replace it.
How a human gets woken up: two levels and a state machine
Here lies the heart. Not everything deserves to wake the operator. The orchestrator distinguishes two classes of action:
- L1 — autonomous: routine maintenance the system does on its own. A real example from the code: if the Velero namespace has more than 10
Succeededpods, it cleans them up without asking. - L2 — requires authorization: anything destructive or costly. Here the system doesn’t act: it prepares and flags.
And there’s a choice that says everything about the philosophy — pods in CrashLoopBackOff are detected and logged, never restarted automatically:
if "CrashLoopBackOff" in line:
# log only, non restart automatico
crashloop_pods.append({"namespace": ..., "name": ..., "restarts": restarts})
A pod that crashes 2400 times isn’t a restart problem: it’s a symptom. Restarting it automatically would hide the disease. Better an L2 alert.
But the piece I’m most proud of is the decision state machine (decision_manager.py). Every Board proposal becomes a JSON file with an explicit lifecycle:
NEW → NOTIFIED → SEEN → ACCEPTED/REJECTED → IMPLEMENTED
NOTIFIED → (5 giorni senza SEEN) → EXPIRED → re-analisi → NEW
The transitions are validated upfront — you can’t jump from NEW to IMPLEMENTED:
valid_transitions = {
"NEW": ["NOTIFIED"],
"NOTIFIED": ["SEEN", "EXPIRED"],
"SEEN": ["ACCEPTED", "REJECTED"],
"ACCEPTED": ["IMPLEMENTED"],
"REJECTED": [], "IMPLEMENTED": [],
"EXPIRED": ["NEW"],
}
The elegant detail is EXPIRED → NEW. If a decision sits for 5 days without the human looking at it, it doesn’t expire in silence: it gets re-analyzed with fresh data and re-proposed, with a refresh_count that grows. The system insists, but never decides in your place. It’s the opposite of alert fatigue: instead of 200 identical notifications, one decision that comes back more informed.
The honest gotcha: the debate isn’t (yet) code
Here’s the part a README would sell differently. The documentation says: “Orchestration: AGNO framework for multi-agent teams with roles and collaboration”. I ran grep -rn agno on the whole repo. Zero imports. Zero Agent(), zero Team().
The multi-agent framework, today, is aspirational. The debate between NOC and SRE doesn’t happen between two Python processes exchanging messages: it happens inside one LLM which, reading the various teams’ directives as a persona, plays both roles and produces a structured Decision Record. The “confrontation” is a prompt-orchestration pattern, not an architecture of concurrent agents.
Does it work? Yes, surprisingly well — because the real substance is in the deterministic scripts (those exist and run) and in the state machine (that exists and validates). The AGNO layer is the icing that will formalize the roles, but the cake already holds up without it. It’s exactly the kind of README/reality drift that must be said out loud: the value isn’t in the cited framework, it’s in the fact that the data is reliable and the human stays in the loop.
How it’s going: genuinely operational
It’s not a slideware POC. Phases 0–4 are complete and the department produces real output. A recent cycle turned up, among other things:
- NOC/SRE: 7 CRITICAL + 8 WARNING across 7 nodes; a node with Ceph storage ~19× slower than another (root cause: HDD under I/O stress, not CPU/RAM); a control plane at 2 vCPUs saturated at 100%; a worker with 283 pods, 55% of the cluster.
- Capacity: ~120 PVCs, ~5TB used out of ~43TB (12%) — no storage panic, the bottleneck is latency, not space.
- SOC: 6 CRITICAL and 124 WARNING (SSL flexible, no fail2ban on the bare metal, dozens of DNS records with the origin IP exposed), against TLS certificates all valid.
Every finding flowed into one of the 4 P0/P1 Decision Records that wait — patiently, with their refresh_count — for my decision. Continuous automation (collection agent via cron every 5 minutes, alerting) is the next incremental phase.
What I learned
- Reliability doesn’t come from the LLM, it comes from where you take it out. Every time I moved logic from the model’s reasoning to a deterministic script, the system became more predictable. The model should choose, not compute.
- The debate matters more than the answer. Forcing the system to always produce a “do nothing” option, a devil’s advocate and a dissenting opinion changed the quality of the recommendations more than any “be smart” prompt.
- Letting a decision expire is a feature, not a bug.
EXPIRED → NEWis the difference between a system that torments you and one that reminds you of things at the right moment, with updated data. - Write the drift out loud. Saying “AGNO is documented but not yet implemented” costs a moment of pride and buys all the credibility of the rest.
The system thinks, rebuts itself, and prepares. But the last word stays where it belongs: with the operator.
Updates
2026-09Backups, disks and patches: the week of silent things
This week the department didn’t debate. It put back together pieces that had broken silently. The worst discovery: for four Sundays, the WordPress autopatch had been rolling back updates on perfectly healthy sites.
- Autopatch smoke test — it called
/wp-login.phpon the Service without aHostheader and followed redirects. Multisite sites and those withWP_HOMEanswered with a 302 or a blank page, and three upgrades were rolled back every week. Nowwp_core_autopatch.pyreads the canonical host fromWORDPRESS_CONFIG_EXTRA, refuses redirects and flags every 3xx along with itsLocation. I also addedNOTIFY=0and--self-test. ADRY_RUNpass gave 7/7 healthy sites. - Velero in git — I exported the schedules from the cluster into
k8s/velero/schedules.yaml, which is now the reference. Ten namespaces with PVCs had no daily backup, mail included: if it broke, I risked losing up to 7 days of email. Now they’re covered. - trivy reports out of the backups — about 644 regenerable SBOMReports ended up in every nightly Velero LIST. For a 2 vCPU, 8 GB control plane, that was wasted load.
- Dead
mcimage — the MinIO disk monitor was stuck inImagePullBackOff: theminio/*repos on Docker Hub are gone and quay answersUNAUTHORIZED. I pinned it by digest tobitnamilegacy/minio-client, withMC_CONFIG_DIR=/tmp/.mcbecause it runs as a non-root user. - SMART for the Ceph disks — I turned off Ceph’s
devicehealthmodule: every night it hung four OSDs, which were then restarted by the liveness probe. In its place there’s asmartctl-exporterDaemonSet that reads 6 disks across 3 nodes every 5 minutes. It sends critical alerts for reallocated or pending sectors, rising CRC errors and temperatures above 50 °C.
args:
- --smartctl.device=/dev/sda
- --smartctl.device=/dev/sdb
- --no-smartctl.scan # never touch the rbd/nbd devices
- --smartctl.interval=300s
One point is still open: the MinIO client points to a frozen image. It needs to move to a copy in the internal registry, which doesn’t exist yet.
2026-09All green, nothing working
These two months had a single thread: components reporting success while doing nothing. A backup watchdog was OOM-killed on every run because it listed 8446 objects without paginating, and a failed list was indistinguishable from an empty one: it printed “all OK” and exited 0. A hardware-listing scraper had been broken for 36 days because upstream had moved to a nested schema and every get() returned the default, silently discarding 100% of the candidates; a security agent on one worker was Ready 2/2 with zero restarts and hadn’t emitted an event in 12 days. The root cause of the backups was dumber and more instructive: a node at 98% requested CPU against 8% real was rejecting the pods Velero schedules bypassing the scheduler — a practically idle node, and yet full. I paginated the lists, put the destructive powers behind flags that default to OFF, added canaries on the field that actually filters (not the one that’s convenient to check), and an alert on request saturation, which no rule was watching. Now the backups complete — including a schedule that had never once succeeded in its life — the storage metrics came back after 76 days, and the configuration that lived only in the cluster made it into the repo; it stays honest to say it: half of the restored rules are severity=warning, and a warning wakes nobody.


