Skip to content
18 changes: 16 additions & 2 deletions cmd/codeaf/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,14 @@ func openChatV3(name string, args []string, pickSession bool) error {
// second id, or under none, is a record nothing can be joined to. The id is
// minted whether or not the switch is on: /debug can turn the record on
// halfway through a conversation, and it needs a run to turn it on for.
//
// THE RECORD ITSELF OPENS AFTER THE LAUNCH'S OWN UPDATE below. That road
// can hand the terminal to another build, and a record opened first would
// be one that run never writes to. Nothing between here and there makes a
// model call.
if *debug {
trace.Enable()
}
ctx := openDebugRecord(name, *model, "")
defer trace.Announce(ctx, os.Stderr)
if flags.NArg() != 0 {
// Resume's usage names no --once and no --session: it opens a list of
// the sessions there ARE, so naming one on the command line is the other
Expand Down Expand Up @@ -206,6 +209,17 @@ func openChatV3(name string, args []string, pickSession bool) error {
if !ok {
return fmt.Errorf("--reasoning %q: use auto, low, medium, high, xhigh or max (off also clears the override)", *reasoning)
}
// THE LAUNCH'S OWN UPDATE, after every refusal above and before any door
// forks: a released codeaf installs the newest stable and restarts onto it
// here, before a frame is drawn, and every other launch is told what is out
// on one terminal line (prelaunch.go). It is the same for all four doors
// because the file being replaced is this machine's whichever one opens.
// --once is a script, not a person, and is never restarted under it.
if strings.TrimSpace(*once) == "" {
updateBeforeLaunch()
}
ctx := openDebugRecord(name, *model, "")
defer trace.Announce(ctx, os.Stderr)

// THE OTHER DOOR, and it forks BEFORE any of this machine's own resolution
// below: over --host the models, the keys, the session files, the gate and
Expand Down
12 changes: 12 additions & 0 deletions cmd/codeaf/chatv3_surface.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,19 @@ func runSurface(ctx context.Context, options tui3.Options) error {
// A BREW, DISTRO OR NIX FILE, OR ONE IN A FOLDER THIS ACCOUNT CANNOT
// WRITE, is not ours to replace in place.
Refusal: func() string { return codeupdate.InstallRefusal(executable, curl) },
// THE BUILD'S HALF OF THE AUTOMATIC ROAD, decided by the check off the
// frame on the resolved file (codeupdate.AutomaticFor), exactly as the
// launch's own road decides it (prelaunch.go): only a stable codeaf
// moving to a newer stable counts down. devaf, stageaf, a dev build, a
// release candidate and every other name hear the quiet line instead.
Automatic: func(available codeupdate.Available) bool { return available.Automatic },
// The release a person already stopped with ctrl+c before this window
// opened. Counting down to it again would be asking twice.
Deferred: launchUpdate.deferred,
}
// A BUILD THE LAUNCH JUST INSTALLED SAYS SO once it is on screen, because
// the line the old build printed is under the chat now.
options.UpdatedFrom = launchUpdate.updatedFrom
options.UpdateCheck = func(check context.Context) (codeupdate.Available, bool) {
return codeupdate.CheckLaunch(check, codeupdate.CheckOptions{
Running: revision, ProfileDir: options.ProfileDir, Client: client,
Expand Down
8 changes: 5 additions & 3 deletions cmd/codeaf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,8 @@ Housekeeping — changes state on disk or on the network
codeaf disconnect <provider>
forget a provider and the key or sign-in behind it
codeaf update [--check] [--stable|--rc|--dev|--staging] [--version tag]
check or install a release; this build's own channel is the default
check or install a release; this build's own channel is the default;
--version pins that release until a plain codeaf update moves it
codeaf cache
what the shared build cache holds, and how big it is
codeaf cache clean [--yes]
Expand Down Expand Up @@ -689,8 +690,9 @@ A variable that pins a ` + "`/settings`" + ` row wins over that row.
profiles, catalog, skills (default ~/.codeaf). Move it
to run a disposable store that touches nothing of yours.
CODEAF_NO_UPDATE_CHECK
1 skips the launch check; /update and codeaf update
still work.
1 skips the launch check and the update a released
codeaf installs as it opens; /update and codeaf
update still work.
CODEAF_PROFILE_DIR where measured behaviour is kept (default CODEAF_HOME)
CODEAF_CALL_LOG the model-call log (default <profile>/logs/calls.jsonl).
"off" writes nothing; any other value is the file to
Expand Down
275 changes: 275 additions & 0 deletions cmd/codeaf/prelaunch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
package main

import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"strings"
"time"

"github.com/Agent-Field/codeaf/internal/buildinfo"
"github.com/Agent-Field/codeaf/internal/config"
internalenv "github.com/Agent-Field/codeaf/internal/env"
codeupdate "github.com/Agent-Field/codeaf/internal/update"
)

// ── THE UPDATE BEFORE THE CHAT OPENS ────────────────────────────────────────
//
// A person who typed `codeaf` on a released build gets the newest stable
// release BEFORE anything is drawn: the launch looks once, installs, and hands
// the terminal to the new build with the same arguments. Everything here is
// bounded so that a launch is never held hostage by the network:
//
// - The look is free on most launches — the answer is cached for a day — and
// otherwise waits at most [codeupdate.PrelaunchLookup]. A look that got no
// answer is not waited on again for an hour.
// - The install waits at most [prelaunchInstallWait], and ctrl+c ends it at
// once. Either way the build already on disk opens, and the chat's own
// background road (internal/tui3's updateoffer.go) is still there.
// - Anything that goes wrong is one line on stderr, and the chat opens.
//
// WHO IS INSTALLED UNASKED is [codeupdate.AutomaticFor]'s question, and it is
// asked here with the person's half beside it: the `update.auto` row, a pin, a
// release they skipped. A launch that will not install still SAYS what is out,
// on one line in the terminal, before the chat says it again inside.
//
// IT RUNS ONLY WHERE A PERSON IS WAITING ON IT: standard input and output are a
// terminal, `CI` is unset, and the launch is not `--once`. A source build, a
// `CODEAF_NO_UPDATE_CHECK=1` shell or a launch the last one started does not
// look at all.

// updatedFromEnv is how a launch tells the build it just installed what it
// replaced. The new build reads it, clears it so no child inherits it, and
// does not look again — which is also what makes a restart loop impossible.
const updatedFromEnv = "CODEAF_UPDATED_FROM"

// prelaunchInstallWait bounds the download a person watches before the chat
// opens. It is long enough for a release on an ordinary connection and short
// enough that a slow one opens the build on disk rather than holding the
// terminal; the chat's own road finishes the job in the background.
const prelaunchInstallWait = 20 * time.Second

var (
prelaunchRevision = buildinfo.Revision
prelaunchExecutable = os.Executable
prelaunchClient = codeupdate.NewClient
prelaunchInstall = codeupdate.Install
prelaunchRelaunch = codeupdate.Relaunch
prelaunchArguments = func() []string { return append([]string(nil), os.Args[1:]...) }
prelaunchOut io.Writer = os.Stderr
prelaunchNow = time.Now
prelaunchLookupWait = codeupdate.PrelaunchLookup
prelaunchInstallFor = prelaunchInstallWait
// prelaunchAttended is whether a person is at this terminal: both ends of
// it are a terminal and no CI runner says otherwise. A pipe, a script or a
// test harness is nobody to restart in front of.
prelaunchAttended = func() bool {
return stdinIsTerminal(os.Stdin) && stdinIsTerminal(os.Stdout) && strings.TrimSpace(internalenv.Value("CI")) == ""
}
// prelaunchMeterLive is whether the moving download line has a terminal to
// move on: the writer it draws to is standard error.
prelaunchMeterLive = func() bool { return stdinIsTerminal(os.Stderr) }
// prelaunchInterrupt turns ctrl+c into "open what is on disk now" for as
// long as the install runs, and gives the signal back afterwards.
prelaunchInterrupt = func(parent context.Context) (context.Context, context.CancelFunc) {
return signal.NotifyContext(parent, os.Interrupt)
}
)

// launchUpdate is what this launch's own road leaves for the surface it opens.
// It is package state because four doors open the surface and all of them go
// through [runSurface], which is the one place that reads it.
var launchUpdate launchUpdateFacts

type launchUpdateFacts struct {
// updatedFrom is the build the previous process replaced, or "".
updatedFrom string
// deferred is the release a person stopped with ctrl+c at this launch. The
// chat names it on its quiet line instead of counting down to it again.
deferred string
}

// updateBeforeLaunch is the launch's own road. It returns only to let the chat
// open; a successful install never returns, because the new build has taken
// the terminal.
func updateBeforeLaunch() {
launchUpdate = launchUpdateFacts{}
// THE BUILD A LAUNCH JUST STARTED DOES NOT LOOK AGAIN. The variable is
// cleared before anything else, so an engine or a worker this process
// starts never inherits a fact about a launch it was not part of.
if from := strings.TrimSpace(internalenv.Get(updatedFromEnv)); from != "" {
// BOTH SPELLINGS GO, because the one door that read it answers from
// either: a shell exporting the legacy spelling would otherwise switch
// this road off for good and claim an update on every launch.
_ = os.Unsetenv(updatedFromEnv)
_ = os.Unsetenv(internalenv.Legacy(updatedFromEnv))
launchUpdate.updatedFrom = from
return
}
running := strings.TrimSpace(prelaunchRevision())
if codeupdate.Kind(running) == "other" || internalenv.Get(codeupdate.NoUpdateCheckEnv) == "1" || !prelaunchAttended() {
return
}
executable, err := prelaunchExecutable()
if err != nil {
return
}
profileDir := config.ProfileDir()
client := prelaunchClient(running, codeupdate.CheckTimeout)
look, cancel := context.WithTimeout(context.Background(), prelaunchLookupWait)
available, newer := codeupdate.CheckLaunch(look, codeupdate.CheckOptions{
Running: running, ProfileDir: profileDir, Client: client,
Executable: executable, Prelaunch: true, Now: prelaunchNow,
})
cancel()
if !newer {
return
}
tag := strings.TrimSpace(available.Latest)
command := codeupdate.InstallName(executable) + " update"
curl := codeupdate.CurlLine(executable, codeupdate.FollowedChannel(running))
headline := "codeaf " + tag + " is out · you have " + running
// THE ORDER IS THE CHAT'S ORDER (internal/tui3's tookUpdateCheck), so the
// terminal and the chat never give one launch two different answers.
switch {
case codeupdate.InstallRefusal(executable, curl) != "":
fmt.Fprintln(prelaunchOut, headline+" · "+codeupdate.InstallRefusal(executable, curl))
return
case strings.TrimSpace(available.Pinned) != "":
fmt.Fprintln(prelaunchOut, "codeaf "+tag+" is out · you have "+running+", pinned · "+command+" installs it and unpins")
return
case !config.UpdateAutoAt(profileDir) || !available.Automatic:
// THE BUILD'S HALF IS THE CHECK'S ANSWER (codeupdate.AutomaticFor on
// the resolved file), so this road and the chat cannot disagree about a
// symlinked install.
fmt.Fprintln(prelaunchOut, headline+" · "+command+" installs it")
return
}
state := codeupdate.LoadAutoState(profileDir)
if !codeupdate.ShouldOffer(state, tag, prelaunchNow()) || state.SlowRecently(tag, prelaunchNow()) {
// A RELEASE SOMEBODY SKIPPED, OR ONE STILL BACKING OFF AFTER A FAILED
// INSTALL, is not raised here — exactly as the chat does not raise it.
// ONE THAT DID NOT ARRIVE IN TIME AT A RECENT LAUNCH is left to the
// chat's background road too, which has no clock a person waits on:
// paying the same twenty seconds again would teach nothing new.
return
}
installBeforeLaunch(client, available, executable, curl, profileDir)
}

// installBeforeLaunch downloads and installs the release a launch found, then
// hands the terminal to it. Every way it can stop short says one line and
// returns, so the build already on disk opens.
func installBeforeLaunch(client *codeupdate.Client, available codeupdate.Available, executable, curl, profileDir string) {
running, tag := strings.TrimSpace(available.Running), strings.TrimSpace(available.Latest)
target, err := codeupdate.ExecutableTarget(func() (string, error) { return executable, nil })
if err != nil {
fmt.Fprintln(prelaunchOut, "codeaf "+tag+" is out · this codeaf could not be found on disk · opening "+running)
return
}
fmt.Fprintln(prelaunchOut, "updating codeaf "+running+" to "+tag+" · ctrl+c opens "+running+" now")
interrupted, stopInterrupt := prelaunchInterrupt(context.Background())
install, cancel := context.WithTimeout(interrupted, prelaunchInstallFor)
meter := newDownloadMeter(prelaunchOut, prelaunchMeterLive())
result, err := prelaunchInstall(install, codeupdate.InstallOptions{
Client: client, Target: target, Curl: curl, Automatic: true,
// THE RELEASE THE LOOK NAMED, with the moment it carried, and never a
// fresh channel resolve: what was announced is what is installed.
Release: codeupdate.Release{Tag: tag, PublishedAt: available.LatestPublished},
Progress: meter.observe,
})
timedOut := errors.Is(install.Err(), context.DeadlineExceeded)
stopped := interrupted.Err() != nil
cancel()
stopInterrupt()
meter.clear()
if err != nil {
var refusal *codeupdate.RefusalError
switch {
case stopped:
launchUpdate.deferred = tag
fmt.Fprintln(prelaunchOut, "update stopped · opening "+running)
case timedOut:
// WRITTEN DOWN, so the next launch does not start the same download
// against the same clock ([codeupdate.AutoState.SlowRecently]).
codeupdate.AutoSlow(profileDir, tag)
fmt.Fprintln(prelaunchOut, "codeaf "+tag+" is still downloading · opening "+running+" now · the chat installs it in the background")
case errors.As(err, &refusal):
fmt.Fprintln(prelaunchOut, refusal.Reason)
default:
// A REAL FAILURE COUNTS, on the same record the chat keeps, so a
// release that cannot be installed backs off instead of costing
// every launch the same wait.
codeupdate.AutoFailure(profileDir, tag, err)
fmt.Fprintln(prelaunchOut, "codeaf "+tag+" could not be installed · "+err.Error()+" · opening "+running)
}
return
}
codeupdate.AutoSuccess(profileDir)
if result.Already {
// THE FILE ALREADY HELD THE RELEASE — another window installed it after
// this process started — so nothing was installed here, and the line
// says so. The file on disk is still newer than this process, so the
// terminal is still handed to it.
fmt.Fprintln(prelaunchOut, "codeaf "+tag+" is already on disk · opening it")
} else {
fmt.Fprintln(prelaunchOut, "codeaf "+tag+" installed · opening it")
}
_ = os.Setenv(updatedFromEnv, running)
if err := prelaunchRelaunch(codeupdate.Plan{Path: result.Path, Args: prelaunchArguments()}); err != nil {
// The file on disk is the new build; this process is still the old one,
// and it is better to open it than to leave the person at a prompt.
_ = os.Unsetenv(updatedFromEnv)
fmt.Fprintln(prelaunchOut, "could not start codeaf "+tag+": "+err.Error()+" · opening "+running+" · the next launch opens "+tag)
}
}

// downloadMeter draws the one line that moves while a release arrives. It
// redraws at most ten times a second and only when the shown number changes,
// because a 32 KB read is far finer than anybody can see.
//
// IT DRAWS ONLY ON A TERMINAL. The launch runs with stdin and stdout on one,
// but `codeaf 2>log` sends this writer to a file, where a carriage return and
// an erase-line sequence are litter rather than a moving number.
type downloadMeter struct {
out io.Writer
live bool
shown string
last time.Time
drew bool
}

func newDownloadMeter(out io.Writer, live bool) *downloadMeter {
return &downloadMeter{out: out, live: live}
}

func (m *downloadMeter) observe(received, total int64) {
if !m.live {
return
}
line := " " + megabytes(received)
if total > 0 {
line += " of " + megabytes(total)
}
line += " MB"
now := time.Now()
if line == m.shown || (m.drew && now.Sub(m.last) < 100*time.Millisecond && received != total) {
return
}
m.shown, m.last, m.drew = line, now, true
fmt.Fprint(m.out, "\r\x1b[K"+line)
}

// clear takes the moving line away so whatever is said next starts clean.
func (m *downloadMeter) clear() {
if m.drew {
fmt.Fprint(m.out, "\r\x1b[K")
}
}

func megabytes(bytes int64) string {
return fmt.Sprintf("%d", (bytes+500_000)/1_000_000)
}
Loading
Loading