Skip to content

feat(doctor): surface the machine beneath the nodes (backend#2221) - #541

Merged
shujaatTracebloc merged 7 commits into
developfrom
feature/2221-doctor-machine-chain
Aug 24, 2026
Merged

feat(doctor): surface the machine beneath the nodes (backend#2221)#541
shujaatTracebloc merged 7 commits into
developfrom
feature/2221-doctor-machine-chain

Conversation

@shujaatTracebloc

@shujaatTraceblocshujaatTracebloc commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What

Adds a Machine capacity check to tracebloc doctor: the four-level chain host → Docker VM → node capacity → unrequested, plus the one invariant that makes the double-count legible:

sum(node capacity) ≤ VM capacity

Uncapped k3d violates it by exactly the node count. This is the doctor half 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:

LayerCPUMemory
Host1016.00 GiB
Docker Desktop VM107.75 GiB
k3d node containers ×210 each8126672Ki each — byte-identical to the VM's MemTotal
What Kubernetes believes2015.50 GiB (2.000×)

So checkNodeFit can 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:

· FAIL Node capacity — no Ready node can fit a training job (needs cpu=2, memory=8Gi)
· 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.00× the memory this machine has, because the k3d node containers are
uncapped and each reports the whole VM

The two lines together tell the true story that neither tells alone. As a cross-check, 7.75 − 4.55 = 3.20 GiB requested 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:

  • non-k3d or mixed cluster — on EKS the nodes are machines and docker info on this laptop describes something unrelated; asserting from it would be worse than silence
  • unreadable VM — the VM is the level this check adds; guessing is what the bug already does
  • no Ready node
  • nodes reporting no capacity — "0 GiB claimed, all good" would be a green with nothing behind it

The 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 (3221225472 B) reported capacity 3221225Ki2.4% above the limit it came from. A strict sum > vm would therefore report correct capping as over-commit. 1.05 sits 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 allocatable honest 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:

mutationreddens
tolerance 1.05100the 2-node and 3-node over-count tests
allK3d always truethe non-k3d and mixed-cluster tests
count terminal pods as holding memorythe unrequested-level test

Full suite green. gofmt -s, goimports -local, staticcheck -checks all,-ST1005, ineffassign, misspell all clean locally.

TestRun_HealthyCluster's check count moves 9 → 10. Its fixture nodes are not k3d-named, so the new check reports StatusUnknown there — 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 doctor now 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,” renderHealth prints Warn (not Fail), and doctorVerdict is 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.

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

Copy link
Copy Markdown
ContributorAuthor

bugbot run

Comment threadinternal/doctor/machine.go Outdated
Comment threadinternal/doctor/machine.go
shujaat hasanand others added 2 commits August 20, 2026 16:53
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

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@shujaatTracebloc
shujaatTracebloc marked this pull request as ready for review August 24, 2026 06:47
@shujaatTracebloc
shujaatTracebloc requested review from LukasWodka and removed request for saadqbalAugust 24, 2026 07:07
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>
Comment threadinternal/doctor/machine.go

@LukasWodkaLukasWodka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment threadinternal/doctor/machine.go
…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

Copy link
Copy Markdown
ContributorAuthor

bugbot run

Comment threadinternal/cli/doctor.go

@LukasWodkaLukasWodka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

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:

  1. The test is now in the file's own shape. You asked for withDetail(allOK, "Machine capacity", StatusWarn, detail) — and it could not be written that way, because with/withDetail only mutate entries that already exist and "Machine capacity" was missing from the allOK fixture entirely. My version appended a result instead, which passed but hid a real gap: every "all healthy" case in this file described a shorter check list than doctor actually emits. The check is in the fixture now and both new tests use the idiom.

  2. The remedy names the durable fix, not just the workaround — "run one training at a time; to fix it for good, recreate the environment as a single-node one."

Deliberately not taken, and I want to flag the divergence rather than bury it: passing by["Machine capacity"].Remedy straight through. 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 plain-language fix is in the summary and the flag-level detail stays one --verbose away, where the granular Remedy already says it verbatim. The reasoning is recorded at the call site. Happy to pass it through if you would rather have the flags up front — it is a one-line change.

Also not needed: your StatusUnknown fallback. 92bb4d3 gave renderHealth a real StatusWarn arm using the p.Warnf that already existed, so the honest ⚠ is available — and Unknown would claim we could not check when in fact we did.

On the rest: agreed on af22d8f6, and thank you for chasing why the two gates cleared instead of writing up "the gate was armed with a backlog" — I nearly filed the same wrong conclusion before finding cli#544 on develop.

Re-requesting you now. Full suite green, format-verify passes, and the ⚠ arm is re-verified against the live 2-node edge (where the harder Node capacity Fail still correctly wins).

@shujaatTracebloc

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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
LukasWodka previously approved these changes Aug 24, 2026

@LukasWodkaLukasWodka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

Bugbot's last finding ("Warn closing claims no problems") was marked resolved but was not actually fixed in code — with ready = StatusWarn the closing switch still fell into the Unknown arm and printed "No problems found, but some checks couldn't finish". Fixed properly in 8830913: doctorVerdict is now the four-valued doctor.Status it always secretly was, and the Warn case gets its own closing line ("Training will run, but doctor found a problem — the ⚠ above says how to fix it."), exit 0 preserved. TestDoctorVerdict pins all four arms; the copy-catalog mirror and zz-all-strings.golden follow.

@LukasWodkaLukasWodka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: → StatusUnknown

connected=OK, ready=Warn → not Fail, not both-OK, worseStatus = Warn → StatusWarn ✓. ready=UnknownworseStatus treats Unknown as non-worsening, so it is not Warn either and falls to defaultStatusUnknown ✓. 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 CITest, 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.

@LukasWodkaLukasWodka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

armprintsexit
pointerStale(remedy already printed above)return &exitError{exitChecksFailed} → 2
verdict == StatusFailsupport hint unless --diagnosereturn &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

Copy link
Copy Markdown
ContributorAuthor

@LukasWodka — your approval of 8513803a was dismissed 9 seconds after you submitted it, so this is sitting at REVIEW_REQUIRED / BLOCKED through no fault of the code.

The timeline is unambiguous:

07:34:58 reviewed state=dismissed
07:35:07 review_dismissed actor=shujaatTracebloc

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 8513803a, all 25 checks pass, zero unresolved threads, and Bugbot reports no new issues.

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 summarizeDoctor already document this exact defect class) is the reasoning I should have led with, and I have quoted it in the commit so the next person meets it there.

@shujaatTracebloc
shujaatTracebloc merged commit b64d8cf into developAug 24, 2026
28 checks passed
@shujaatTracebloc
shujaatTracebloc deleted the feature/2221-doctor-machine-chain branch August 24, 2026 07:45
LukasWodka added a commit that referenced this pull request Aug 24, 2026
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@shujaatTracebloc@LukasWodka