Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions cmd/codeaf/chatv3_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ func (l *localLink) dial() (io.ReadWriteCloser, error) {
if err != nil {
return nil, err
}
// A staleHost refusal is passed through UNTOUCHED: openChatV3Local reads it
// back out of the dial's error to decide that this launch may not fall back
// to a conversation in this process.
note, err := clearStaleEngineHost(l.workspace)
if err != nil {
return nil, err
Expand All @@ -149,6 +152,16 @@ type hostUnreachable struct{ reason string }

func (h *hostUnreachable) Error() string { return h.reason }

// hostRefusal is an engine on this machine that holds the workspace and will not
// give it up: a busy one on another wire, one holding work, or one too old to be
// asked. It is deliberately NOT [hostUnreachable], because the floor is not
// available here — that engine holds the conversation's journal, so opening the
// conversation in this process would be a second writer beside it. The launch
// refuses in the host's own words instead (openChatV3Local states the whole of it).
type hostRefusal struct{ sentence string }

func (h *hostRefusal) Error() string { return h.sentence }

// hostFallbackReason is the clause a person reads after "this conversation
// opened in this terminal instead, and ends with it: ". The two host failures
// written in machinery words are translated here, at the surface that knows
Expand Down Expand Up @@ -206,9 +219,19 @@ func openChatV3Local(launch localLaunch) error {
client, err = remote.Roam("", hello, remote.Roaming{Dial: link.dial})
}
if err != nil {
// AN ENGINE THAT HOLDS THE WORKSPACE IS NOT AN UNREACHABLE ONE. The
// in-process floor is right for a machine where no host could be
// reached or started — and wrong here, because this host holds the
// conversation's journal: opening it in this process as well would be a
// second writer beside it. So the sentence is the whole answer and the
// launch refuses (staleHost is the takeover rule's own refusal).
var stale *staleHost
if errors.As(err, &stale) {
return &hostRefusal{sentence: stale.reason}
}
// A host that cannot be reached or started is not the end of the
// launch: the in-process door is the floor, and the reason travels with
// the fallback so a stale host's own sentence is still read.
// the fallback so nothing is swallowed.
return &hostUnreachable{reason: hostFallbackReason(err)}
}
closeClient := func() { _ = client.Close() }
Expand Down Expand Up @@ -428,9 +451,9 @@ func localDoors(options *tui3.Options, welcome remote.Welcome, settings config.C
// point at.
//
// The three launches that do not: one the engine answered (err is nil), one it
// refused for any other reason — unreachable, a wrong wire version, a stale
// host, all of which are the in-process door's own floor — and `--once`, which
// has no screen to land an offer on and says the sentence instead
// refused for any other reason — unreachable, a wrong wire version, and a stale
// host, which is a refusal rather than a fallback — and `--once`, which has no
// screen to land an offer on and says the sentence instead
// ([sessionHeldElsewhereSentence]).
func localAskAgainAfterRefusal(launch localLaunch, err error) (string, bool) {
if err == nil || launch.once != "" || !hostHeldRefusal(err) {
Expand Down
2 changes: 1 addition & 1 deletion cmd/codeaf/chatv3_local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,7 @@ func TestALongStatePathIsSaidInWordsAndNotInTheEnginesOwn(t *testing.T) {
t.Fatalf("the %s speaks in machinery words: %q", name, said)
}
}
written := busyEngineHostSentence(true)
written := busyEngineHostSentence("/home/somebody/api", true)
if got := hostFallbackReason(errors.New(written)); got != written {
t.Fatalf("a sentence already written for a person became %q, want %q", got, written)
}
Expand Down
38 changes: 36 additions & 2 deletions cmd/codeaf/chatv3_surface.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"

"github.com/Agent-Field/codeaf/internal/buildinfo"
"github.com/Agent-Field/codeaf/internal/config"
internalenv "github.com/Agent-Field/codeaf/internal/env"
"github.com/Agent-Field/codeaf/internal/tui3"
codeupdate "github.com/Agent-Field/codeaf/internal/update"
Expand Down Expand Up @@ -58,23 +59,56 @@ func runSurface(ctx context.Context, options tui3.Options) error {
options.UpdateRunning = revision
options.UpdateCurl = curl
options.UpdateArgs = surfaceArguments()
// THE AUTOMATIC UPDATER IS WIRED HERE AND NOWHERE ELSE, so all four doors
// that open this surface get one coordinator: one setting, one remembered
// dismissal, one failure count and one install lock per profile. The lock
// is what keeps two terminals from replacing this executable at once, and
// the surface hands back only a release closure so it never touches a file
// itself.
autoUpdate := config.UpdateAutoAt(options.ProfileDir)
options.UpdateAuto = &tui3.UpdateCoordinator{
Enabled: autoUpdate,
// THE LIVE READER IS THE PROFILE ITSELF, and it is what the countdown
// and the deadline ask: /settings updates this window's Enabled, while
// a row changed in ANOTHER window is only visible by reading it again.
EnabledLive: func() bool { return config.UpdateAutoAt(options.ProfileDir) },
State: func() codeupdate.AutoState { return codeupdate.LoadAutoState(options.ProfileDir) },
Dismiss: func(tag string) error { return codeupdate.DismissRelease(options.ProfileDir, tag) },
Failure: func(tag string, failure error) { codeupdate.AutoFailure(options.ProfileDir, tag, failure) },
Success: func() { codeupdate.AutoSuccess(options.ProfileDir) },
Disable: func() error { return config.SaveUpdateAuto(options.ProfileDir, false) },
// 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) },
}
options.UpdateCheck = func(check context.Context) (codeupdate.Available, bool) {
return codeupdate.CheckLaunch(check, codeupdate.CheckOptions{
Running: revision, ProfileDir: options.ProfileDir, Client: client,
// OFF STILL LOOKS, ONCE, AND ONLY THE ENVIRONMENT SILENCES THE
// REQUEST. `update.auto` off means codeaf never installs on its
// own and the surface says what is out on one dim line; the
// check itself is a read, not an act, and a person who turned the
// row off still deserves to know a release exists. Only
// CODEAF_NO_UPDATE_CHECK, which is a shell's decision to make no
// request at all, takes the look away — the launch check's own
// [codeupdate.CheckOptions.Disabled] contract.
Disabled: internalenv.Get(codeupdate.NoUpdateCheckEnv) == "1",
Executable: executable,
})
}
options.ResolveUpdate = client.Select
options.InstallUpdate = func(install context.Context, release codeupdate.Release) (codeupdate.InstallResult, error) {
options.InstallUpdate = func(install context.Context, options codeupdate.InstallOptions) (codeupdate.InstallResult, error) {
// Resolution belongs to the off-frame installation command. A launch may
// live on a slow mount, but its first frame never has to wait for that
// mount merely because /update exists.
target, err := surfaceExecutable(func() (string, error) { return executable, executableErr })
if err != nil {
return codeupdate.InstallResult{}, err
}
return codeupdate.Install(install, codeupdate.InstallOptions{Client: client, Release: release, Target: target, Curl: curl})
options.Client = client
options.Target = target
options.Curl = curl
return codeupdate.Install(install, options)
}
// The byte meter, off unless a developer named a log file (wire.go). It
// measures what this surface DRAWS and is therefore as local as the terminal
Expand Down
128 changes: 127 additions & 1 deletion cmd/codeaf/chatv3_surface_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -151,7 +152,7 @@ func TestRunSurfaceWiresTheDeferredLaunchCheckAndInstallerThroughRealInit(t *tes
t.Fatalf("surface exit: %v", err)
}

result, err := seen.InstallUpdate(context.Background(), codeupdate.Release{Tag: "v0.2.0", Repository: "Agent-Field/codeaf"})
result, err := seen.InstallUpdate(context.Background(), codeupdate.InstallOptions{Release: codeupdate.Release{Tag: "v0.2.0", Repository: "Agent-Field/codeaf"}})
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -344,3 +345,128 @@ func TestTheCrashLogAndTheRunningLogAreOneFileUnderAProfile(t *testing.T) {
t.Fatalf("the sentence on screen names %q, not the file both wrote: %s", stderr.String(), want)
}
}

// TestRunSurfaceChecksWithAutoOffAndNeverInstalls is the production regression
// for the wiring the fixture tests cannot see: `update.auto` OFF must still make
// the ONE launch request and say what is out (the quiet notice), while the
// surface never offers a countdown and never replaces the file by itself. Only
// CODEAF_NO_UPDATE_CHECK takes the look away.
//
// It drives [runSurface] itself, so the assertion is about the closure this door
// hands the surface rather than about a seam a test built for itself.
func TestRunSurfaceChecksWithAutoOffAndNeverInstalls(t *testing.T) {
asset := []byte("new executable")
digest := sha256.Sum256(asset)
var releases atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
switch {
case strings.HasSuffix(request.URL.Path, "/releases/latest"):
releases.Add(1)
fmt.Fprint(w, `{"tag_name":"v0.2.0"}`)
case strings.HasSuffix(request.URL.Path, "/checksums.txt"):
fmt.Fprintf(w, "%x codeaf-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH)
default:
_, _ = w.Write(asset)
}
}))
defer server.Close()

target := filepath.Join(t.TempDir(), "codeaf")
if err := os.WriteFile(target, []byte("old executable"), 0o755); err != nil {
t.Fatal(err)
}
oldRun := runSurfaceProgram
oldClient, oldExecutable := surfaceUpdateClient, surfaceExecutable
oldRunningExecutable := surfaceRunningExecutable
oldRevision, oldArguments := surfaceRevision, surfaceArguments
t.Cleanup(func() {
runSurfaceProgram = oldRun
surfaceUpdateClient, surfaceExecutable = oldClient, oldExecutable
surfaceRunningExecutable = oldRunningExecutable
surfaceRevision, surfaceArguments = oldRevision, oldArguments
})
frame := &surfaceFrameWriter{ready: make(chan struct{})}
input, inputWriter := io.Pipe()
defer inputWriter.Close()
optionsSeen := make(chan tui3.Options, 1)
runSurfaceProgram = func(ctx context.Context, options tui3.Options) error {
optionsSeen <- options
options.Input = input
options.Output = frame
options.Width, options.Height = 80, 24
return tui3.Run(ctx, options)
}
surfaceUpdateClient = func(revision string, timeout time.Duration) *codeupdate.Client {
return &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL, Revision: revision}
}
surfaceExecutable = func(executable func() (string, error)) (string, error) { return target, nil }
surfaceRunningExecutable = func() (string, error) { return target, nil }
surfaceRevision = func() string { return "v0.1.1" }
surfaceArguments = func() []string { return []string{"chat"} }

profile := t.TempDir()
if err := config.SaveUpdateAuto(profile, false); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
finished := make(chan error, 1)
go func() {
finished <- runSurface(ctx, tui3.Options{
Agent: &quietAgent{}, Workspace: "/tmp/lab", ProfileDir: profile, SessionFile: "/tmp/this.jsonl",
})
}()
seen := <-optionsSeen
if seen.UpdateAuto == nil || seen.UpdateAuto.Enabled || seen.UpdateAuto.EnabledLive == nil {
cancel()
t.Fatalf("auto off did not wire an off coordinator with a live reader: %+v", seen.UpdateAuto)
}

// THE ONE LOOK HAPPENS AND NAMES THE RELEASE. The offer's own countdown
// never appears, because the automatic road is off.
deadline := time.Now().Add(5 * time.Second)
for !strings.Contains(frame.String(), "is out") && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if !strings.Contains(frame.String(), "v0.2.0 is out") {
cancel()
t.Fatalf("the launch check did not say what is out with auto off:\n%s", frame.String())
}
if got := releases.Load(); got != 1 {
cancel()
t.Fatalf("release check requests = %d, want exactly 1", got)
}
if strings.Contains(frame.String(), "installs in") {
cancel()
t.Fatalf("auto off offered a countdown:\n%s", frame.String())
}
installed, err := os.ReadFile(target)
if err != nil || string(installed) != "old executable" {
cancel()
t.Fatalf("auto off replaced the executable: %q, %v", installed, err)
}
// AND THE COORDINATOR READS THE ROW FROM THE PROFILE, not from the value
// captured at wiring: another window's change is what the grace rechecks.
if err := config.SaveUpdateAuto(profile, true); err != nil {
t.Fatal(err)
}
if !seen.UpdateAuto.EnabledLive() {
cancel()
t.Fatal("the live reader did not see another window turn auto update on")
}
if err := config.SaveUpdateAuto(profile, false); err != nil {
t.Fatal(err)
}
cancel()
if err := <-finished; err != nil && !errors.Is(err, context.Canceled) {
t.Fatalf("surface exit: %v", err)
}

// THE ENVIRONMENT IS THE ONLY THING THAT SILENCES THE LOOK.
t.Setenv(codeupdate.NoUpdateCheckEnv, "1")
if available, show := seen.UpdateCheck(context.Background()); show || available.Latest != "" {
t.Fatalf("CODEAF_NO_UPDATE_CHECK did not silence the check: %+v show=%t", available, show)
}
if got := releases.Load(); got != 1 {
t.Fatalf("a silenced check still made %d requests", got)
}
}
Loading
Loading