package main import ( "bufio" "context" "crypto/sha256" "encoding/json" "errors" "fmt" "io" "os" "sort" "strconv" "strings" "sync" "time" "github.com/gorilla/websocket" ) type wsEvent struct { Type string `json:"type"` TaskID string `json:"task_id"` Data json.RawMessage `json:"data"` } type app struct { rootCtx context.Context api *apiClient identityPath string passphrase string maxNodes int quiet bool unattended bool mu sync.RWMutex task taskDTO seq int64 points map[string]point me meDTO ws *websocket.Conn wsConnected bool sessionCancel context.CancelFunc switching bool leaderWatch bool lastGuessAt time.Time lastGuessOK bool beaconPathMode string } func newApp(rootCtx context.Context, api *apiClient, identityPath, passphrase string, maxNodes int, quiet, unattended bool) *app { if maxNodes < 50 { maxNodes = 50 } if maxNodes > 10000 { maxNodes = 10000 } return &app{rootCtx: rootCtx, api: api, identityPath: identityPath, passphrase: passphrase, maxNodes: maxNodes, quiet: quiet, unattended: unattended, points: make(map[string]point)} } func shortID(s string) string { if len(s) <= 12 { return s } return s[:6] + "…" + s[len(s)-4:] } func taskName(t taskCard) string { if strings.TrimSpace(t.DisplayName) != "" { return strings.TrimSpace(t.DisplayName) } return "Task " + shortID(t.ID) } func dtoName(t taskDTO) string { if strings.TrimSpace(t.DisplayName) != "" { return strings.TrimSpace(t.DisplayName) } return "Task " + shortID(t.ID) } func (a *app) printTasks(ctx context.Context) ([]taskCard, error) { ts, err := a.api.tasks(ctx) if err != nil { return nil, err } fmt.Println("\nACTIVE TASKS") fmt.Println("────────────────────────────────────────────────────────────────────────────") for i, t := range ts { mark := " " if t.Selected { mark = "*" } state := "RUN" if t.Paused { state = "PAUSED" } rank := "—" if t.OwnRank > 0 { rank = fmt.Sprintf("#%d", t.OwnRank) } fmt.Printf("%s %2d %-24s %3dbit %-6s nodes=%-5d score=%7.3f rank=%s\n", mark, i+1, clip(taskName(t), 24), t.RangeBits, state, t.PointCount, t.OwnScore, rank) if d := strings.TrimSpace(t.Description); d != "" { fmt.Printf(" %s\n", clip(d, 70)) } fmt.Printf(" id=%s\n", t.ID) } if len(ts) == 0 { fmt.Println("(keine aktiven Tasks)") } fmt.Println() return ts, nil } func clip(s string, n int) string { r := []rune(strings.TrimSpace(s)) if len(r) <= n { return string(r) } if n < 2 { return string(r[:n]) } return string(r[:n-1]) + "…" } func resolveTask(tasks []taskCard, selector string) (taskCard, error) { selector = strings.TrimSpace(selector) if selector == "" { for _, t := range tasks { if t.Selected { return t, nil } } if len(tasks) > 0 { return tasks[0], nil } return taskCard{}, fmt.Errorf("no active task") } if n, err := strconv.Atoi(selector); err == nil && n >= 1 && n <= len(tasks) { return tasks[n-1], nil } low := strings.ToLower(selector) var matches []taskCard for _, t := range tasks { if strings.EqualFold(t.ID, selector) || strings.HasPrefix(strings.ToLower(t.ID), low) || strings.Contains(strings.ToLower(t.DisplayName), low) { matches = append(matches, t) } } if len(matches) == 1 { return matches[0], nil } if len(matches) > 1 { return taskCard{}, fmt.Errorf("task selector %q is ambiguous", selector) } return taskCard{}, fmt.Errorf("task %q not found", selector) } func (a *app) stopSession() { a.mu.Lock() cancel := a.sessionCancel conn := a.ws a.sessionCancel = nil a.ws = nil a.wsConnected = false a.mu.Unlock() if cancel != nil { cancel() } if conn != nil { _ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "switch task"), time.Now().Add(200*time.Millisecond)) _ = conn.Close() } // Give the server's single-identity presence cleanup a short chance to run. time.Sleep(120 * time.Millisecond) } func (a *app) startTask(ctx context.Context, taskID string) error { a.mu.Lock() if a.switching { a.mu.Unlock() return errSwitching } a.switching = true a.mu.Unlock() defer func() { a.mu.Lock() a.switching = false a.mu.Unlock() }() a.stopSession() var ( t taskDTO err error ) if taskID != "" { t, err = a.api.selectTask(ctx, taskID) } else { t, err = a.api.currentTask(ctx) } if err != nil { return err } ws, err := a.api.dialWS(ctx, a.maxNodes) if err != nil { return err } ps, _ := a.api.points(ctx, t.ID, a.maxNodes*3) m, _ := a.api.me(ctx) sctx, cancel := context.WithCancel(context.Background()) a.mu.Lock() a.task, a.seq, a.ws, a.sessionCancel, a.me = t, t.NextSeq, ws, cancel, m a.wsConnected = true a.points = make(map[string]point, len(ps)) for _, p := range ps { a.points[p.ClientID] = p } a.mu.Unlock() go a.wsLoop(sctx, ws, t.ID) go a.guessLoop(sctx, t.ID) if !a.quiet { fmt.Printf("\n▶ %s [%s] %d bit\n", dtoName(t), shortID(t.ID), t.RangeBits) if t.Paused { fmt.Println(" Task ist pausiert; automatische Tipps warten.") } } return nil } func (a *app) wsLoop(ctx context.Context, initial *websocket.Conn, taskID string) { conn := initial backoff := 500 * time.Millisecond for { completed, err := a.readWSOnce(ctx, conn, taskID) a.mu.Lock() if a.ws == conn { a.ws = nil a.wsConnected = false } a.mu.Unlock() if completed || ctx.Err() != nil { return } if err != nil && !a.quiet { fmt.Printf("\n[ws] Verbindung beendet: %v; Reconnect folgt automatisch\n", err) } for { timer := time.NewTimer(backoff) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: } next, dialErr := a.api.dialWS(ctx, a.maxNodes) if dialErr != nil { if !a.quiet { fmt.Printf("[ws] Reconnect fehlgeschlagen: %v\n", dialErr) } backoff = time.Duration(minInt64(int64(8*time.Second), int64(float64(backoff)*1.7))) continue } a.mu.Lock() if a.task.ID != taskID || ctx.Err() != nil { a.mu.Unlock() _ = next.Close() return } a.ws = next a.wsConnected = true a.mu.Unlock() conn = next backoff = 500 * time.Millisecond go a.refreshTask(taskID) if !a.quiet { fmt.Println("[ws] wieder verbunden") } break } } } func minInt64(a, b int64) int64 { if a < b { return a } return b } func (a *app) forceWSReconnect() { a.mu.Lock() conn := a.ws a.ws = nil a.wsConnected = false a.mu.Unlock() if conn != nil { _ = conn.Close() } } func (a *app) readWSOnce(ctx context.Context, conn *websocket.Conn, taskID string) (bool, error) { // The server emits a WebSocket ping every 30 seconds. A client-side read // deadline is equally important: without one, a half-open TCP connection // (Wi-Fi/VPN/NAT outage) can block ReadMessage forever and wsConnected would // incorrectly remain true. Receiving either data or a server ping refreshes // the deadline; 90 seconds of silence forces the normal reconnect loop. const idleTimeout = 90 * time.Second _ = conn.SetReadDeadline(time.Now().Add(idleTimeout)) defaultPing := conn.PingHandler() conn.SetPingHandler(func(appData string) error { _ = conn.SetReadDeadline(time.Now().Add(idleTimeout)) return defaultPing(appData) }) for { _, b, err := conn.ReadMessage() if err != nil { if ctx.Err() != nil { return false, ctx.Err() } return false, err } _ = conn.SetReadDeadline(time.Now().Add(idleTimeout)) var ev wsEvent if json.Unmarshal(b, &ev) != nil { continue } switch ev.Type { case "snapshot": var ps []point if json.Unmarshal(ev.Data, &ps) == nil { a.replacePoints(ps) } case "point": var p point if json.Unmarshal(ev.Data, &p) == nil { a.upsertPoint(p) } case "points": var ps []point if json.Unmarshal(ev.Data, &ps) == nil { for _, p := range ps { a.upsertPoint(p) } } case "task_changed": if ev.TaskID == "" || ev.TaskID == taskID { go a.refreshTask(taskID) } case "task_completed": if ev.TaskID == taskID { if !a.quiet { fmt.Println("\n✓ Task abgeschlossen. Wechsle auf den Folge-Task …") } go a.retryCurrentTaskAfterCompletion(taskID) return true, nil } } } } // retryCurrentTaskAfterCompletion closes a gap that previously left unattended // workers idle forever when the successor task was not yet queryable on the // first 450ms attempt. It keeps retrying with bounded backoff until either a // new active task is available or the whole client is shutting down. func (a *app) retryCurrentTaskAfterCompletion(completedTaskID string) { backoff := 500 * time.Millisecond for { select { case <-a.rootCtx.Done(): return case <-time.After(backoff): } if err := a.startTask(a.rootCtx, ""); err == nil { return } else if !errors.Is(err, errSwitching) && !a.quiet { fmt.Printf("[task] Folge-Task noch nicht bereit: %v; neuer Versuch folgt\n", err) } backoff = time.Duration(minInt64(int64(15*time.Second), int64(float64(backoff)*1.7))) a.mu.RLock() currentID := a.task.ID a.mu.RUnlock() if currentID != "" && currentID != completedTaskID { return } } } func (a *app) replacePoints(ps []point) { a.mu.Lock() defer a.mu.Unlock() a.points = make(map[string]point, len(ps)) for _, p := range ps { a.points[p.ClientID] = p } } func (a *app) upsertPoint(p point) { a.mu.Lock() defer a.mu.Unlock() if a.points == nil { a.points = make(map[string]point) } a.points[p.ClientID] = p if p.ClientID == a.api.clientID() { a.me.Score, a.me.Rank = p.Score, p.Rank } if len(a.points) > a.maxNodes*3 { a.trimPointsLocked() } } func (a *app) trimPointsLocked() { ps := make([]point, 0, len(a.points)) for _, p := range a.points { ps = append(ps, p) } sort.Slice(ps, func(i, j int) bool { return ps[i].Score > ps[j].Score }) keep := a.maxNodes * 3 if keep > len(ps) { keep = len(ps) } next := make(map[string]point, keep+1) for _, p := range ps[:keep] { next[p.ClientID] = p } if own, ok := a.points[a.api.clientID()]; ok { next[own.ClientID] = own } a.points = next } func (a *app) refreshTask(taskID string) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() t, err := a.api.currentTask(ctx) if err != nil || t.ID != taskID { return } ps, _ := a.api.points(ctx, t.ID, a.maxNodes*3) m, _ := a.api.me(ctx) a.mu.Lock() oldSeed := a.task.PublicSeed a.task = t // Preserve the hot sequence for same-seed changes (bits/intervals/config). // A reroll changes public_seed and intentionally resets to the server state. if oldSeed != t.PublicSeed { a.seq = t.NextSeq } else if t.NextSeq > a.seq { a.seq = t.NextSeq } if m.ClientID != "" { a.me = m } if ps != nil { a.points = make(map[string]point, len(ps)) for _, p := range ps { a.points[p.ClientID] = p } } a.mu.Unlock() if !a.quiet { fmt.Printf("\n[task] Konfiguration aktualisiert: %d bit, Intervall %ds, paused=%v\n", t.RangeBits, t.ClientSubmitIntervalSec, t.Paused) } } func chooseBeaconPath(mode, clientID string, seq int64) string { switch strings.ToUpper(strings.TrimSpace(mode)) { case "PULSE", "FLUX", "ORBIT": return strings.ToUpper(strings.TrimSpace(mode)) } h := sha256.Sum256([]byte(fmt.Sprintf("nh-cli-beacon-auto|%s|%d", clientID, seq))) paths := []string{"PULSE", "FLUX", "ORBIT"} return paths[int(h[0])%len(paths)] } func (a *app) guessLoop(ctx context.Context, taskID string) { for { a.mu.RLock() t := a.task a.mu.RUnlock() if t.ID != taskID { return } sec := t.ClientSubmitIntervalSec if sec <= 0 { sec = 11 } timer := time.NewTimer(time.Duration(sec) * time.Second) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: } // Re-read state after sleeping. Admin actions may have changed seed/bits, // sequence or pause state while this iteration was waiting. a.mu.RLock() t = a.task seq := a.seq connected := a.wsConnected a.mu.RUnlock() if t.ID != taskID { return } if t.Paused || !connected { continue } path := "" if t.BeaconHuntEnabled == 1 && t.GuessLotteryMaxAccepted > 0 { path = chooseBeaconPath(a.beaconPathMode, a.api.clientID(), seq) if !a.quiet { fmt.Printf("[beacon] Pfad %s · Bonusgewicht bei Treffer ×%d\n", path, t.BeaconBonusWeight) } } correct, err := a.api.guess(ctx, t, seq, path) if err != nil { if ctx.Err() != nil { return } if ae := new(apiError); errorsAs(err, &ae) && (ae.Status == 409 || ae.Status == 423 || ae.Status == 429) { a.refreshTask(taskID) if ae.Status == 409 && ae.Code() == "presence_required" { a.forceWSReconnect() } } else if !a.quiet { fmt.Printf("\n[guess] %v\n", err) } continue } a.mu.Lock() if a.task.ID == taskID && a.seq == seq { a.seq++ } a.lastGuessAt = time.Now() a.lastGuessOK = true a.mu.Unlock() if correct { fmt.Printf("\n★ GEWONNEN: %s ★\n", dtoName(t)) return } } } // errorsAs is a tiny wrapper to keep the UI file's imports readable. func errorsAs(err error, target any) bool { return errors.As(err, target) } func (a *app) printStatus(ctx context.Context) { m, err := a.api.me(ctx) if err == nil { a.mu.Lock() a.me = m a.mu.Unlock() } a.mu.RLock() t, seq, points, me, last := a.task, a.seq, len(a.points), a.me, a.lastGuessAt a.mu.RUnlock() fmt.Println("\nSTATUS") fmt.Println("────────────────────────────────────────────────────────") fmt.Printf("Identity : %s\n", a.api.clientID()) fmt.Printf("Task : %s (%s)\n", dtoName(t), t.ID) fmt.Printf("Raum : %d bit Revision %d Paused %v\n", t.RangeBits, t.Revision, t.Paused) fmt.Printf("Score : %.4f Rank #%d Wins %d\n", me.Score, me.Rank, me.Wins) fmt.Printf("Sequence : %d Nodes im Working Set %d\n", seq, points) if !last.IsZero() { fmt.Printf("Letzter Tipp: %s\n", last.Format("15:04:05")) } if len(me.Unlocks) > 0 { fmt.Printf("Unlocks : %s\n", strings.Join(me.Unlocks, ", ")) } fmt.Println() } func (a *app) printMap(limit int) { if limit <= 0 { limit = 30 } a.mu.RLock() ps := make([]point, 0, len(a.points)) for _, p := range a.points { ps = append(ps, p) } cid := a.api.clientID() t := a.task a.mu.RUnlock() sort.Slice(ps, func(i, j int) bool { if ps[i].Score == ps[j].Score { return ps[i].ClientID < ps[j].ClientID } return ps[i].Score > ps[j].Score }) if len(ps) > limit { ps = ps[:limit] } fmt.Printf("\nTARGET FIELD — %s\n", dtoName(t)) fmt.Println("TASK ◉ ← höherer Score bedeutet näher am Zentrum") fmt.Println("────────────────────────────────────────────────────────────────") bands := []struct { name string min float64 max float64 }{{"99+ INNER CORE", 99, 101}, {"95–99 NEAR", 95, 99}, {"90–95 CLOSE", 90, 95}, {"75–90 MID", 75, 90}, {"50–75 FAR", 50, 75}, {"0–50 OUTER", 0, 50}} for _, b := range bands { var names []string for _, p := range ps { if p.Score >= b.min && p.Score < b.max { mark := "" if p.ClientID == cid { mark = "*" } names = append(names, fmt.Sprintf("%s%s %.3f", mark, shortID(p.ClientID), p.Score)) } } fmt.Printf("%-14s │ %s\n", b.name, strings.Join(names, " ")) } fmt.Println("* = deine Identität") fmt.Println() } func (a *app) printLeaderboard(ctx context.Context, limit int) error { ls, err := a.api.leaderboard(ctx) if err != nil { return err } if limit <= 0 || limit > len(ls) { limit = minInt(25, len(ls)) } fmt.Println("\nREALTIME LEADERBOARD") fmt.Println("──────────────────────────────────────────────────────────────────────────") fmt.Printf("%-5s %-15s %6s %10s %10s %8s %5s\n", "RANK", "CLIENT", "WINS", "LIVE", "BEST", "GUESSES", "NFT") for i, l := range ls[:limit] { conn := " " if l.Connected { conn = "●" } self := "" if l.ClientID == a.api.clientID() { self = "*" } fmt.Printf("#%-4d %-15s %6d %10.4f %10.4f %8d %5d\n", i+1, conn+self+shortID(l.ClientID), l.Wins, l.LiveScore, l.BestScore, l.GuessCount, l.NFTCount) } fmt.Println("● online * du") fmt.Println() return nil } func (a *app) printNFTs(ctx context.Context, limit int) error { if limit <= 0 { limit = 30 } items, err := a.api.artifacts(ctx, limit) if err != nil { return err } fmt.Println("\nNFT / WINNER ARTIFACTS (öffentliche Wasserzeichen-Previews)") fmt.Println("──────────────────────────────────────────────────────────────────────────") for i, n := range items { fmt.Printf("%2d task=%-16s winner=%-14s %3dbit %s\n", i+1, shortID(n.TaskID), shortID(n.WinnerClientID), n.RangeBits, n.CompletedAt.Local().Format("2006-01-02 15:04")) fmt.Printf(" %s%s\n", a.api.base, n.PreviewURI) } if len(items) == 0 { fmt.Println("(noch keine fertigen Artefakte)") } fmt.Println() return nil } func (a *app) printMyNFTs(ctx context.Context, limit int) error { if limit <= 0 { limit = 30 } items, err := a.api.ownedArtifacts(ctx, limit) if err != nil { return err } fmt.Println("\nMEINE NFTS / ORIGINAL-ARTEFAKTE") fmt.Println("──────────────────────────────────────────────────────────────────────────") for i, n := range items { name := strings.TrimSpace(n.DisplayName) if name == "" { name = shortID(n.TaskID) } fmt.Printf("%2d %-24s task=%-16s %3dbit %s\n", i+1, name, shortID(n.TaskID), n.RangeBits, n.CompletedAt.Local().Format("2006-01-02 15:04")) fmt.Printf(" Original: %s%s\n", a.api.base, n.DownloadURI) } if len(items) == 0 { fmt.Println("(für diese Identität noch keine fertigen Gewinner-Artefakte)") } fmt.Println() return nil } func (a *app) watcher(ctx context.Context) { t := time.NewTicker(5 * time.Second) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: a.mu.RLock() watch := a.leaderWatch task := a.task seq := a.seq me := a.me a.mu.RUnlock() if watch { _ = a.printLeaderboard(ctx, 15) } else if a.unattended && !a.quiet { fmt.Printf("%s task=%s score=%.4f rank=%d seq=%d paused=%v\n", time.Now().Format("15:04:05"), shortID(task.ID), me.Score, me.Rank, seq, task.Paused) } } } } func (a *app) commandLoop(ctx context.Context, in io.Reader) error { s := bufio.NewScanner(in) fmt.Println("Befehle: help, tasks, use , status, map [n], leaderboard [n|watch|stop], nfts [n], my-nfts [n], nft get , nft original , identity, identity export , hosted-code, quit") for { fmt.Print("neuralhunt> ") if !s.Scan() { return s.Err() } line := strings.TrimSpace(s.Text()) if line == "" { continue } parts := strings.Fields(line) cmd := strings.ToLower(parts[0]) switch cmd { case "help", "?": fmt.Println(" tasks aktive Tasks anzeigen") fmt.Println(" use Task wechseln") fmt.Println(" status Score, Rank, Sequence, Task") fmt.Println(" map [n] textuelles Target Field / Nähe") fmt.Println(" leaderboard [n] Live-Rangliste") fmt.Println(" leaderboard watch|stop Rangliste alle 5s ein/aus") fmt.Println(" nfts [n] öffentliche Wasserzeichen-NFTs anzeigen") fmt.Println(" my-nfts [n] eigene Gewinner-Artefakte anzeigen") fmt.Println(" nft get Wasserzeichen-Preview speichern") fmt.Println(" nft original eigenes Original speichern") fmt.Println(" identity Client-ID und Identity-Datei") fmt.Println(" identity export browser-kompatiblen verschlüsselten Export schreiben") fmt.Println(" hosted-code 10-Minuten-Code zum Koppeln als Reward-Identität") fmt.Println(" quit beenden") case "tasks": if _, err := a.printTasks(ctx); err != nil { fmt.Println("Fehler:", err) } case "use": if len(parts) < 2 { fmt.Println("use benötigt Nummer, ID-Präfix oder Namen") continue } ts, err := a.api.tasks(ctx) if err != nil { fmt.Println("Fehler:", err) continue } t, err := resolveTask(ts, strings.Join(parts[1:], " ")) if err != nil { fmt.Println("Fehler:", err) continue } if err := a.startTask(ctx, t.ID); err != nil { fmt.Println("Fehler:", err) } case "status": a.printStatus(ctx) case "map": n := 30 if len(parts) > 1 { n, _ = strconv.Atoi(parts[1]) } a.printMap(n) case "leaderboard", "lb": if len(parts) > 1 && strings.EqualFold(parts[1], "watch") { a.mu.Lock() a.leaderWatch = true a.mu.Unlock() fmt.Println("Leaderboard-Watch aktiviert.") continue } if len(parts) > 1 && (strings.EqualFold(parts[1], "stop") || strings.EqualFold(parts[1], "off")) { a.mu.Lock() a.leaderWatch = false a.mu.Unlock() fmt.Println("Leaderboard-Watch deaktiviert.") continue } n := 25 if len(parts) > 1 { n, _ = strconv.Atoi(parts[1]) } if err := a.printLeaderboard(ctx, n); err != nil { fmt.Println("Fehler:", err) } case "nfts": n := 30 if len(parts) > 1 { n, _ = strconv.Atoi(parts[1]) } if err := a.printNFTs(ctx, n); err != nil { fmt.Println("Fehler:", err) } case "my-nfts", "mine": n := 30 if len(parts) > 1 { n, _ = strconv.Atoi(parts[1]) } if err := a.printMyNFTs(ctx, n); err != nil { fmt.Println("Fehler:", err) } case "nft": if len(parts) == 4 && strings.EqualFold(parts[1], "get") { if err := a.api.downloadPreview(ctx, parts[2], parts[3]); err != nil { fmt.Println("Fehler:", err) } else { fmt.Println("Wasserzeichen-Preview gespeichert:", parts[3]) } } else if len(parts) == 4 && (strings.EqualFold(parts[1], "original") || strings.EqualFold(parts[1], "download")) { if err := a.api.downloadOwnedArtifact(ctx, parts[2], parts[3]); err != nil { fmt.Println("Fehler:", err) } else { fmt.Println("Original-Artefakt gespeichert:", parts[3]) } } else { fmt.Println("nft get | nft original ") } case "identity", "id": if len(parts) >= 2 && strings.EqualFold(parts[1], "export") { if len(parts) != 3 { fmt.Println("identity export ") continue } if err := exportBrowserIdentity(parts[2], a.passphrase, a.api.id); err != nil { fmt.Println("Fehler:", err) } else { fmt.Println("Verschlüsselter Browser-Export geschrieben:", parts[2]) } } else { fmt.Println("Client-ID:", a.api.clientID()) fmt.Println("Identity :", a.identityPath) } case "hosted-code", "hosted-link": x, err := a.api.hostedLinkCode(ctx) if err != nil { fmt.Println("Fehler:", err) } else { fmt.Println("Hosted-Code:", x.Code) fmt.Println("Client-ID :", x.ClientID) fmt.Println("Gültig bis :", x.ExpiresAt.Local().Format(time.RFC3339)) } case "quit", "exit", "q": return nil default: fmt.Println("Unbekannter Befehl. 'help' zeigt die Befehle.") } } } func minInt(a, b int) int { if a < b { return a } return b } func selectInitialTask(ctx context.Context, a *app, selector string, interactive bool) (string, error) { ts, err := a.printTasks(ctx) if err != nil { return "", err } if len(ts) == 0 { return "", fmt.Errorf("server has no active tasks") } if selector != "" || !interactive { t, err := resolveTask(ts, selector) if err == nil { return t.ID, nil } // A hosted worker stores the task ID that was active when it was created. // After that task completes, the game moves the identity to its successor, // but the Customer-Service record may still contain the historical ID. On // a later container restart, falling back to the server-selected/current // active task lets the same persistent identity resume instead of crash- // looping forever on "task not found". if !interactive && strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_ID")) != "" { for _, candidate := range ts { if candidate.Selected { if !a.quiet { fmt.Printf("[task] konfigurierte Task-ID %q ist nicht mehr aktiv; setze mit %s fort\n", selector, taskName(candidate)) } return candidate.ID, nil } } if !a.quiet { fmt.Printf("[task] konfigurierte Task-ID %q ist nicht mehr aktiv; verwende %s\n", selector, taskName(ts[0])) } return ts[0].ID, nil } return "", err } fmt.Print("Task auswählen [Nummer/ID/Name, Enter = markierter Task]: ") line, _ := bufio.NewReader(os.Stdin).ReadString('\n') t, err := resolveTask(ts, strings.TrimSpace(line)) return t.ID, err }