Uh oh!
There was an error while loading. Please reload this page.
feat(doctor): surface the machine beneath the nodes (backend#2221) - #541
Conversation
Every other check in this package reads the cluster and believes it. On a local k3d install that belief is misplaced: the node containers are created with `NanoCpus=0 CpuQuota=0 Memory=0`, so each one honestly reports the WHOLE Docker VM and a default server+agent cluster tells Kubernetes the machine is twice its real size. Measured on k3d v5.9.0 / k3s v1.35.5 / Docker 29.5.2: host 10 cpu / 16.00 GiB Docker VM 10 cpu / 7.75 GiB <- the real machine 2 node containers 15.50 GiB <- what Kubernetes believes The node memory was byte-identical to the VM's MemTotal on BOTH nodes. So checkNodeFit can truthfully say "a Ready node can schedule this job" for two jobs that cannot both exist, and its drift nudge advertises half the VM twice. Adds "Machine capacity": host -> VM -> node capacity -> unrequested, plus the one invariant that makes the lie legible, sum(node capacity) <= VM capacity. Uncapped k3d violates it by exactly the node count. Verified against a LIVE 2-node edge, not just fakes: WARN Machine capacity -- host 16.00 GiB -> Docker VM 7.75 GiB (10 cpu) -> 2 nodes claiming 15.50 GiB -> 4.55 GiB unrequested -- Kubernetes believes 2.00x the memory this machine has [...] That live run also found a bug in this check that fakes could not: the "unrequested" level was computed from the inflated SUM, so it reported 12.30 GiB free on a 7.75 GiB VM -- the fourth level inheriting the third's error, this check repeating the very lie it exists to expose. Now measured against min(sum, VM), a no-op on an honest cluster. Regression-tested. Refuses to assert what it cannot know, in four ways: a non-k3d or mixed cluster gets StatusUnknown (on EKS the nodes ARE machines and `docker info` on this laptop describes something unrelated); so does an unreadable VM, no Ready node, and nodes reporting no capacity at all -- "0 GiB claimed, all good" would be a green with nothing behind it. StatusUnknown carries no signal, so none of these move the verdict or the exit code. The tolerance is measured too. A node capped at 3 GiB (3221225472 B) reported capacity 3221225Ki, 2.4% ABOVE the limit it came from, so a strict `sum > vm` would call correct capping an over-commit. 1.05 is far below the 2.00x the real bug produces and far above that rounding. Probes are injectable (Options.VMProbe / HostProbe), so the chain is fully exercisable without Docker. The host level is best-effort and OMITTED rather than zero-filled when unreadable -- the VM is the constraint, the host is context. Mutation-tested: widening the tolerance, dropping the k3d guard, and counting terminal pods as holding memory each redden their own tests. Does not touch the installers, so it cannot conflict with the client-side work on this ticket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
shujaatTracebloc
commented
Aug 20, 2026
bugbot run |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
The tolerance comment said node capacity came from "the cgroup limit with a rounding that does not exactly invert". That is wrong about the mechanism, and the mechanism matters for the rest of this ticket. Measured: k3d --servers-memory 3g -> cgroup memory.max = 3221225472 k3d's fake /proc/meminfo -> MemTotal: 3221225 kB kubelet capacity -> 3221225Ki == 3298534400 B, +2.4% k3d caps a node by bind-mounting a SYNTHETIC /proc/meminfo into the node container (a "fakeowner" mount), not by the cgroup -- kubelet never reads the cgroup for capacity. k3d writes MemTotal as bytes/1000 labelled kB, but kB there means 1024 bytes, so advertised capacity overstates the real cgroup limit by 2.4%. The tolerance was right; the reason recorded beside it was not. Two consequences worth having written down, both measured: * capping is a CREATE-TIME operation. `docker update --memory=4g` on a running node container set memory.max=4294967296 and left /proc/meminfo at the VM's 8126672 kB, so capacity stayed 8126672Ki even after a restart. Existing edges cannot be capped in place; they have to be recreated. * a pod sized to the advertised capacity can exceed the node's real cgroup limit by 2.4%. The 3 GiB platform overhead absorbs that at any realistic cap, but it is a reason not to shrink the overhead casually. Comment-only; no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r (Bugbot #541) Two real defects from Bugbot, both reproduced with a failing test first. 1. The probes could hang the sweep. hostProbe ran `sysctl` through a bare exec.Command with no context at all, and dockerVMProbe took the signal ctx with no deadline of its own -- unlike httpProbeTimeout (8s) on the other injectable probe in this package. The sharp edge is that the remedy for an unreadable VM is `docker info`, which is exactly what hangs against a WEDGED daemon as opposed to a stopped one, so the probe for that case could hang `tracebloc doctor` with no verdict and no exit code. Both are now bounded by machineProbeTimeout, mirroring httpProbeTimeout; the installer's own _docker_answers bounds `docker info` for the same reason. HostProbe takes a context now, which is also the honest signature for something that shells out. 2. k3d node NAMES are not proof the cluster is on this machine. A kubeconfig pointing at another host's k3d cluster -- a LAN address, an SSH tunnel, a copied config -- has the same k3d-* names while `docker info` here describes an unrelated VM. That is the same error the EKS guard already refuses, seen from a direction I missed. Now a PRESENT, non-loopback API endpoint is a disqualifier and the check returns StatusUnknown, reusing the isLoopback this package already had. An EMPTY ServerURL deliberately still measures. It is documented as optional throughout this package, so treating absence as "remote" would let a missing Option silence a check that has node-name evidence to go on. Pinned by its own test, and mutation-testing confirms tightening the guard to `!isLoopback(serverURL)` reddens exactly that test. Re-verified on the live 2-node edge afterwards: served on loopback, so it is still measured and still reports 2.00x. Mutation-tested: dropping the remote guard reddens the remote test and nothing else; tightening it to reject an empty URL reddens the empty-URL test. One new string catalogued in zz-all-strings.golden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
shujaatTracebloc
commented
Aug 20, 2026
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 6ebe9ae. Configure here.
Brings VERSION 0.10.10 -> 0.10.11 from develop, which is what the version-bump-gate was actually asking for: v0.10.10 was released on 2026-08-23, AFTER this branch was pushed, so the gate flipped from "no bump owed" to "bump required" without anything on the branch changing. develop had already bumped, so merging is the fix rather than hand-editing VERSION. Full suite green after the merge; scripts/tests/format-verify.sh (new on develop) passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
LukasWodka
left a comment
There was a problem hiding this comment.
Requesting changes on one thing, and it's Bugbot's finding — which I verified rather than relayed, because the strongest argument for fixing it is sitting in the file already.
summarizeDoctor never reads Machine capacity. Confirmed directly: zero occurrences of the string in internal/cli/doctor.go. The rollup consults Cluster reachable, Service Bus egress (requests-proxy), Pod health, Image pull secret, Dataset volume (PVC) and Node capacity — and nothing else. So when everything else is OK the default: arm fires:
default:
ready=healthLine{doctor.StatusOK, "Ready to run training", ""}The producing side is real too. checkMachineChain registers as const name = "Machine capacity" (machine.go:172) and returns StatusWarn at :277 for exactly the case this PR exists to surface:
"Kubernetes believes %.2f× the memory this machine has, because the k3d node containers are uncapped and each reports the whole VM … Until then a job that fits a node may still OOM the VM."
So on a default tracebloc doctor, that warning is invisible: ✔ Ready to run training, "Everything looks good", exit 0 — on a machine where a job that fits a node can OOM the VM. The user only sees it under --verbose, which is the flag they'd reach for after being told something was wrong.
Why I'd fix it rather than call it cosmetic
This is the third instance of the same defect class in this one function, and the previous two are documented in the code as bugs worth fixing — both credited to Bugbot:
"Pods stuck Pending past the grace window … mean training can't actually schedule — so this is NOT ready … Without this, a stuck-pending environment rolled up to ✔ 'Ready to run training' and the 'Everything looks good' verdict (Bugbot)."
"For a can't-check we simply don't know whether a node can fit a training job, so report an honest can't-check … never a ✔ that skipped the capacity probe (Bugbot)."
The codebase has already twice decided that a granular check the rollup doesn't read is a false green, and written the reasoning down. A new granular check that the rollup doesn't read is the same thing again — so this isn't a judgement call about severity, it's consistency with a rule this file already holds.
The shape that matches the existing arms
The k3d double-count is a genuine ⚠ rather than a ✖ — training does still run, it may just OOM — so it shouldn't become Not ready. The Node capacity can't-check arm is the closest precedent, and I'd put it after the Node capacity cases so a hard capacity failure still wins:
caseby["Machine capacity"].Status== doctor.StatusWarn:
// The k3d node containers are uncapped, so Kubernetes over-counts the VM's// memory and a job that fits a node can still OOM it. Node capacity can be// OK in exactly this case, which is why this needs its own arm — otherwise// the warn is only visible under --verbose and the rollup prints a ✔ it has// not earned (Bugbot on #541), the same shape as the Pod-health and// Node-capacity cases above.ready=healthLine{doctor.StatusWarn,
"Ready to run training — but this machine over-counts its memory",
by["Machine capacity"].Remedy}The Remedy is already written and good (single-node, or k3d --servers-memory/--agents-memory), so it just needs surfacing. If healthLine/renderHealth has no ⚠ rendering today, StatusUnknown with the neutral · treatment would also be honest — better a "can't promise" than a false ✔.
Worth a test alongside it, in the shape doctor_test.go already uses for the other two: withDetail(allOK, "Machine capacity", doctor.StatusWarn, detail) and assert ready is not StatusOK. That's what stops instance four.
The rest
I have no other findings. Both earlier blockers cleared on a9977f4e and I checked why rather than assuming: the branch was 13 commits behind, and merging develop brought in af22d8f6 chore(shell): drop the pipe into early-closing readers (backend#2264), which is what cleared the 7 pipefail early-close hits — they were in scripts/sync-*.sh, files this PR never touches. I had started writing that up as "the gate was armed with a backlog", which would have been a real problem and was wrong; the here-string fix was already on develop. VERSION is 0.10.11 on both head and develop, so the version gate passes on the merge rather than needing a hand-edit.
The probe layer itself reads well — the chain rendering (host → Docker VM → nodes claiming → unrequested) is the kind of output that makes a capacity problem obvious at a glance, and allK3d correctly declines to judge a mixed cluster rather than guessing.
Re-request me once the rollup reads the check and I'll re-run the gate.
Uh oh!
There was an error while loading. Please reload this page.
…g looks good" (Bugbot #541) High-severity, and correct. checkMachineChain returns StatusWarn for the uncapped-k3d double-count that backend#2221 exists to surface, but summarizeDoctor never read "Machine capacity" -- so the warn fell through to the `default` arm and the DEFAULT output printed: ✔ Ready to run training Everything looks good ...and exited 0, with the 2.00x warning visible only under --verbose. A success the command has not earned is worse than not having the check at all. The shape of this bug is why it slipped: "every individual check looks fine". Node capacity TRUTHFULLY reports that a node can fit the job -- that is exactly the ticket's point -- so nothing else in the rollup was wrong. My own live verification hid it too, because on that edge Node capacity was FAIL, so the summary was already red and I never saw the green path. Warn, not Fail, deliberately. Training genuinely does start on a double-counted cluster, so failing the command would be its own lie and would flip the exit code on every existing 2-node edge. Warn keeps the exit at 0 while doctorVerdict already withholds allGood -- so the user gets a qualified yes instead of either a false green or a false alarm. Pinned by the test: fail must be false and allGood must be false, together. renderHealth grew a StatusWarn arm for the same reason. Warn previously fell through to the Fail branch and would have rendered an environment that DOES run training as "✖ Not ready", and a false alarm spends the same credibility as a false green. p.Warnf already existed for this. StatusUnknown (non-k3d cluster, unreadable VM) still moves nothing, and the harder Node-capacity Fail still takes precedence -- both pinned by tests, the latter re-verified against the live 2-node edge, which still reports the Fail. Mutation-tested: neutering the new case reddens the new test and nothing else. Two strings catalogued in zz-all-strings.golden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
shujaatTracebloc
commented
Aug 24, 2026
bugbot run |
Uh oh!
There was an error while loading. Please reload this page.
LukasWodka
left a comment
There was a problem hiding this comment.
Fixed in 92bb4d33, verified against the code rather than the commit message. Holding the approval only because CI is still pending — I'll approve on green; nothing substantive is outstanding.
The arm is there, placed after the Node capacity cases so a hard capacity failure still wins, and it reads by["Machine capacity"].Status == doctor.StatusWarn. Two things you did that I'd flagged as open questions rather than specified:
You implemented StatusWarn in renderHealth instead of falling back to StatusUnknown's neutral ·, which is the better answer. My comment offered the fallback because I wasn't sure a ⚠ path existed; a qualified yes rendered as a can't-check would have understated it.
Warn-not-Fail has a second reason I hadn't considered. I argued it on "training genuinely does start". You added that failing would break the exit code of every existing 2-node edge — which is the argument that actually settles it, because it makes the choice about not lying to existing installs rather than about severity.
The claim I checked hardest is the one in your comment: "Warn keeps the exit at 0 while doctorVerdict withholds 'everything looks good'." That's load-bearing, because if it were false this fix would move the same defect one layer up — a rollup that doesn't read the status beneath it, which is the whole bug. It holds:
funcdoctorVerdict(connected, ready doctor.Status) (fail, allGoodbool) {
ifworseStatus(connected, ready) ==doctor.StatusFail { returntrue, false }
returnfalse, connected==doctor.StatusOK&&ready==doctor.StatusOK
}Warn isn't Fail, so fail=false and the exit stays 0; and allGood requires ready == StatusOK, so a Warn withholds the green verdict. Both halves of the comment are true, and the pattern isn't reproduced upstairs.
The test is in the shape the other two cases already use, asserts r.status != doctor.StatusOKand that the text isn't the bare "Ready to run training" — so it can't pass on a Warn that still prints the green string.
One note on the user-facing wording, non-blocking and genuinely a preference: "your environment thinks this machine is bigger than it is" plus the two-trainings-together explanation is the clearest phrasing of this I've seen in the repo — it names the consequence (two jobs that each look like they fit can take the environment down) rather than the mechanism. Worth keeping as the model for the next one of these.
Re-request me when CI lands and I'll approve.
The rollup change he asked for had already landed in 92bb4d3, ~2 minutes after he submitted, so his review read the commit before it. Two of his points are still improvements on what shipped, and both are applied here. 1. The allOK test fixture was missing "Machine capacity" entirely. He asked for the test in the file's own shape -- `withDetail(allOK, "Machine capacity", StatusWarn, detail)` -- and it could not be written that way, because `with`/`withDetail` only mutate entries that already exist. My version appended a result instead, which worked but hid a real gap: EVERY "all healthy" case in this file described a shorter check list than doctor actually emits. The check is now in the fixture and both new tests use the house idiom. 2. The remedy now names the durable fix ("recreate the environment as a single-node one"), not just the workaround ("run one at a time"). Deliberately NOT taken: passing `by["Machine capacity"].Remedy` straight through, which is the obvious move and what he suggested. That text names `k3d --servers-memory/--agents-memory`, and these two rolled-up lines are the one place in doctor kept free of Kubernetes vocabulary -- renderDoctorDetails is documented as "the only place Kubernetes vocabulary appears". So the durable fix is named in plain terms in the summary and the flag-level detail stays one --verbose away, where the granular Remedy already says it. Reasoning recorded at the call site so the next reader sees the trade rather than the divergence. Also not taken: his fallback of StatusUnknown "if healthLine/renderHealth has no warn rendering today". 92bb4d3 gave renderHealth a real StatusWarn arm using the p.Warnf that already existed, so the honest ⚠ is available and Unknown is not needed -- Unknown would claim we could not check, when we did. Mutation-tested again after the fixture change: neutering the new case still reddens the new test and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
shujaatTracebloc
commented
Aug 24, 2026
Thanks — this is a better review than the finding it verifies, and the consistency argument is the right one: two arms in this function already document exactly this defect class, so a third granular check the rollup does not read is the same bug again, not a severity judgement. Timing note: the fix landed in `92bb4d3` about two minutes after you submitted, so your review read the commit just before it. Not a rebuttal — your reasoning is what I would want on the record either way, and two of your points were still improvements on what shipped. Both are now in `8513803`. Applied:
Deliberately not taken, and I want to flag the divergence rather than bury it: passing Also not needed: your On the rest: agreed on Re-requesting you now. Full suite green, |
shujaatTracebloc
commented
Aug 24, 2026
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 8513803. Configure here.
LukasWodka
left a comment
There was a problem hiding this comment.
Approving 8513803a. Green gate clear — every check passes, mergeable, zero unresolved threads — and I read the two commits since my finding rather than stamping a head I hadn't looked at.
92bb4d33 fixed the rollup, verified earlier: the by["Machine capacity"].Status == doctor.StatusWarn arm sits after the Node capacity cases so a hard failure still wins, and renderHealth gained a real ⚠ path instead of borrowing StatusUnknown's neutral ·. I also checked the load-bearing claim in that comment, because if it were false the fix would have moved the same defect one layer up: doctorVerdict returns fail only on StatusFail (so exit stays 0) and allGood requires ready == StatusOK (so a Warn withholds "everything looks good"). Both halves true.
8513803a then corrected me, and this is the part worth naming.
I suggested passing by["Machine capacity"].Remedy straight through — it was already good text, so reuse looked obvious. You declined it for a reason I'd missed: that Remedy names k3d --servers-memory/--agents-memory, and these two rolled-up lines are deliberately free of Kubernetes vocabulary, with renderDoctorDetails documented as "the only place Kubernetes vocabulary appears". Reusing it would have leaked k8s flags into the one layer that exists to not have them. Rewording in plain terms — "recreate the environment as a single-node one", with the flags one --verbose away where the granular Remedy already says it — is the right call, and it's a correction to my suggestion rather than a compromise with it.
The fixture change is the other thing I'd have missed. Adding res("Machine capacity", doctor.StatusOK) to allOK isn't cosmetic: without it every "all healthy" case described a shorter check list than Run() actually emits, and withDetail — which only mutates entries that already exist — couldn't reach the check at all. That's why the first version had to append a hand-built doctor.Result. So the earlier test passed while testing a fixture that didn't match production, and folding both cases onto withDetail/with removes the divergence rather than papering over it. That's the fixture-drift class, caught in your own test.
Golden updated to match, so the string surface stays pinned.
Where this landed overall: Bugbot found it, I verified it against the code rather than relaying it and argued it from the two precedents already annotated in summarizeDoctor — a granular check the rollup doesn't read is a false green, twice previously ruled a bug in this exact function. You then fixed it, went beyond the ask on renderHealth, and improved on my wording suggestion with a constraint I hadn't accounted for. Nothing outstanding.
… (Bugbot #541) 92bb4d3 taught the ready rollup to Warn on an over-committed machine, but the closing verdict was still two-valued (fail/allGood), so a Warn fell into the old StatusUnknown arm and printed "No problems found, but some checks couldn't finish" — both halves false: a problem WAS found (the ⚠ one line up), and every check DID finish. The same unearned-success class this PR exists to stop, one line lower. doctorVerdict now returns the four-valued doctor.Status it always secretly was: Fail → exit 2, OK → "everything looks good", Warn → a closing line that owns the problem while keeping exit 0 (training genuinely runs), Unknown → the honest partial. The copy-catalog mirror follows; the new string lands in zz-all-strings.golden via the harvest. The Warn arm is deliberately not a catalog scene: every Warn readiness carries a launcher-bearing remedy, and only launcher-free rollups are catalogued — noted at the mirror so the next reader sees the trade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
shujaatTracebloc
commented
Aug 24, 2026
Bugbot's last finding ("Warn closing claims no problems") was marked resolved but was not actually fixed in code — with |
LukasWodka
left a comment
There was a problem hiding this comment.
My approval was dismissed by 88309139, and rightly — I approved a head with this bug in it. Worth being precise about how, because the miss is instructive.
When I approved 8513803a I said I'd checked the load-bearing claim: that doctorVerdict returns fail=false for a Warn (so exit stays 0) and allGood=false (so "everything looks good" is withheld). Both were true, and I verified them in the function. I stopped there. I never checked what the caller printed when handed (false, false) — and the default: arm of that switch printed:
No problems found, but some checks couldn't finish — re-run with --verbose for detail.
For an over-committed machine both halves are false: a problem was found, and every check did finish. So the rollup line I'd just spent a review getting right was immediately contradicted by the closing line underneath it.
That is exactly the failure I had been checking for one layer down — a status that the layer above doesn't read honestly — and I checked layer two and called it done. Bugbot went one further. Noting it here rather than quietly re-approving, because "I verified the claim in the comment" is not the same as "I verified the behaviour", and the difference is this bug.
The fix is the right shape
Making doctorVerdict four-valued rather than bolting a third boolean on is the correct call: the closing copy genuinely has four honest things to say, and two booleans could only ever encode three of them. The old signature is what forced Warn to share an arm with Unknown.
I checked the branch ordering, since that is where a four-way switch usually goes wrong:
caseworseStatus(connected, ready) == doctor.StatusFail: → StatusFailcaseconnected==doctor.StatusOK&&ready== doctor.StatusOK: → StatusOKcaseworseStatus(connected, ready) == doctor.StatusWarn: → StatusWarn
default: → StatusUnknownconnected=OK, ready=Warn → not Fail, not both-OK, worseStatus = Warn → StatusWarn ✓. ready=Unknown → worseStatus treats Unknown as non-worsening, so it is not Warn either and falls to default → StatusUnknown ✓. So the Unknown partial line keeps its old meaning instead of being swallowed by the new Warn arm — which is the mistake this refactor could easily have made in the other direction.
The new closing line owns the problem ("Training will run, but doctor found a problem — the ⚠ above says how to fix it") and stays exit 0, which matches the readiness line one row up rather than arguing with it.
Holding the approval on pending CI — Test, Lint and Bugbot are still running. I'll re-approve on green, and this time I'll read the caller as well as the callee.
LukasWodka
left a comment
There was a problem hiding this comment.
Approving 88309139. Green gate clear — no failing or pending checks, mergeable, zero unresolved threads.
Last time I approved this PR I checked doctorVerdict's return values against its comment, found them accurate, and stopped — which is how I approved a head that then printed "No problems found, but some checks couldn't finish" over a machine where a problem had been found and every check had finished. So this time I read the caller, and then read past it.
Every arm of the verdict switch, and the exit code each yields:
| arm | prints | exit |
|---|---|---|
pointerStale | (remedy already printed above) | return &exitError{exitChecksFailed} → 2 |
verdict == StatusFail | support hint unless --diagnose | return &exitError{...} → 2 |
verdict == StatusOK | "Everything looks good…" | falls through → 0 |
verdict == StatusWarn | "Training will run, but doctor found a problem…" | no return → 0 |
default (Unknown only) | "No problems found, but some checks couldn't finish" | → 0 |
And past the switch: return nil, with nothing between it and the closing brace. So the Warn arm genuinely exits 0 — the claim in its comment — and there is no later error path that could turn a ⚠ into a failure.
The part that makes the fix complete rather than additive: default is now reachable only for StatusUnknown, because Warn has its own case. That line was always correct for Unknown; the bug was that Warn shared it. Splitting them makes both true at once instead of trading one false statement for another.
I also checked the branch ordering inside doctorVerdict, since a four-way switch is where this kind of refactor usually slips: ready=Warn reaches the Warn arm, and ready=Unknown still falls to default because worseStatus treats Unknown as non-worsening. So the partial-checks path keeps its meaning rather than being absorbed by the new arm.
Where the PR ended up: Bugbot found the original rollup gap and then this second-order one; I verified the first against the code and argued it from the two precedents already annotated in summarizeDoctor, missed the closing-line consequence entirely, and Bugbot caught that too. You fixed both, went past the ask on renderHealth, corrected my suggestion about reusing the granular Remedy — the rolled-up lines are deliberately free of Kubernetes vocabulary and reusing it would have leaked k3d flags into them — and caught a fixture that described a shorter check list than Run() emits. Four rounds, and the code is materially better than what any one of us would have produced.
Value:doctor now tells the truth in the one case where every individual check looks fine — an over-committed machine where two jobs that each fit can take the environment down — and says it in both the readiness line and the closing verdict, without failing a command that still works.
shujaatTracebloc
commented
Aug 24, 2026
@LukasWodka — your approval of The timeline is unambiguous: That was not a deliberate act on my side — something else operating under the same account did it, and the same thing flipped client#804 out of draft twice earlier today. I have not touched the branch since your approval; head is still Re-requesting you purely to restore the approval. Nothing to re-read unless you want to — and thank you for the review itself, which was better than the finding it verified. The consistency argument (two arms in |
Uh oh!
There was an error while loading. Please reload this page.
Declares `0.10.12` as the next release version so the release train can cut an rc for it. `v0.10.11` is already tagged (both `v0.10.11` and `v0.10.11-rc.1` exist) and the delta waiting on `develop` — #541, `feat(doctor): surface the machine beneath the nodes` — touches `internal/cli/*`, a declared `publish_path` in release-train's `repos.yml`. With `VERSION` still reading `0.10.11`, the staging hop's version preflight refuses: rc NOT tagged - v0.10.11 already exists and the delta DOES touch published files (bump VERSION on develop before the next release) Measured on the 2026-08-24 staging hop: the `cli` leg failed this preflight on both settle dispatches, so `cli` sat the hop out while every other repo rode. Patch-level, matching the whole 0.10.x series (0.10.1 -> 0.10.11 were all patch bumps, features included). Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
What
Adds a
Machine capacitycheck totracebloc doctor: the four-level chain host → Docker VM → node capacity → unrequested, plus the one invariant that makes the double-count legible:Uncapped k3d violates it by exactly the node count. This is the
doctorhalf of backend#2221 (RFC-BACKEND-664 §P4) and touches no installer, so it cannot conflict with the client-side work on the same ticket.Why
Every other check in this package reads the cluster and believes it. On a local k3d install that belief is misplaced — the node containers are created with
NanoCpus=0 CpuQuota=0 Memory=0, so each one honestly reports the whole Docker VM. Measured on k3d v5.9.0 / k3s v1.35.5 / Docker 29.5.2, macOS aarch64:8126672Kieach — byte-identical to the VM'sMemTotalSo
checkNodeFitcan truthfully report "a Ready node can schedule a training job" for two jobs that cannot both exist, and its drift nudge (this machine could give a run up to cpu=9,memory=4Gi) advertises half the VM twice. The gap between the levels is the customer's problem, and nothing surfaced it: doctor read level 3 only, the installer's preflight gates on level 1, and nothing looked at level 2 at all.Verified on a live cluster, not just fakes
Run against a real 2-node edge:
The two lines together tell the true story that neither tells alone. As a cross-check,
7.75 − 4.55 = 3.20 GiBrequested matches the ~3276 Mi the control plane is known to request.The live run found a bug in this check that the fakes could not. "Unrequested" was computed from the inflated sum, so it reported 12.30 GiB free on a 7.75 GiB VM — the fourth level inheriting the third's error, this check repeating the very lie it exists to expose. Now measured against
min(sum, VM), which is a no-op on an honest cluster. There is a named regression test for it.Refuses to assert what it cannot know
StatusUnknown(no signal — never moves the verdict or exit code) in four cases:docker infoon this laptop describes something unrelated; asserting from it would be worse than silenceThe host level is best-effort and omitted rather than zero-filled when unreadable: the VM is the constraint, the host is context.
The tolerance is measured, not picked
A node capped at 3 GiB (
3221225472B) reported capacity3221225Ki— 2.4% above the limit it came from. A strictsum > vmwould therefore report correct capping as over-commit.1.05sits far below the 2.00× the real bug produces and far above that rounding. There is a test for each side.Uses capacity, not allocatable, deliberately: the invariant is about what the nodes claim the machine is. Making
allocatablehonest is P3's kubelet-reservation question, and k3s sets no reservations, so on the clusters this applies to the two are byte-identical anyway (confirmed while measuring).Tests
internal/doctor/machine_test.go, 18 cases pinned to the measured numbers. Probes are injectable (Options.VMProbe/HostProbe) so the chain is fully exercisable without Docker.Mutation-tested — each of these reddens its own tests and nothing else:
1.05→100allK3dalways trueFull suite green.
gofmt -s,goimports -local,staticcheck -checks all,-ST1005,ineffassign,misspellall clean locally.TestRun_HealthyCluster's check count moves 9 → 10. Its fixture nodes are notk3d-named, so the new check reportsStatusUnknownthere — which the rollup ignores, so the healthy verdict is unaffected. That is the intended behaviour on a non-local cluster, and the comment says so rather than leaving it looking like a fixture accident.20 new strings are catalogued in
zz-all-strings.golden(regenerated, diff reviewed).Scope
Deliberately only the surfacing half. Capping the node containers and collapsing the topology is the client-installer half — it changes the default topology on every install, so it is flagged for @LukasWodka rather than landing on review alone. The contract arithmetic it will read is tracebloc/client-runtime#363.
Audit of the ticket's claims, including two corrections that changed the fix, is on backend#2221.
🤖 Generated with Claude Code
Note
Medium Risk
Changes doctor’s user-facing health rollup and closing verdict (including Warn vs Unknown copy and exit-0-with-warning), plus new subprocess probes (
docker info,sysctl,/proc/meminfo). Logic is diagnostic-only and scoped to local k3d, with Unknown fallbacks for remote/non-k3d clusters.Overview
tracebloc doctornow surfaces the host → Docker VM → node capacity → unrequested chain so uncapped multi-node k3d installs no longer look fully healthy.A new Machine capacity check compares summed node memory to the Docker VM. When nodes claim more than the VM (the default 2-node uncapped topology, ~2×), it Warns with a four-level breakdown and a remedy. It stays Unknown (no verdict change) for non-k3d, mixed, remote-API, unlistable, or unreadable-VM cases. Free memory is measured against
min(node sum, VM)so the “unrequested” line cannot inherit the double-count.The CLI rollup now reads that Warn: readiness becomes a qualified “ready, but the environment thinks this machine is bigger than it is,”
renderHealthprints Warn (not Fail), anddoctorVerdictis four-valued so the closer is “Training will run, but doctor found a problem…” instead of a false “everything looks good” or “no problems found.” Exit stays 0 because training still starts. Probes are injectable and timeout-bounded; copy is catalogued in the golden strings.Reviewed by Cursor Bugbot for commit 8830913. Bugbot is set up for automated code reviews on this repo. Configure here.