Compare commits
2
Commits
4874868ce6
...
e07025f03d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e07025f03d | ||
|
|
487dfbdf68 |
No files matched your search
+29
-3
@@ -27,6 +27,8 @@ type buildResult struct {
|
|||||||
Verified []string `json:"verified"`
|
Verified []string `json:"verified"`
|
||||||
Problems []string `json:"problems"`
|
Problems []string `json:"problems"`
|
||||||
Summary string `json:"summary"`
|
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"
|
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")
|
provider := fs.String("provider", "ollama", "ollama or gemini")
|
||||||
modelName := fs.String("model", "", "model name (default as for draft)")
|
modelName := fs.String("model", "", "model name (default as for draft)")
|
||||||
attempts := fs.Int("attempts", 3, "fix rounds for validation problems, each time")
|
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")
|
out := fs.String("out", "", "write the result JSON here")
|
||||||
fs.Parse(args)
|
fs.Parse(args)
|
||||||
if *gameName == "" || *variantArg == "" {
|
if *gameName == "" || *variantArg == "" {
|
||||||
@@ -158,14 +160,20 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) {
|
|||||||
return res, nil
|
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
|
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++ {
|
for boot := 1; boot <= o.bootRounds; boot++ {
|
||||||
fmt.Printf("[build] boot %d of the draft with the node-agent\n", 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)
|
bootProblems, notes, err := bootCheck(o.gamesDir, o.id, o.agentImage, wantOutside)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
failure := strings.Join(bootProblems, "\n")
|
||||||
|
iv.outcome(len(bootProblems) == 0, lastFailure, failure)
|
||||||
|
lastFailure = failure
|
||||||
if len(bootProblems) == 0 {
|
if len(bootProblems) == 0 {
|
||||||
res.Passed = true
|
res.Passed = true
|
||||||
res.Verified = append(res.Verified, notes...)
|
res.Verified = append(res.Verified, notes...)
|
||||||
@@ -179,7 +187,18 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) {
|
|||||||
if boot == o.bootRounds {
|
if boot == o.bootRounds {
|
||||||
break
|
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
|
res.Rounds = d.rounds
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
@@ -189,6 +208,7 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
res.Diagnoses = iv.log
|
||||||
if res.Passed {
|
if res.Passed {
|
||||||
res.Problems = nil
|
res.Problems = nil
|
||||||
}
|
}
|
||||||
@@ -426,6 +446,12 @@ func buildSummary(o buildOptions, res *buildResult) string {
|
|||||||
fmt.Fprintf(&b, "- %s\n", firstLine(p))
|
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 != "" {
|
if o.variant.Notes != "" {
|
||||||
fmt.Fprintf(&b, "\n**Notes from research:** %s\n", o.variant.Notes)
|
fmt.Fprintf(&b, "\n**Notes from research:** %s\n", o.variant.Notes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// When a draft fails its boot, the builder investigates before anything is changed, the way a person
|
||||||
|
// would: it collects evidence (the server's errors, what others found about that error on the web), has
|
||||||
|
// the model state the cause and the evidence for it, lets the model ask for more checks (a search, a
|
||||||
|
// page, the files the server wrote, a boot with one thing changed), and only then asks for exactly that
|
||||||
|
// fix. Every attempt is logged, so a fix that changed nothing rules its cause out instead of being tried
|
||||||
|
// again, and the build stops when a ruled-out cause comes back.
|
||||||
|
|
||||||
|
type attempt struct {
|
||||||
|
Cause string `json:"cause"`
|
||||||
|
Fix string `json:"fix"`
|
||||||
|
Outcome string `json:"outcome"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkRequest struct {
|
||||||
|
Type string `json:"type"` // search, read, files, boot_with
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Config map[string]string `json:"config,omitempty"`
|
||||||
|
Startup string `json:"startup,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type diagnosis struct {
|
||||||
|
Cause string `json:"cause"`
|
||||||
|
Evidence string `json:"evidence"`
|
||||||
|
Confidence string `json:"confidence"`
|
||||||
|
Fix string `json:"fix"`
|
||||||
|
Checks []checkRequest `json:"checks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var diagnosisSchema = map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"required": []string{"cause", "evidence", "confidence", "fix", "checks"},
|
||||||
|
"properties": map[string]any{
|
||||||
|
"cause": map[string]any{"type": "string"},
|
||||||
|
"evidence": map[string]any{"type": "string"},
|
||||||
|
"confidence": map[string]any{"enum": []string{"high", "medium", "low"}},
|
||||||
|
"fix": map[string]any{"type": "string"},
|
||||||
|
"checks": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 3,
|
||||||
|
"items": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"required": []string{"type"},
|
||||||
|
"properties": map[string]any{
|
||||||
|
"type": map[string]any{"enum": []string{"search", "read", "files", "boot_with"}},
|
||||||
|
"query": map[string]any{"type": "string"},
|
||||||
|
"url": map[string]any{"type": "string"},
|
||||||
|
"config": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}},
|
||||||
|
"startup": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const diagnosePrompt = "You debug why a game server template fails on GBR Hosting nodes. Work like an engineer: find the " +
|
||||||
|
"cause from the evidence, never by guessing or by changing things to see what happens. The house rules below " +
|
||||||
|
"describe how servers really run; the boot test starts the server with exactly the settings a new server gets " +
|
||||||
|
"(settings left empty stay empty; only x-generate fields are filled).\n\n" +
|
||||||
|
"Answer with JSON: cause (what is wrong, specifically), evidence (which lines, files or results show it), " +
|
||||||
|
"confidence, fix (the exact change to the template), and checks. If the evidence doesn't settle the cause, ask " +
|
||||||
|
"for up to 3 checks instead of guessing:\n" +
|
||||||
|
"- {\"type\":\"search\",\"query\":\"...\"}: a web search, e.g. the exact error message with the game's name\n" +
|
||||||
|
"- {\"type\":\"read\",\"url\":\"...\"}: read a page\n" +
|
||||||
|
"- {\"type\":\"files\"}: boot the template once more and list the files and config files the server writes, " +
|
||||||
|
"where it writes them, and its error lines\n" +
|
||||||
|
"- {\"type\":\"boot_with\",\"config\":{\"Key\":\"value\"},\"startup\":\"...\"}: boot test with those settings " +
|
||||||
|
"and/or that startup command changed, to see whether that change makes the difference\n" +
|
||||||
|
"Leave checks empty once you are sure. Never propose a cause the attempt log has ruled out.\n\n"
|
||||||
|
|
||||||
|
type investigator struct {
|
||||||
|
m model
|
||||||
|
gameName, gamesDir, id, agentImage string
|
||||||
|
searx, reader, rules string
|
||||||
|
log []attempt
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInvestigator(m model, gameName, gamesDir, id, agentImage string) *investigator {
|
||||||
|
rules, _ := os.ReadFile("AUTHORING.md")
|
||||||
|
return &investigator{m: m, gameName: gameName, gamesDir: gamesDir, id: id, agentImage: agentImage,
|
||||||
|
searx: os.Getenv("SEARXNG_URL"), reader: os.Getenv("READER_URL"), rules: string(rules)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// diagnose investigates one failure and returns the model's diagnosis (checks answered).
|
||||||
|
func (iv *investigator) diagnose(failure string) (*diagnosis, error) {
|
||||||
|
template, _ := os.ReadFile(filepath.Join(iv.gamesDir, iv.id, "game.yaml"))
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "Game: %s\n\n## The template\n```yaml\n%s```\n\n## What failed\n%s\n", iv.gameName, template, failure)
|
||||||
|
if ev := iv.webEvidence(failure); ev != "" {
|
||||||
|
b.WriteString("\n## What the web says about its errors\n" + ev)
|
||||||
|
}
|
||||||
|
b.WriteString("\n## Attempt log\n" + iv.logText())
|
||||||
|
messages := []chatMessage{
|
||||||
|
{Role: "system", Content: diagnosePrompt + iv.rules},
|
||||||
|
{Role: "user", Content: b.String()},
|
||||||
|
}
|
||||||
|
var d diagnosis
|
||||||
|
for round := 0; ; round++ {
|
||||||
|
fmt.Printf("[diagnose] round %d: asking %s what is wrong\n", round+1, iv.m.name())
|
||||||
|
answer, err := iv.m.chat(messages, diagnosisSchema)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d = diagnosis{}
|
||||||
|
if err := json.Unmarshal([]byte(trimToJSON(answer)), &d); err != nil {
|
||||||
|
return nil, fmt.Errorf("unreadable diagnosis: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("[diagnose] cause (%s confidence): %s\n", d.Confidence, firstLine(d.Cause))
|
||||||
|
if len(d.Checks) == 0 || round == 2 {
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
messages = append(messages, chatMessage{Role: "assistant", Content: answer})
|
||||||
|
messages = append(messages, chatMessage{Role: "user", Content: "Results of your checks:\n\n" + iv.runChecks(d.Checks) +
|
||||||
|
"\nNow give your diagnosis. Ask for more checks only if these still don't settle the cause."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// webEvidence searches for the server's most telling error line and reads the best answer.
|
||||||
|
func (iv *investigator) webEvidence(failure string) string {
|
||||||
|
errs := distinctErrors(failure, 3)
|
||||||
|
if len(errs) == 0 || iv.searx == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return iv.search(iv.gameName + " dedicated server " + errs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (iv *investigator) search(query string) string {
|
||||||
|
if iv.searx == "" {
|
||||||
|
return "(no search configured)\n"
|
||||||
|
}
|
||||||
|
fmt.Printf("[diagnose] searching: %s\n", query)
|
||||||
|
hits, err := searxSearch(iv.searx, query)
|
||||||
|
if err != nil || len(hits) == 0 {
|
||||||
|
return fmt.Sprintf("search %q: no results\n", query)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "Search %q:\n", query)
|
||||||
|
for i, h := range hits {
|
||||||
|
if i == 5 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "- %s — %s: %.300s\n", h.URL, h.Title, h.Content)
|
||||||
|
}
|
||||||
|
if iv.reader != "" {
|
||||||
|
if text, err := readPageLimit(iv.reader, hits[0].URL, 6000); err == nil {
|
||||||
|
fmt.Fprintf(&b, "\nTop result (%s):\n%s\n", hits[0].URL, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (iv *investigator) runChecks(checks []checkRequest) string {
|
||||||
|
var b strings.Builder
|
||||||
|
boots := 0
|
||||||
|
for _, c := range checks {
|
||||||
|
switch c.Type {
|
||||||
|
case "search":
|
||||||
|
b.WriteString(iv.search(c.Query))
|
||||||
|
case "read":
|
||||||
|
fmt.Printf("[diagnose] reading %s\n", c.URL)
|
||||||
|
if text, err := readPageLimit(iv.reader, c.URL, 8000); err != nil {
|
||||||
|
fmt.Fprintf(&b, "read %s: %v\n", c.URL, err)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, "Page %s:\n%s\n", c.URL, text)
|
||||||
|
}
|
||||||
|
case "files", "boot_with":
|
||||||
|
// Boots take minutes: at most two per round
|
||||||
|
if boots++; boots > 2 {
|
||||||
|
fmt.Fprintf(&b, "%s: skipped (at most two boots per round)\n", c.Type)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.Type == "files" {
|
||||||
|
b.WriteString(iv.filesCheck())
|
||||||
|
} else {
|
||||||
|
b.WriteString(iv.bootWith(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// filesCheck boots the template and reports what the server wrote and where.
|
||||||
|
func (iv *investigator) filesCheck() string {
|
||||||
|
fmt.Println("[diagnose] booting once more to see what the server writes")
|
||||||
|
r, err := firstBootGame(iv.gamesDir, iv.id)
|
||||||
|
if err != nil {
|
||||||
|
return "files: the boot could not run: " + err.Error() + "\n"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "Files check: %s\n", r.Outcome)
|
||||||
|
for i, f := range r.FilesCreated {
|
||||||
|
if i == 60 {
|
||||||
|
fmt.Fprintf(&b, " ... and %d more files\n", len(r.FilesCreated)-60)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " file %s (%d bytes)\n", f.Path, f.Size)
|
||||||
|
}
|
||||||
|
for i, s := range r.Settings {
|
||||||
|
if i == 80 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " setting %s | %s | %s = %s\n", s.File, s.Section, s.Key, s.Value)
|
||||||
|
}
|
||||||
|
if dirs := outsideDirs(r); len(dirs) > 0 {
|
||||||
|
fmt.Fprintf(&b, " written outside /server and /data (lost on every restart): %s\n", strings.Join(dirs, ", "))
|
||||||
|
}
|
||||||
|
for i, l := range r.ErrorLines {
|
||||||
|
if i == 10 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " error line: %s\n", l.Text)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootWith boot-tests a copy of the template with settings and/or the startup command changed.
|
||||||
|
func (iv *investigator) bootWith(c checkRequest) string {
|
||||||
|
desc := describeChange(c)
|
||||||
|
fmt.Printf("[diagnose] boot test with %s\n", desc)
|
||||||
|
games, err := os.MkdirTemp("", "gbr-diagnose-")
|
||||||
|
if err != nil {
|
||||||
|
return "boot_with: " + err.Error() + "\n"
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(games)
|
||||||
|
if err := copyTemplateWith(filepath.Join(iv.gamesDir, iv.id), filepath.Join(games, iv.id), c); err != nil {
|
||||||
|
return "boot_with: " + err.Error() + "\n"
|
||||||
|
}
|
||||||
|
output, passed, problems, err := agentBoot(games, iv.id, iv.agentImage, false)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
return "boot_with: the boot could not run: " + err.Error() + "\n"
|
||||||
|
case problems != nil:
|
||||||
|
return "boot_with: " + strings.Join(problems, "; ") + "\n"
|
||||||
|
case passed:
|
||||||
|
return fmt.Sprintf("Boot with %s: PASSED (%s)\n", desc, bootVerdict(output))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Boot with %s: %s\n", desc, strings.TrimPrefix(bootFailure(output), "booting your template with the node-agent (the real deploy code) failed: "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeChange(c checkRequest) string {
|
||||||
|
var parts []string
|
||||||
|
for _, k := range sortedKeysString(c.Config) {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s=%q", k, c.Config[k]))
|
||||||
|
}
|
||||||
|
if c.Startup != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("startup %q", c.Startup))
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "no change"
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyTemplateWith copies games/<id>/ to dst with the requested settings and startup command applied.
|
||||||
|
func copyTemplateWith(src, dst string, c checkRequest) error {
|
||||||
|
g, err := loadMap(filepath.Join(src, "game.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(c.Config) > 0 {
|
||||||
|
defaults, _ := g["defaultConfig"].(map[string]any)
|
||||||
|
if defaults == nil {
|
||||||
|
defaults = map[string]any{}
|
||||||
|
}
|
||||||
|
for k, v := range c.Config {
|
||||||
|
defaults[k] = v
|
||||||
|
}
|
||||||
|
g["defaultConfig"] = defaults
|
||||||
|
}
|
||||||
|
if c.Startup != "" {
|
||||||
|
g["defaultStartupCommand"] = c.Startup
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeGame(filepath.Join(dst, "game.yaml"), g); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if raw, err := os.ReadFile(filepath.Join(src, "test.yaml")); err == nil {
|
||||||
|
return os.WriteFile(filepath.Join(dst, "test.yaml"), raw, 0o644)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
digits = regexp.MustCompile(`\d+`)
|
||||||
|
// Timestamps the shared linePrefix doesn't cover, like 10/26/2026 01:12:02:
|
||||||
|
slashDate = regexp.MustCompile(`^\s*\d{1,2}/\d{1,2}/\d{2,4}[ T][0-9:.,]+:?\s*`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// distinctErrors picks the server's error-looking lines, without timestamps and with repeats removed.
|
||||||
|
// The boot test's own verdict lines are not the server's errors.
|
||||||
|
func distinctErrors(output string, n int) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, raw := range strings.Split(output, "\n") {
|
||||||
|
raw = strings.TrimSpace(ansi.ReplaceAllString(raw, ""))
|
||||||
|
if strings.HasPrefix(raw, "[boot-test]") || strings.HasPrefix(raw, "booting your template") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(slashDate.ReplaceAllString(linePrefix.ReplaceAllString(raw, ""), ""))
|
||||||
|
if line == "" || !errorWords.MatchString(line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := digits.ReplaceAllString(strings.ToLower(line), "N")
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
if len(line) > 160 {
|
||||||
|
line = line[:160]
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
if len(out) == n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (iv *investigator) logText() string {
|
||||||
|
if len(iv.log) == 0 {
|
||||||
|
return "(first attempt)\n"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for i, a := range iv.log {
|
||||||
|
fmt.Fprintf(&b, "%d. cause: %s\n fix: %s\n outcome: %s\n", i+1, a.Cause, a.Fix, a.Outcome)
|
||||||
|
}
|
||||||
|
b.WriteString("A fix whose outcome is unchanged ruled its cause out: look for another cause.\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruledOut reports whether the diagnosis names a cause an earlier fix already disproved.
|
||||||
|
func (iv *investigator) ruledOut(d *diagnosis) bool {
|
||||||
|
key := causeKey(d.Cause)
|
||||||
|
for _, a := range iv.log {
|
||||||
|
if causeKey(a.Cause) == key && !strings.HasPrefix(a.Outcome, "fixed") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonWord = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
|
||||||
|
func causeKey(s string) string {
|
||||||
|
return strings.Trim(nonWord.ReplaceAllString(strings.ToLower(s), " "), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (iv *investigator) record(d *diagnosis) {
|
||||||
|
iv.log = append(iv.log, attempt{Cause: d.Cause, Fix: d.Fix, Outcome: "(not booted yet)"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// outcome fills in how the last fix went: fixed, the same failure, or a new one.
|
||||||
|
func (iv *investigator) outcome(passed bool, before, after string) {
|
||||||
|
if len(iv.log) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a := &iv.log[len(iv.log)-1]
|
||||||
|
switch {
|
||||||
|
case passed:
|
||||||
|
a.Outcome = "fixed: the boot test passed"
|
||||||
|
case causeKey(firstLine(before)) == causeKey(firstLine(after)):
|
||||||
|
a.Outcome = "unchanged: " + firstLine(after)
|
||||||
|
default:
|
||||||
|
a.Outcome = "a different failure: " + firstLine(after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixRequest(failure string, d *diagnosis) []string {
|
||||||
|
return []string{fmt.Sprintf("%s\n\nDiagnosis:\n- cause: %s\n- evidence: %s\n- fix: %s\n\nApply exactly this fix and change "+
|
||||||
|
"nothing unrelated to it.", firstLine(failure), d.Cause, d.Evidence, d.Fix)}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeModel answers with the given replies in order and keeps what it was asked.
|
||||||
|
type fakeModel struct {
|
||||||
|
replies []string
|
||||||
|
asked [][]chatMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeModel) name() string { return "fake" }
|
||||||
|
func (f *fakeModel) chat(m []chatMessage, _ map[string]any) (string, error) {
|
||||||
|
f.asked = append(f.asked, append([]chatMessage{}, m...))
|
||||||
|
r := f.replies[0]
|
||||||
|
f.replies = f.replies[1:]
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagnoseRunsRequestedChecksBeforeAnswering(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/search") {
|
||||||
|
w.Write([]byte(`{"results":[{"url":"https://example.com/fix","title":"Server stops at start","content":"set a password"}]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write([]byte("The server refuses to run without a join password."))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
games := t.TempDir()
|
||||||
|
os.MkdirAll(filepath.Join(games, "example"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(games, "example", "game.yaml"), []byte("id: example\nname: Example\n"), 0o644)
|
||||||
|
|
||||||
|
m := &fakeModel{replies: []string{
|
||||||
|
`{"cause":"unsure","evidence":"no error line","confidence":"low","fix":"","checks":[{"type":"search","query":"example server stops at start"}]}`,
|
||||||
|
`{"cause":"the server needs a join password","evidence":"the search result says so","confidence":"high","fix":"give JoinPassword x-generate","checks":[]}`,
|
||||||
|
}}
|
||||||
|
iv := &investigator{m: m, gameName: "Example", gamesDir: games, id: "example", searx: srv.URL, reader: srv.URL}
|
||||||
|
d, err := iv.diagnose("FAILED: server crashed and was restarted 1 time(s)\nError: could not start")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d.Confidence != "high" || !strings.Contains(d.Fix, "x-generate") {
|
||||||
|
t.Errorf("diagnosis: %+v", d)
|
||||||
|
}
|
||||||
|
second := m.asked[1]
|
||||||
|
last := second[len(second)-1].Content
|
||||||
|
if !strings.Contains(last, "Results of your checks") || !strings.Contains(last, "https://example.com/fix") ||
|
||||||
|
!strings.Contains(last, "refuses to run without a join password") {
|
||||||
|
t.Errorf("check results not sent back:\n%s", last)
|
||||||
|
}
|
||||||
|
// The first question already carried the web evidence for the error line
|
||||||
|
if first := m.asked[0][1].Content; !strings.Contains(first, "What the web says") {
|
||||||
|
t.Errorf("no web evidence in:\n%s", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttemptLogRulesOutCausesThatChangedNothing(t *testing.T) {
|
||||||
|
iv := &investigator{}
|
||||||
|
iv.record(&diagnosis{Cause: "Not enough memory.", Fix: "requiredMemoryMb 8192"})
|
||||||
|
iv.outcome(false, "FAILED: server crashed", "FAILED: server crashed")
|
||||||
|
if !strings.HasPrefix(iv.log[0].Outcome, "unchanged") {
|
||||||
|
t.Errorf("outcome %q", iv.log[0].Outcome)
|
||||||
|
}
|
||||||
|
if !iv.ruledOut(&diagnosis{Cause: "not enough memory"}) {
|
||||||
|
t.Error("the same cause again should be ruled out")
|
||||||
|
}
|
||||||
|
if iv.ruledOut(&diagnosis{Cause: "the join password is empty"}) {
|
||||||
|
t.Error("a new cause is not ruled out")
|
||||||
|
}
|
||||||
|
if !strings.Contains(iv.logText(), "ruled its cause out") {
|
||||||
|
t.Error("log text should tell the model")
|
||||||
|
}
|
||||||
|
iv.record(&diagnosis{Cause: "empty join password", Fix: "x-generate"})
|
||||||
|
iv.outcome(true, "FAILED: server crashed", "")
|
||||||
|
if !strings.HasPrefix(iv.log[1].Outcome, "fixed") || iv.ruledOut(&diagnosis{Cause: "empty join password"}) {
|
||||||
|
t.Errorf("fixed cause: %+v", iv.log[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDistinctErrorsDropsTimestampsAndRepeats(t *testing.T) {
|
||||||
|
out := "[boot-test] FAILED: container exited\n" +
|
||||||
|
"10/26/2026 01:12:02: Error: failed to bind port 30000\n" +
|
||||||
|
"10/26/2026 01:12:03: Error: failed to bind port 30001\n" +
|
||||||
|
"all fine here\n" +
|
||||||
|
"[12:00:01] Fatal: missing config file"
|
||||||
|
got := distinctErrors(out, 5)
|
||||||
|
if len(got) != 2 || got[0] != "Error: failed to bind port 30000" || got[1] != "Fatal: missing config file" {
|
||||||
|
t.Errorf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyTemplateWithAppliesTheChange(t *testing.T) {
|
||||||
|
src, dst := filepath.Join(t.TempDir(), "example"), filepath.Join(t.TempDir(), "example")
|
||||||
|
os.MkdirAll(src, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(src, "game.yaml"), []byte("id: example\nname: Example\ndefaultStartupCommand: ./server\ndefaultConfig:\n JoinPassword: \"\"\n"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(src, "test.yaml"), []byte("timeoutSec: 600\n"), 0o644)
|
||||||
|
if err := copyTemplateWith(src, dst, checkRequest{Config: map[string]string{"JoinPassword": "secret1"}, Startup: "./server -verbose"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g, err := loadMap(filepath.Join(dst, "game.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if g["defaultStartupCommand"] != "./server -verbose" || g["defaultConfig"].(map[string]any)["JoinPassword"] != "secret1" {
|
||||||
|
t.Errorf("got %v", g)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dst, "test.yaml")); err != nil {
|
||||||
|
t.Error("test.yaml not copied")
|
||||||
|
}
|
||||||
|
if got := describeChange(checkRequest{Config: map[string]string{"JoinPassword": "secret1"}}); got != `JoinPassword="secret1"` {
|
||||||
|
t.Errorf("describe: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFixRequestCarriesTheDiagnosis(t *testing.T) {
|
||||||
|
got := strings.Join(fixRequest("FAILED: server crashed\nlog...", &diagnosis{Cause: "c", Evidence: "e", Fix: "f"}), "")
|
||||||
|
for _, want := range []string{"FAILED: server crashed", "- cause: c", "- fix: f", "Apply exactly this fix"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("missing %q in %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user