feat(builder): diagnose a failed boot before changing the template
Validate game templates / validate (push) Successful in 21s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped

A failed boot went straight back to the model as "fix it", so it guessed:
one draft raised its memory three rounds in a row while the real cause, an
empty setting the server can't run without, stayed untouched.

Now a failed boot is investigated first:
- Evidence: the server's distinct error lines (timestamps and repeats
  removed), and a web search on the most telling one, with the top answer
  read through the reader.
- Diagnosis: the model states the cause, the evidence for it, its
  confidence and the exact fix. If unsure, it asks for up to 3 checks: a
  search, a page, the files the server writes (a first boot of the draft),
  or a boot test with settings or the startup command changed. Results go
  back to it, two rounds at most.
- Fix: the model applies exactly the diagnosed fix and nothing unrelated.
- Attempt log: each cause, fix and outcome. A fix that changed nothing
  rules its cause out; if the diagnosis returns to a ruled-out cause, the
  build stops instead of guessing, and the PR lists what the builder found.

Boot rounds go up to 5, since rounds now end on a ruled-out cause rather
than a fixed count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gregandClaude Opus 5.5 committed 2026-10-25 23:15:33 -04:00
1 parent 4874868ce6
commit 487dfbdf68
3 files changed
+542 -3

No files matched your search

+29 -3
View File
@@ -27,6 +27,8 @@ type buildResult struct {
Verified []string `json:"verified"`
Problems []string `json:"problems"`
Summary string `json:"summary"`
// What the builder diagnosed after each failed boot, the fix it made, and how that went
Diagnoses []attempt `json:"diagnoses,omitempty"`
}
const defaultAgentImage = "www.gitea.gbreed.com/gbreed-public/gbr-node-agent:latest"
@@ -42,7 +44,7 @@ func runBuild(args []string) error {
provider := fs.String("provider", "ollama", "ollama or gemini")
modelName := fs.String("model", "", "model name (default as for draft)")
attempts := fs.Int("attempts", 3, "fix rounds for validation problems, each time")
bootRounds := fs.Int("boot-rounds", 3, "boots of the draft before giving up")
bootRounds := fs.Int("boot-rounds", 5, "boots of the draft at most (it stops earlier when a ruled-out cause comes back)")
out := fs.String("out", "", "write the result JSON here")
fs.Parse(args)
if *gameName == "" || *variantArg == "" {
@@ -158,14 +160,20 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) {
return res, nil
}
// 3. Boot the draft the way nodes run it; failures go back to the model
// 3. Boot the draft the way nodes run it. A failure is investigated and diagnosed before the model
// changes anything; a fix that changes nothing rules its cause out
wantOutside := len(outsideDirs(report)) > 0
iv := newInvestigator(d.m, o.name, o.gamesDir, o.id, o.agentImage)
lastFailure := ""
for boot := 1; boot <= o.bootRounds; boot++ {
fmt.Printf("[build] boot %d of the draft with the node-agent\n", boot)
bootProblems, notes, err := bootCheck(o.gamesDir, o.id, o.agentImage, wantOutside)
if err != nil {
return res, err
}
failure := strings.Join(bootProblems, "\n")
iv.outcome(len(bootProblems) == 0, lastFailure, failure)
lastFailure = failure
if len(bootProblems) == 0 {
res.Passed = true
res.Verified = append(res.Verified, notes...)
@@ -179,7 +187,18 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) {
if boot == o.bootRounds {
break
}
problems, err := d.refine(o.attempts, bootProblems)
diag, err := iv.diagnose(failure)
if err != nil {
fmt.Printf("[build] diagnosis failed (%v); sending the failure as it is\n", err)
diag = &diagnosis{Cause: "unknown", Fix: "find the cause in the server's output", Evidence: failure}
}
if iv.ruledOut(diag) {
res.Problems = append(res.Problems, "the diagnosis came back to a cause an earlier fix ruled out ("+firstLine(diag.Cause)+
"); the builder stopped rather than guess. See what the builder found")
break
}
iv.record(diag)
problems, err := d.refine(o.attempts, fixRequest(failure, diag))
res.Rounds = d.rounds
if err != nil {
return res, err
@@ -189,6 +208,7 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) {
break
}
}
res.Diagnoses = iv.log
if res.Passed {
res.Problems = nil
}
@@ -426,6 +446,12 @@ func buildSummary(o buildOptions, res *buildResult) string {
fmt.Fprintf(&b, "- %s\n", firstLine(p))
}
}
if len(res.Diagnoses) > 0 {
b.WriteString("\n**What the builder found** (after each failed boot)\n")
for i, a := range res.Diagnoses {
fmt.Fprintf(&b, "%d. Cause: %s\n Fix: %s\n Outcome: %s\n", i+1, firstLine(a.Cause), firstLine(a.Fix), a.Outcome)
}
}
if o.variant.Notes != "" {
fmt.Fprintf(&b, "\n**Notes from research:** %s\n", o.variant.Notes)
}