Skip to content

Commit 4e25bff

Browse files
torrmalclaude
andcommitted
Say when an update check fails, and keep the web server current
A launch check that failed looked exactly like being up to date: its error was printed only under YOLOCODER_DEBUG, and its two-second limit could run out on GitHub's redirect. A web server left running also never looked again, so it showed the build it started with long after main moved on. Launch now prints a failed check and waits five seconds, and the web server checks hourly, installs a newer build over the binary on disk, and says in the page and terminal to restart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3aa2e1b commit 4e25bff

6 files changed

Lines changed: 84 additions & 8 deletions

File tree

‎cmd/yolocoder/main.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,13 @@ func main() {
4444
}
4545

4646
var updated bool
47+
var updateErr error
4748
ui.WithRobot(os.Stdout, "Starting YoloCoder...", func(status ui.RobotStatus) {
48-
updated = update.CheckOnLaunch(version.Commit, status)
49+
updated, updateErr = update.CheckOnLaunch(version.Commit, status)
4950
})
51+
if updateErr != nil {
52+
fmt.Fprintf(os.Stderr, "\x1b[2m[^_^] Could not check for updates, starting %s: %v\x1b[0m\n", version.Display(), updateErr)
53+
}
5054
if updated {
5155
// Relaunch replaces the file on disk; without re-executing it,
5256
// this process would keep running the old code it already

‎internal/update/update.go‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,13 @@ type Checker struct {
3939
// reporting whether a new build was installed. The caller should follow a
4040
// true result with Relaunch so this invocation continues on the new build
4141
// instead of finishing out on the one it started with.
42-
func CheckOnLaunch(currentCommit string, status ui.RobotStatus) bool {
42+
//
43+
// A failed check is returned rather than swallowed. It used to be printed
44+
// only under YOLOCODER_DEBUG, so a check that timed out looked exactly like
45+
// being current, and a build three commits behind went unnoticed.
46+
func CheckOnLaunch(currentCommit string, status ui.RobotStatus) (bool, error) {
4347
_, updated, err := check(currentCommit, false, status)
44-
if err != nil {
45-
debugf("%v", err)
46-
}
47-
return updated
48+
return updated, err
4849
}
4950

5051
// Relaunch re-executes the just-updated binary at its own path with the
@@ -104,7 +105,9 @@ func check(currentCommit string, force bool, status ui.RobotStatus) (string, boo
104105
if !force && !checker.Due(now) {
105106
return "", false, nil
106107
}
107-
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
108+
// Two seconds was too tight for GitHub's redirect to the asset host on
109+
// a slow connection, and a timeout meant no update at all.
110+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
108111
status("Checking for updates...")
109112
latest, err := checker.LatestCommit(ctx)
110113
cancel()

‎internal/web/server.go‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import (
2222
"github.com/mindsdb/yolocoder/internal/app"
2323
"github.com/mindsdb/yolocoder/internal/config"
2424
"github.com/mindsdb/yolocoder/internal/session"
25+
"github.com/mindsdb/yolocoder/internal/update"
26+
"github.com/mindsdb/yolocoder/internal/version"
2527
)
2628

2729
//go:embed static
@@ -140,6 +142,9 @@ type Server struct {
140142
stateMutex sync.Mutex
141143
busy bool
142144
phase string
145+
// updated is the notice that a newer build was installed while this
146+
// server ran, empty until one is.
147+
updated string
143148
// settleUntil is when errors start being believed again after a turn
144149
// changed files. Guarded by stateMutex with busy, because it is the
145150
// same question asked about a slightly later moment.
@@ -227,6 +232,7 @@ func Serve(ctx context.Context, provider config.LLM, port int, initialTask strin
227232
if task := strings.TrimSpace(initialTask); task != "" {
228233
go server.runTask(context.Background(), task, nil, "user")
229234
}
235+
go server.watchForUpdates(ctx)
230236

231237
select {
232238
case <-ctx.Done():
@@ -417,17 +423,60 @@ func (server *Server) handleConfig(response http.ResponseWriter, request *http.R
417423
// this whenever its SSE connection (re)opens.
418424
func (server *Server) handleState(response http.ResponseWriter, request *http.Request) {
419425
server.stateMutex.Lock()
420-
busy, phase := server.busy, server.phase
426+
busy, phase, updated := server.busy, server.phase, server.updated
421427
server.stateMutex.Unlock()
422428
writeJSON(response, map[string]any{
423429
"busy": busy,
424430
"phase": phase,
425431
"process": server.proc.State(),
426432
"port": server.proc.Port(),
427433
"offers": server.pendingOfferInfos(),
434+
"update": updated,
428435
})
429436
}
430437

438+
// updateInterval is how often a running web server looks for a newer build.
439+
const updateInterval = time.Hour
440+
441+
// watchForUpdates keeps a long-running web server from sitting on an old
442+
// build. Updates were only ever checked at launch, and the web server is
443+
// the process people leave open for days, so it showed the build it
444+
// started with long after main had moved on.
445+
//
446+
// A newer build is downloaded over the binary on disk, exactly as at
447+
// launch, and the page says so. It is not restarted into: that would drop
448+
// the chat and the app's ports mid-session, so restarting stays the
449+
// person's call, and it is instant because the new build is already there.
450+
func (server *Server) watchForUpdates(ctx context.Context) {
451+
if version.Commit == "" {
452+
return
453+
}
454+
ticker := time.NewTicker(updateInterval)
455+
defer ticker.Stop()
456+
for {
457+
select {
458+
case <-ctx.Done():
459+
return
460+
case <-ticker.C:
461+
}
462+
latest, updated, err := update.CheckNow(version.Commit, func(string) {})
463+
if err != nil {
464+
fmt.Fprintf(os.Stderr, "\x1b[2m[^_^] Could not check for updates: %v\x1b[0m\n", err)
465+
continue
466+
}
467+
if !updated {
468+
continue
469+
}
470+
text := "YoloCoder " + latest + " is installed. Restart yolocoder to use it."
471+
fmt.Println("[^_^] " + text)
472+
server.stateMutex.Lock()
473+
server.updated = text
474+
server.stateMutex.Unlock()
475+
server.hub.publish("update", map[string]string{"text": text})
476+
return
477+
}
478+
}
479+
431480
// pendingOfferInfo is an unanswered error card's wire shape, so a tab
432481
// that (re)connects after the offer was staged can still render it
433482
// instead of never learning it existed.

‎internal/web/static/app.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,15 @@ modelSelect.addEventListener("change", () => {
541541
// a reconnect during a long build, a tab opened mid-task — would
542542
// otherwise leave the UI stuck showing whatever it last knew, forever;
543543
// this is what lets it catch up instead.
544+
// A newer build installed while this server was running. It stays until
545+
// yolocoder is restarted, since that is the only thing that clears it.
546+
const updateNotice = document.getElementById("update-notice");
547+
function showUpdate(text) {
548+
if (!text) return;
549+
updateNotice.textContent = text;
550+
updateNotice.hidden = false;
551+
}
552+
544553
function resyncState() {
545554
fetch("/state")
546555
.then((response) => response.json())
@@ -551,6 +560,7 @@ function resyncState() {
551560
// A tab that (re)connects after an offer was staged still renders it:
552561
// the offer event is one-shot, but /state carries the unanswered ones.
553562
for (const offer of state.offers || []) addErrorOffer(offer.id, offer.source, offer.text);
563+
showUpdate(state.update);
554564
})
555565
.catch(() => {});
556566
}
@@ -611,6 +621,7 @@ events.addEventListener("error-offer", (event) => {
611621
const data = JSON.parse(event.data);
612622
addErrorOffer(data.id, data.source, data.text || "");
613623
});
624+
events.addEventListener("update", (event) => showUpdate(JSON.parse(event.data).text));
614625
events.addEventListener("error-offer-dismissed", (event) => {
615626
removeErrorOffer(JSON.parse(event.data).id);
616627
});

‎internal/web/static/index.html‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<div id="brand-block">
1414
<span id="brand"><span class="brand-face">[^_^]</span> YoloCoder</span>
1515
<div id="folder-indicator"></div>
16+
<div id="update-notice" hidden></div>
1617
</div>
1718
<button id="btn-recover" type="button" hidden>Restart</button>
1819
<button id="btn-collapse" type="button" title="Collapse chat">&laquo;</button>

‎internal/web/static/style.css‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ main {
9191
overflow: hidden;
9292
text-overflow: ellipsis;
9393
}
94+
#update-notice {
95+
font: 11px var(--mono);
96+
color: #1d5a2f;
97+
white-space: nowrap;
98+
overflow: hidden;
99+
text-overflow: ellipsis;
100+
}
101+
#update-notice[hidden] { display: none; }
94102
#btn-recover {
95103
border: 1px solid #f3c6c6;
96104
background: #fff5f5;

0 commit comments

Comments
 (0)