diff --git a/cmd/codeaf/chatv3_host.go b/cmd/codeaf/chatv3_host.go index 981da0ff3e..9a6a7ec5de 100644 --- a/cmd/codeaf/chatv3_host.go +++ b/cmd/codeaf/chatv3_host.go @@ -13,6 +13,7 @@ import ( "sync/atomic" "time" + "github.com/Agent-Field/codeaf/internal/buildinfo" "github.com/Agent-Field/codeaf/internal/catalog" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/enginehost" @@ -31,7 +32,7 @@ import ( // ── `codeaf chat --host devbox` ───────────────────────────────────────────── // // The surface runs here and the session runs there. This file is the door: it -// parses the destination, starts `ssh codeaf engine`, completes the +// parses the destination, starts the matching program over ssh, completes the // handshake, and hands the connection to the same surface a local launch opens. // // THE PROMPT LAW, and it is the reason this whole path is shaped the way it is: @@ -177,10 +178,8 @@ func (l *engineLink) spawn() (io.ReadWriteCloser, error) { // The remote command, as the far machine's login shell will read it. The // workspace is quoted because a path with a space in it is a path, and an // unquoted one would arrive at `codeaf engine` as two arguments. - remoteCommand := "codeaf engine" - if l.workspace != "" { - remoteCommand += " --workspace " + shellQuote(l.workspace) - } + remoteCommand := hostEngineCommand(programName(), l.dest, l.workspace) + // -T because there is nothing interactive on the far end: the engine reads // frames on stdin and writes them on stdout, and a pseudo-terminal in the // middle would turn a newline into a carriage return and a frame into @@ -210,6 +209,25 @@ func (l *engineLink) spawn() (io.ReadWriteCloser, error) { return pipePair{r: stdout, w: stdin}, nil } +// programName follows the executable file, including custom installation names. +func programName() string { return codeupdate.InstallName(enginehost.ThisBinary()) } + +// hostEngineCommand selects on the far machine in the same ssh hop. An engine +// failure never triggers a fallback: only absence of the requested file does. +func hostEngineCommand(name, dest, workspace string) string { + args := " engine" + if workspace != "" { + args += " --workspace " + shellQuote(workspace) + } + primary := "exec " + shellQuote(name) + args + if name == "codeaf" { + return primary + } + notice := "printf '%s has no %s; using its codeaf (%s)\\n' " + shellQuote(dest) + " " + shellQuote(name) + " \"$(codeaf version)\" >&2" + return "if command -v " + shellQuote(name) + " >/dev/null 2>&1; then " + primary + + "; elif command -v codeaf >/dev/null 2>&1; then " + notice + "; exec codeaf" + args + "; else " + primary + "; fi" +} + // stderrWriter keeps the launch-time prompts visible but keeps redial output // inside the session's diagnostic tail. A reconnect happens while Bubble Tea // owns the terminal's alternate screen, so writing ssh's transient errors to @@ -389,10 +407,8 @@ func (l *engineLink) diagnose(dest string, cause error) error { } } -const missingHostCommandText = "the program is called codeaf now (it was aforge before 2026-09-14) and must be installed on %s under that name — put it on the PATH that a non-login ssh command sees" // legacy-name - func missingHostCommand(dest string) error { - return fmt.Errorf(missingHostCommandText, dest) + return fmt.Errorf("%s is not installed on %s — run %s on %s; put it on the PATH that a non-login ssh command sees", programName(), dest, codeupdate.CurlLine(enginehost.ThisBinary(), codeupdate.FollowedChannel(buildinfo.String())), dest) } // mentionsMissingCommand reads the far shell's own wording. The three spellings diff --git a/cmd/codeaf/chatv3_host_test.go b/cmd/codeaf/chatv3_host_test.go index c23fa218d7..fa44f7922d 100644 --- a/cmd/codeaf/chatv3_host_test.go +++ b/cmd/codeaf/chatv3_host_test.go @@ -155,7 +155,7 @@ func TestH9HostMissingCommandNamesTheCurrentInstallation(t *testing.T) { } } got := missingHostCommand("devbox").Error() - for _, want := range []string{"called codeaf now", "before 2026-09-14", "installed on devbox under that name"} { + for _, want := range []string{"not installed on devbox", "https://agentfield.ai/get/", "non-login ssh"} { if !strings.Contains(got, want) { t.Fatalf("host remedy %q does not contain %q", got, want) } diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index d1d03ba7d8..ab48c971b1 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -50,12 +50,14 @@ import ( "path/filepath" "strings" "sync" + "time" "github.com/Agent-Field/codeaf/internal/buildinfo" "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/enginehost" + "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" @@ -299,8 +301,8 @@ func clearStaleEngineHost(workspace string) (string, error) { // WHICH IS OLDER IS THE BUILD MOMENT AND NOTHING ELSE, and a tie is never a // replacement, so two windows on two builds agree and the slot cannot go back // and forth. A NEWER ENGINE IS JOINED when it speaks this build's wire and -// refused in words when it does not — the older half of that pair is this -// binary, and the sentence names it. +// handed to its executable when it does not. If that file cannot be used, +// the older half of the pair is this binary, and the sentence names its update. func clearStaleEngineHostAs(workspace string, me engineBuild) (string, error) { held, err := enginehost.Inspect(workspace) if err != nil { @@ -337,7 +339,15 @@ func clearStaleEngineHostAs(workspace string, me engineBuild) (string, error) { if host.Version == remote.Version { return settleCellsMode(workspace, host, me) } - return "", &staleHost{reason: newerEngineHostSentence(hostWorkspace(host, workspace))} + if _, handedOff := env.Lookup(engineHandoffEnv); !handedOff && host.BuiltAt.After(me.BuiltAt) { + if info, err := os.Stat(host.Binary); err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0111 != 0 { + environment := append(os.Environ(), engineHandoffEnv+"=1") + if err := execEngineHolder(host.Binary, os.Args, environment); err != nil { + return "", &staleHost{reason: newerEngineHolderSentence(host, workspace) + " (" + err.Error() + ")"} + } + } + } + return "", &staleHost{reason: newerEngineHolderSentence(host, workspace)} } // Too old to answer at all: it cannot say whether it is holding work, so // nothing here may decide for it. Words, and the explicit stop. @@ -350,6 +360,12 @@ func clearStaleEngineHostAs(workspace string, me engineBuild) (string, error) { return olderEngineOnThisWire(workspace, held) } +const engineHandoffEnv = "CODEAF_ENGINE_HANDOFF" + +func newerEngineHolderSentence(host remote.HostSelf, workspace string) string { + return newerEngineHostSentence(hostWorkspace(host, workspace)) + "; the holder's executable " + shellQuote(host.Binary) + " is unavailable or cannot be used" +} + // olderEngineOnThisWire is an older engine that speaks this build's protocol: // the ordinary road. Busy means joined, idle means asked to go. func olderEngineOnThisWire(workspace string, held enginehost.Holder) (string, error) { @@ -544,9 +560,9 @@ func staleEngineHostSentence(busy bool, workspace string) string { } stop := engineStopWords(workspace) if busy { - return fmt.Sprintf("engine: %s is still running an older codeaf and something is still going in it — let that finish, or run %s on %s (interrupts active turns and tasks)", name, stop, name) + return fmt.Sprintf("engine: %s is still running an older %s — after work finishes, close every window for --workspace %s and reopen", name, programName(), shellQuote(workspace)) } - return fmt.Sprintf("engine: %s is still holding this conversation on an older codeaf — run %s on %s (interrupts active turns and tasks)", name, stop, name) + return fmt.Sprintf("engine: %s is still holding this conversation on an older %s — run %s on %s", name, programName(), stop, name) } // unknownOlderEngineHostSentence is what a person reads when the process holding @@ -562,8 +578,7 @@ func unknownOlderEngineHostSentence(workspace string) string { if strings.TrimSpace(name) == "" { name = "that machine" } - stop := engineStopWords(workspace) - return fmt.Sprintf("engine: %s is holding this conversation on an older codeaf that cannot say what it is or what it is doing, and this build will not end work it cannot see — run %s on %s if you want it stopped (interrupts active turns and tasks)", name, stop, name) + return fmt.Sprintf("engine: %s is holding this conversation on an older %s that cannot say what it is or what it is doing — after work finishes, close every window for --workspace %s and reopen", name, programName(), shellQuote(workspace)) } // otherProtocolEngineHostSentence is the refusal for an older engine on a wire @@ -576,8 +591,7 @@ func otherProtocolEngineHostSentence(workspace string) string { if strings.TrimSpace(name) == "" { name = "that machine" } - stop := engineStopWords(workspace) - return fmt.Sprintf("engine: %s is holding this conversation on a codeaf that speaks a different protocol, and something is still going in it — let that finish, or run %s on %s (interrupts active turns and tasks)", name, stop, name) + return fmt.Sprintf("engine: %s is holding this conversation on a %s that speaks a different protocol — after work finishes, close every window for --workspace %s and reopen", name, programName(), shellQuote(workspace)) } // engineStopWords is the command a refusal tells a person to run: the explicit @@ -607,15 +621,7 @@ func newerEngineHostSentence(workspace string) string { if strings.TrimSpace(name) == "" { name = "that machine" } - self := enginehost.ThisBinary() - if strings.TrimSpace(self) == "" { - self = "this codeaf" - } - stop := "codeaf engine --status" - if workspace = strings.TrimSpace(workspace); workspace != "" { - stop += " --workspace " + workspace - } - return fmt.Sprintf("engine: %s is holding this conversation on a newer codeaf than %s — use the newer binary (%s on %s shows which one it is)", name, self, stop, name) + return fmt.Sprintf("engine: %s is holding this conversation on a newer %s — run %s update on this machine", name, programName(), programName()) } // hostWorkspace is what the host says it is holding, and what this process asked @@ -645,11 +651,11 @@ func busyEngineHostSentence(workspace string, waits bool) string { if strings.TrimSpace(name) == "" { name = "this machine" } - note := fmt.Sprintf("this window is using an older codeaf background engine on %s; restarting one window does not update it. Attached windows count as in use. After work finishes, close every window for this workspace and reopen", name) + note := fmt.Sprintf("this window is using an older %s background engine on %s; restarting one window does not update it. Attached windows count as in use. After work finishes, close every window for this workspace and reopen", programName(), name) if waits { note += " — the engine will step aside when all windows and work are gone" } - return note + ". Or run " + engineStopWords(workspace) + " on " + name + " (interrupts active turns and tasks); reconnect with this build" + return note } // replacedEngineHostSentence is the ONE LINE a takeover owes: which engine was diff --git a/cmd/codeaf/engine_handoff_unix.go b/cmd/codeaf/engine_handoff_unix.go new file mode 100644 index 0000000000..a3f23c09d3 --- /dev/null +++ b/cmd/codeaf/engine_handoff_unix.go @@ -0,0 +1,10 @@ +//go:build !windows + +package main + +import "syscall" + +// Exec retains the bridge's arguments and all three standard streams. +func execEngineHolder(binary string, args, environment []string) error { + return syscall.Exec(binary, args, environment) +} diff --git a/cmd/codeaf/engine_handoff_windows.go b/cmd/codeaf/engine_handoff_windows.go new file mode 100644 index 0000000000..43acfe8533 --- /dev/null +++ b/cmd/codeaf/engine_handoff_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package main + +import "fmt" + +func execEngineHolder(binary string, args, environment []string) error { + return fmt.Errorf("automatic executable handoff is unavailable on this platform") +} diff --git a/cmd/codeaf/engine_stale_test.go b/cmd/codeaf/engine_stale_test.go index fc16286817..97540b6b13 100644 --- a/cmd/codeaf/engine_stale_test.go +++ b/cmd/codeaf/engine_stale_test.go @@ -269,7 +269,7 @@ func TestAnOlderEngineHoldingWorkKeepsItAndIsJoined(t *testing.T) { if err != nil { t.Fatalf("an older engine holding work was refused rather than joined: %v", err) } - for _, want := range []string{"older codeaf background engine", "Attached windows count as in use", "all windows and work are gone", "interrupts active turns and tasks", engineStopWords(workspace)} { + for _, want := range []string{"older " + programName() + " background engine", "Attached windows count as in use", "all windows and work are gone"} { if !strings.Contains(note, want) { t.Fatalf("the busy line %q does not say %q", note, want) } @@ -405,7 +405,7 @@ func TestALaunchRefusesAnIncompatibleBusyEngineRatherThanFallingBack(t *testing. if !errors.As(err, &refusal) { t.Fatalf("the launch answered %v, want the host's own refusal", err) } - if !strings.Contains(refusal.sentence, "different protocol") || !strings.Contains(refusal.sentence, "engine --stop") { + if !strings.Contains(refusal.sentence, "different protocol") || !strings.Contains(refusal.sentence, "close every window") { t.Fatalf("the refusal %q does not say what is holding it and the way out", refusal.sentence) } // AND NOTHING WAS OPENED IN THIS PROCESS: no project tree, no journal. @@ -431,8 +431,8 @@ func TestAnOlderBuildWithNoStampHoldingWorkIsJoined(t *testing.T) { if !strings.Contains(note, "older codeaf") { t.Fatalf("the notice did not say which engine this is: %q", note) } - if !strings.Contains(note, "After work finishes, close every window") || !strings.Contains(note, "interrupts active turns and tasks") { - t.Fatalf("the notice does not offer a safe update and warn about explicit stop: %q", note) + if !strings.Contains(note, "After work finishes, close every window") || strings.Contains(note, "--stop") { + t.Fatalf("the notice does not offer a safe update: %q", note) } if !hostAnswers(workspace) || !host.waitingSaid() || host.forced() { t.Fatal("a stampless older build was ended rather than joined") @@ -509,14 +509,14 @@ func TestANewerEngineIsJoinedAndNeverReplacedByAnOlderWindow(t *testing.T) { func TestANewerEngineOnAnotherWireIsRefusedAndLeftAlone(t *testing.T) { shortEngineHome(t) workspace := "/home/somebody/api" - standIn(t, workspace, remote.HostSelf{Version: remote.Version + 1, Build: "tomorrow", BuiltAt: later, Workspace: workspace}, false) + standIn(t, workspace, remote.HostSelf{Version: remote.Version + 1, Build: "tomorrow", BuiltAt: later, Workspace: workspace, Binary: "/missing/newer-program"}, false) _, err := clearStaleEngineHostAs(workspace, window("today", earlier)) var stale *staleHost if !errors.As(err, &stale) { t.Fatalf("a newer engine on another wire answered %v, want a refusal", err) } - if !strings.Contains(stale.reason, "newer codeaf") || !strings.Contains(stale.reason, "codeaf engine --status --workspace "+workspace) { + if !strings.Contains(stale.reason, "newer ") || !strings.Contains(stale.reason, " update") { t.Fatalf("the refusal does not say this binary is the older one and how to see which is newer: %q", stale.reason) } if !hostAnswers(workspace) { @@ -542,7 +542,7 @@ func TestAnOlderEngineOnAnotherWireHoldingWorkIsRefusedNotKilled(t *testing.T) { } // AND THE COMMAND IS COPY-PASTEABLE: the folder is quoted, because a path // with a space in it typed out of a sentence has to be one argument. - for _, want := range []string{"different protocol", "engine --stop", "--workspace " + shellQuote(workspace)} { + for _, want := range []string{"different protocol", "close every window", "--workspace " + shellQuote(workspace)} { if !strings.Contains(stale.reason, want) { t.Fatalf("the refusal %q does not say %q", stale.reason, want) } @@ -741,7 +741,7 @@ func TestAnEngineTooOldToBeAskedIsRefusedAndTheExplicitStopStillEndsIt(t *testin if !errors.As(err, &stale) { t.Fatalf("a too-old engine answered %v, want a refusal in words", err) } - for _, want := range []string{"cannot say what it is", "engine --stop", "--workspace " + shellQuote(workspace)} { + for _, want := range []string{"cannot say what it is", "close every window", "--workspace " + shellQuote(workspace)} { if !strings.Contains(stale.reason, want) { t.Fatalf("the refusal %q does not say %q", stale.reason, want) } @@ -848,3 +848,77 @@ func TestEngineStatusUpgradeGuidanceMatchesAdmission(t *testing.T) { }) } } + +// Re-execute a helper process so the production exec preserves real argv and +// stdio rather than replacing a function with a mock. +func TestNewerHolderHandsOffToItsExecutable(t *testing.T) { + if os.Getenv("CODEAF_TEST_HANDOFF_CHILD") == "1" { + shortEngineHome(t) + workspace := t.TempDir() + target := filepath.Join(t.TempDir(), "newer-program") + body := "#!/bin/sh\nprintf 'holder-exec:%s\\n' \"$CODEAF_ENGINE_HANDOFF\"\nprintf '%s\\n' \"$@\"\n" + if err := os.WriteFile(target, []byte(body), 0700); err != nil { + t.Fatal(err) + } + standIn(t, workspace, remote.HostSelf{Version: remote.Version + 1, Build: "newer", BuiltAt: later, Binary: target}, false) + _, err := clearStaleEngineHostAs(workspace, window("older", earlier)) + t.Fatalf("handoff returned: %v", err) + } + cmd := exec.Command(os.Args[0], "-test.run=^TestNewerHolderHandsOffToItsExecutable$") + cmd.Env = append(os.Environ(), "CODEAF_TEST_HANDOFF_CHILD=1") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("handoff: %v: %s", err, out) + } + if !strings.Contains(string(out), "holder-exec:1\n-test.run=^TestNewerHolderHandsOffToItsExecutable$\n") { + t.Fatalf("argv or guard lost: %s", out) + } +} + +func TestNewerHolderHandoffCannotLoopOrUseAMissingExecutable(t *testing.T) { + for _, tc := range []struct { + name string + mode os.FileMode + guard bool + value string + }{ + {"missing", 0, false, ""}, {"not-executable", 0600, false, ""}, {"guarded", 0700, true, "1"}, {"empty-guard", 0700, true, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + shortEngineHome(t) + workspace := t.TempDir() + target := filepath.Join(t.TempDir(), "devaf") + if tc.mode != 0 { + if err := os.WriteFile(target, []byte("#!/bin/sh\nexit 9\n"), tc.mode); err != nil { + t.Fatal(err) + } + } + if tc.guard { + t.Setenv(engineHandoffEnv, tc.value) + } + standIn(t, workspace, remote.HostSelf{Version: remote.Version + 1, Build: "newer", BuiltAt: later, Binary: target, Busy: true}, false) + _, err := clearStaleEngineHostAs(workspace, window("older", earlier)) + if err == nil || !strings.Contains(err.Error(), target) || !strings.Contains(err.Error(), " update") || strings.Contains(err.Error(), "--stop") { + t.Fatalf("unsafe refusal: %v", err) + } + if !hostAnswers(workspace) { + t.Fatal("holder was stopped") + } + }) + } +} + +func TestChannelsShareOneWorkspaceAndTheNewerBuildWins(t *testing.T) { + shortEngineHome(t) + workspace := t.TempDir() + host := standIn(t, workspace, remote.HostSelf{Version: remote.Version, Build: "newer", BuiltAt: later, Binary: "/bin/devaf", Busy: true}, false) + for _, name := range []string{"/bin/codeaf", "/bin/devaf", "/bin/custom-af"} { + note, err := clearStaleEngineHostAs(workspace, engineBuild{Build: "older", BuiltAt: earlier, Binary: name}) + if err != nil || note != "" { + t.Fatalf("%s could not join: %q %v", name, note, err) + } + if !hostAnswers(workspace) || host.forced() { + t.Fatalf("%s replaced newer holder", name) + } + } +} diff --git a/cmd/codeaf/host_channel_test.go b/cmd/codeaf/host_channel_test.go new file mode 100644 index 0000000000..d8c03f285e --- /dev/null +++ b/cmd/codeaf/host_channel_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "bytes" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// The fake ssh executes the actual remote command in a separate shell. No +// model or daemon is needed to observe which executable and arguments arrive. +func TestHostLaunchAndRedialRunTheSameProgram(t *testing.T) { + dir := t.TempDir() + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + name := filepath.Base(self) + write := func(name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body), 0700); err != nil { + t.Fatal(err) + } + } + write("ssh", "for arg do command=$arg; done\nexec /bin/sh -c \"$command\"\n") + write(name, "printf 'same-program\\n'; printf '%s\\n' \"$@\"\n") + write("codeaf", "printf 'wrong-program\\n'\n") + t.Setenv("PATH", dir) + t.Setenv("CODEAF_HOME", t.TempDir()) + link := &engineLink{dest: "test-host", workspace: "my app/it's here"} + for range 2 { + pipe, err := link.spawn() + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(pipe) + _ = pipe.Close() + if err != nil { + t.Fatal(err) + } + _ = link.reap() + if string(got) != "same-program\nengine\n--workspace\nmy app/it's here\n" { + t.Fatalf("remote launch ran %q", got) + } + } +} + +func TestMissingHostCommandIncludesChannelInstall(t *testing.T) { + got := missingHostCommand("test-host").Error() + if !strings.Contains(got, "https://agentfield.ai/get/") { + t.Fatalf("no installation command: %s", got) + } +} + +func TestHostChannelSelectionThroughRemoteShell(t *testing.T) { + for _, tc := range []struct { + name string + primary, stable bool + want string + notice bool + exit int + }{ + {"devaf", true, true, "primary", false, 0}, + {"stageaf", true, true, "primary", false, 0}, + {"custom-af", false, true, "stable", true, 0}, + {"devaf", false, false, "", false, 127}, + {"codeaf", false, true, "stable", false, 0}, + {"odd' name", true, true, "primary", false, 0}, + } { + t.Run(tc.name+tc.want, func(t *testing.T) { + dir := t.TempDir() + write := func(name, output string) { + t.Helper() + body := "#!/bin/sh\nif [ \"$1\" = version ]; then printf 'rev-123\\n'; else printf '%s\\n' " + shellQuote(output) + " \"$@\"; fi\n" + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0700); err != nil { + t.Fatal(err) + } + } + if tc.primary { + write(tc.name, "primary") + } + if tc.stable { + write("codeaf", "stable") + } + for range 2 { + cmd := exec.Command("/bin/sh", "-c", hostEngineCommand(tc.name, "test-host", "folder/it's here")) + cmd.Env = append(os.Environ(), "PATH="+dir) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if tc.exit != 0 { + var ex *exec.ExitError + if !errors.As(err, &ex) || ex.ExitCode() != tc.exit { + t.Fatalf("exit %v", err) + } + continue + } + if err != nil { + t.Fatal(err) + } + want := tc.want + "\nengine\n--workspace\nfolder/it's here\n" + if stdout.String() != want { + t.Fatalf("got %q, want %q", stdout.String(), want) + } + if tc.notice { + if stderr.String() != "test-host has no "+tc.name+"; using its codeaf (rev-123)\n" { + t.Fatalf("notice %q", stderr.String()) + } + } else if stderr.Len() != 0 { + t.Fatalf("unexpected notice %q", stderr.String()) + } + } + }) + } +} diff --git a/docs/changes/unreleased/1940-host-same-program.md b/docs/changes/unreleased/1940-host-same-program.md new file mode 100644 index 0000000000..d8f9e1420b --- /dev/null +++ b/docs/changes/unreleased/1940-host-same-program.md @@ -0,0 +1,15 @@ +--- +kind: fixed +title: Remote chat starts the same installed program +pr: 1940 +surface: [chat, engine, remote, docs] +invalidates: + - "Remote chat always started codeaf on the far machine, even when the local program had another name. It now selects the matching executable and explains a fallback to codeaf only when that name is absent." + - "A newer incompatible engine refused an older bridge even when its executable was available. On supported systems the bridge now hands over once, preserving its arguments and connection." + - "Version-mismatch guidance recommended stopping an engine while its windows could restart it. It now gives update guidance or asks for work to finish and all workspace windows to close before reopening." +--- + +Named installations keep their program name in update notices. All names still +share one engine per workspace. Differing wire versions stop cleanly with update +guidance; neither machine is updated automatically. Executable handoff is supported +on Unix systems; Windows retains the explicit update refusal. diff --git a/docs/remote-access-testing.md b/docs/remote-access-testing.md index 778a7c3c0e..bba39302db 100644 --- a/docs/remote-access-testing.md +++ b/docs/remote-access-testing.md @@ -27,7 +27,7 @@ Add `bin/codeaf` to the PATH of **both** machines you test with. The two halves the same protocol version: that version is checked at the door and a mismatch is refused with ``` - runs a different version of codeaf than this machine does — update the older one so both ends speak the same protocol + is on a newer build (); update to match — this connection needs matching versions ``` --- diff --git a/internal/config/settings.go b/internal/config/settings.go index eb37eac028..6f8fb4914a 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1050,6 +1050,8 @@ var OperatorEnvPins = []string{ // of asking its long-lived daemon (internal/cellstore). It is an escape // hatch that changes speed and never results, so it is plumbing. "CODEAF_ENGINE_DAEMON", + // Marks the one permitted executable handoff so a bridge cannot exec in a loop. + "CODEAF_ENGINE_HANDOFF", // CODEAF_BASH names the bash native Windows runs commands in when Git for // Windows is not where codeaf looks (internal/shell). A path to a program // is plumbing for the same reason CODEAF_FURROW is. diff --git a/internal/manual/chat/running-on-another-machine.md b/internal/manual/chat/running-on-another-machine.md index 252a62575f..1c3a3f7514 100644 --- a/internal/manual/chat/running-on-another-machine.md +++ b/internal/manual/chat/running-on-another-machine.md @@ -112,42 +112,26 @@ in a moment`, and pressing enter again once it is back sends it. The failed dial says what it found, rather than guessing: -- codeaf missing over there: the message says the program is called codeaf now, - gives its former name and date, and asks you to install it under the current name: - `the program is called codeaf now (it was … before 2026-09-14) and must be installed on under that name — put it on the PATH that a non-login ssh command sees` +- program missing over there: the message names the program you ran and its install + command. Run that command on the far machine and put the file on the PATH that + a non-login ssh command sees. - ssh could not get a session at all: `ssh could not open a session on `. ssh has already printed its own reason on the line above. - no ssh on this machine: `this machine has no ssh on its path, and --host is ssh`, or `could not start ssh: `. -If the two machines run different builds, the connection is refused at the door and this -side says: +Different builds can connect when they speak the same version. If their versions +are incompatible, the connection stops cleanly before opening a conversation. +For a newer far build, the line names its build and your update command, for example: ``` - runs a different version of codeaf than this machine does — update the older one so both ends speak the same protocol +tower is on a newer build (); devaf update to match — this connection needs matching versions ``` -When the **far** machine is the older one it refuses first, and what you see is its own -sentence rather than that one: - -``` -error: engine: this build speaks protocol and the surface speaks — the two halves have to be the same build -``` - -The two numbers are the two builds' own protocol versions. They change with releases; what -matters is only that they differ. - -If something on that machine is still holding a conversation from the older build, that -same sentence gains a clause naming it, because then the machine — not the binary — is -what is behind: - -``` -error: engine: this build speaks protocol and the surface speaks — the two halves have to be the same build, and this machine is still running the older one — run codeaf engine --stop here to retire it -``` - -Either way the fix is one command: update codeaf on the machine that is behind. Nothing is -negotiated down — two builds that might disagree about a frame must not find that out three -turns into a conversation. +Update the older machine. If the older engine still has windows attached, finish +work, close every window for that workspace, and reopen. Stopping it while another +window remains open makes that window restart it. This launch does not automatically +update either machine or move the terminal interface to the far machine. ## I rebuilt codeaf on my dev box and --host still refuses — updated codeaf but the versions differ @@ -172,32 +156,35 @@ as another build. Four things can be true: old process keeps what it was holding and steps aside at its first quiet moment; a host built before this ask ignores the note, so for one of those the line says the work is kept and **your next safe launch once the work is done and every window has left** opens from - the new build. An attached idle window counts as holding work. After turns and tasks finish, close every window for that workspace and reopen. Restarting just one window keeps joining the old engine while another window remains. The startup note names the machine and an explicit stop command using its installed executable; that command interrupts active turns and tasks, so use it only when ready. + the new build. An attached idle window counts as holding work. After turns and tasks finish, close every window for that workspace and reopen. Restarting just one window keeps joining the old engine while another window remains. The startup note names the machine and asks you to finish work and close every window for that workspace before reopening. - **It is an older build, holding nothing.** It hands the workspace over at once and the window says `replaced the older engine on tower (pid , , ) — this build holds the workspace now`. -- **It is a newer build.** Your window joins it. If its wire is one this binary cannot - speak, the connection is refused, naming this binary as the older one and - `codeaf engine --status` as the way to see which is newer. +- **It is a newer build.** Your window joins it. If the bridge speaks an older version, on Unix it hands over to the newer engine's executable, keeping the same arguments and connection. It tries once. If that file is missing, not executable, or still incompatible, the refusal names the file and the update command. + +## Which program runs on the far machine — devaf, codeaf, and custom names -## The older engine refusal — stop it with the exact --workspace +`devaf chat --host tower` starts `devaf engine` on tower. A program installed under +another name uses that name; `codeaf` starts `codeaf`. Selection and startup happen +in one ssh connection, with the workspace quoted. Reconnecting uses the same rule. -An older engine on another wire, or one too old to be asked at all, is refused, in these -words: +If that name is absent, the launch tries tower's `codeaf` and says one line: ``` -engine: tower is still holding this conversation on an older codeaf — run codeaf engine --stop --workspace /home/you/project on tower +tower has no devaf; using its codeaf () ``` -**The `--workspace` in that line is not decoration — type it.** `codeaf engine --stop` -with no `--workspace` stops whatever is holding your **home directory**, not the folder the -sentence is about. Without the flag you stop a healthy engine, leave the one being -complained about running, and read the same line again the next time you open codeaf. The -sentence spells the workspace out so the command you copy is the command that works. +If neither program is available, the refusal names the install command for your +program. A program that exists but cannot start does not trigger a silent fallback. +All names share one engine per workspace. A compatible newer engine keeps its +conversations; an older busy engine keeps work until every window and task is gone. -One nobody connects to again lets itself go on its own: it notices that the file it was -started from has been removed or rebuilt, and retires the next time it is holding nothing. -`codeaf engine --stop` is on the *Staying on that machine* page. +## An old engine speaks an incompatible version + +An older idle engine hands over automatically. A busy incompatible engine is left +alone: finish work and close every window for the named workspace before reopening. +An engine too old to answer which build it is gets the same safe advice. No version +refusal recommends stopping an engine while its windows remain open. ## What runs on the far machine, and what stays local @@ -949,3 +936,9 @@ opener are on that server. The original-file action explains how to use the loca not launch a viewer on a machine you are not sitting at. The optional cell preview still scrolls as ordinary terminal text; it cannot display screenshot text at full resolution. This distinction applies inside tmux too. + +## Executable handoff on Windows + +Windows cannot replace the running bridge in place. When a newer engine needs a +different version, this launch stops cleanly and names the installed program’s +update command. It leaves the existing conversation and its windows running. diff --git a/internal/manual/chat/staying-on-that-machine.md b/internal/manual/chat/staying-on-that-machine.md index a67f69f9c6..62a4b47637 100644 --- a/internal/manual/chat/staying-on-that-machine.md +++ b/internal/manual/chat/staying-on-that-machine.md @@ -477,13 +477,13 @@ Moving chats is available by default on Linux, macOS and native Windows with a c An older engine may lack that capability. **An idle engine without it is restarted**, with one line saying so: `restarted the idle engine because it ran without moving chats; moving chats available now`. **An engine that is still working is left alone** and the window says what to do: -let that finish, or run `codeaf engine --stop --workspace ` and open again. +finish that work, close every window for the workspace, and open again. **A chat can read as running after every window closed.** That is on purpose: the engine keeps a chat's lock so you can come back and be in it. It lets go on its own after the chat has been idle for 30 minutes, and the engine itself leaves two minutes after its last chat. Until then `codeaf cell gc` and the cell commands pass over that chat as running, and say how long -the wait is; `codeaf engine --stop` lets go at once. +the wait is; finish work and close every window for the workspace before reopening. ## Rebuilt codeaf but your conversation was still on the old engine — how codeaf tells you @@ -511,7 +511,7 @@ next safe launch — after the work is done and every window has left — opens For a safe update, let active turns and tasks finish, close **every window for this workspace**, then reopen with the updated binary. `engine --status --workspace ` names the running build and attached windows. Attached windows count as in use even when no reply is running; status does not treat that alone as proof of an active turn. -The startup note also gives an explicit, shell-quoted `engine --stop --workspace ` command using the installed executable (for example `devaf`). Run it on the named machine only when ready: **it interrupts active turns and tasks in that workspace**. Reconnect afterward with the updated build. Other workspace engines are unaffected. +The startup note asks you to finish work and close every window for this workspace before reopening. Stopping the engine while another window remains open makes that window start it again; it does not complete the update. When both ends support restart detection, that reconnect retires the old reply channels before displaying new replies. If an answer was still open, the window says @@ -523,8 +523,7 @@ loss to the same running engine still resumes its existing answer. days ago is older, and so is one too old to say. **A newer engine is never replaced** by an older codeaf — that window joins it — and two copies of one build never take the slot from each other. An older engine on another wire, or one too old to be asked at all, is refused -with `codeaf engine --stop --workspace ''` named, and nothing is signalled behind your -back. It used to be the other way round for a busy engine: it was killed and replaced, which +with advice to finish work and close every window for that workspace before reopening. On Unix, a newer incompatible engine hands the bridge to its own executable once; if the file cannot be used, the refusal names that file and the installed program's update command. Nothing is signalled behind your back. It used to be the other way round for a busy engine: it was killed and replaced, which is the rule this page no longer states. ## What still does not work, even though the session stays open @@ -665,3 +664,9 @@ machine's memory store could not be opened, the page and the commands say so here, `memory is unavailable on this machine · …` from a far engine). The `notice things to remember` row is read on every turn, so even an engine that has been running for days follows a change from the next turn. + +## Executable handoff on Windows + +Windows cannot replace the running bridge in place. When a newer engine needs a +different version, this launch stops cleanly and names the installed program’s +update command. It leaves the existing conversation and its windows running. diff --git a/internal/remote/client.go b/internal/remote/client.go index 12e78dab72..56edc16319 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -346,7 +346,7 @@ func (c *Client) attach(conn io.ReadWriteCloser) (Welcome, error) { // [spokenError] is for: the door prints the reason unchanged, and a // redial that meets it stops trying rather than spending its whole // window rediscovering the same no (redial.go). - return Welcome{}, spokenError{reason: strings.TrimSpace(frame.Error)} + return Welcome{}, spokenError{reason: callingProgramMismatch(strings.TrimSpace(frame.Error))} default: return Welcome{}, fmt.Errorf("%s answered with a %q where a welcome belongs", c.where(), frame.Kind) } @@ -359,7 +359,7 @@ func (c *Client) attach(conn io.ReadWriteCloser) (Welcome, error) { // turns into a conversation, and the sentence names the fix — one machine // has an older codeaf on it, and the person knows which machine is which. if welcome.Version != Version { - return Welcome{}, spokenError{reason: fmt.Sprintf("%s runs a different version of codeaf than this machine does — update the older one so both ends speak the same protocol", c.where())} + return Welcome{}, spokenError{reason: wireMismatchSentence(c.where(), welcome.Version, Version, welcome.Build)} } if welcome.Encoding != "" && welcome.Encoding != frameEncodingGzip { return Welcome{}, spokenError{reason: fmt.Sprintf("%s selected a frame encoding this build cannot read", c.where())} diff --git a/internal/remote/driver_test.go b/internal/remote/driver_test.go index fefe62069f..629cb9379c 100644 --- a/internal/remote/driver_test.go +++ b/internal/remote/driver_test.go @@ -375,7 +375,7 @@ func TestAJoinOrWatchHelloFromAnotherProtocolIsRefusedBeforeAnythingOpens(t *tes if frame.Kind != "fatal" { t.Fatalf("a hello from another protocol was answered with %q, want a refusal", frame.Kind) } - if !strings.Contains(frame.Error, "protocol") { + if !strings.Contains(frame.Error, "matching versions") { t.Fatalf("the refusal does not say what was wrong with the hello: %q", frame.Error) } if got := opened.Load(); got != 0 { diff --git a/internal/remote/retakelanes_test.go b/internal/remote/retakelanes_test.go index ef5393a142..6967d5baa9 100644 --- a/internal/remote/retakelanes_test.go +++ b/internal/remote/retakelanes_test.go @@ -183,7 +183,7 @@ func TestAnEngineOnAnotherProtocolRefusesRatherThanDroppingFrames(t *testing.T) if answer.Kind != "fatal" { t.Fatalf("an engine speaking another protocol answered a %q frame", answer.Kind) } - if !strings.Contains(answer.Error, "speaks protocol") { + if !strings.Contains(answer.Error, "matching versions") { t.Fatalf("the refusal reads %q", answer.Error) } if far.opened() != 0 { diff --git a/internal/remote/server.go b/internal/remote/server.go index d104c9c43d..6aca8764dd 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1901,18 +1901,9 @@ func (s *server) handshake(line []byte) error { return s.refuse("engine: the hello did not parse") } if hello.Version != Version { - reason := fmt.Sprintf("engine: this build speaks protocol %d and the surface speaks %d — the two halves have to be the same build", Version, hello.Version) - if s.host != nil { - // THE CLAUSE THAT WAS MISSING THE DAY THIS SENTENCE LIED. A host - // outlives the connection, so the process saying this may be an - // older codeaf that is still running on a machine whose binary was - // updated an hour ago — and the sentence above sent the person off - // to update something that was already updated. When there is a - // host behind this connection, say the other thing that is true. - reason += ", and this machine is still running the older one — run codeaf engine --stop here to retire it" - } - return s.refuse(reason) + return s.refuse(wireMismatchSentence(MachineName(), Version, hello.Version, buildinfo.String())) } + if supportsEncoding(hello.Encodings, frameEncodingGzip) { s.encoding = frameEncodingGzip } diff --git a/internal/remote/server_test.go b/internal/remote/server_test.go index 99d0eaba3e..3eec18b42c 100644 --- a/internal/remote/server_test.go +++ b/internal/remote/server_test.go @@ -761,7 +761,7 @@ func TestServeRefusesAnotherVersion(t *testing.T) { if frame.Kind != "fatal" { t.Fatalf("answer to a version mismatch was %q, want fatal", frame.Kind) } - if !strings.Contains(frame.Error, "protocol") { + if !strings.Contains(frame.Error, "matching versions") { t.Errorf("fatal said %q", frame.Error) } if err := l.end(); err == nil { diff --git a/internal/remote/version_words.go b/internal/remote/version_words.go new file mode 100644 index 0000000000..80cde63430 --- /dev/null +++ b/internal/remote/version_words.go @@ -0,0 +1,41 @@ +package remote + +import ( + "fmt" + "github.com/Agent-Field/codeaf/internal/update" + "os" + "strings" +) + +const wireMismatchTail = "this connection needs matching versions" + +// Version mismatches leave the engine and its windows alone. The higher wire +// identifies the end that needs no update; never claim that every host is old. +func wireMismatchSentence(machine string, far, near int, build string) string { + binary, _ := os.Executable() + name := update.InstallName(binary) + if far > near { + return fmt.Sprintf("%s is on a newer build (%s); %s update to match — %s", machine, build, name, wireMismatchTail) + } + return fmt.Sprintf("%s is on an older build (%s); run %s update on %s, then finish work and close every window for this workspace before reopening — %s", machine, build, name, machine, wireMismatchTail) +} + +// Only rewrite our version refusal. Arbitrary remote errors remain verbatim. +// The engine may have a different executable name after fallback or handoff; +// a newer-far refusal updates this installation. Older-far advice must keep +// naming the executable that actually exists on the far machine. +func callingProgramMismatch(reason string) string { + if !strings.HasSuffix(reason, wireMismatchTail) || !strings.Contains(reason, " update to match — ") { + return reason + } + start := strings.LastIndex(reason, "; ") + if start < 0 { + return reason + } + end := strings.Index(reason[start+1:], " update") + if end < 0 { + return reason + } + binary, _ := os.Executable() + return reason[:start+2] + update.InstallName(binary) + reason[start+1+end:] +} diff --git a/internal/remote/whois_test.go b/internal/remote/whois_test.go index 420d9a2ac2..aabd4a793d 100644 --- a/internal/remote/whois_test.go +++ b/internal/remote/whois_test.go @@ -11,6 +11,8 @@ import ( "github.com/Agent-Field/codeaf/internal/buildinfo" "io" "net" + "os" + "path/filepath" "strings" "testing" "time" @@ -159,14 +161,8 @@ func TestTheMismatchRefusalNamesTheOlderBuildStillRunningHere(t *testing.T) { Host: func(WhoIs) HostSelf { return HostSelf{} }, }) reason := helloRefusal(t, conn, Hello{Version: Version + 1}) - if !strings.Contains(reason, "still running the older one") { - t.Fatalf("the refusal from a host said %q", reason) - } - if !strings.Contains(reason, "codeaf engine --stop") { - t.Fatalf("the refusal named no way out: %q", reason) - } - if !strings.Contains(reason, "the two halves have to be the same build") { - t.Fatalf("the refusal lost the fact it always stated: %q", reason) + if !strings.Contains(reason, "older build") || !strings.Contains(reason, " update") || strings.Contains(reason, "--stop") { + t.Fatalf("the refusal gives no safe update road: %q", reason) } } @@ -213,3 +209,44 @@ func helloRefusal(t *testing.T, conn net.Conn, hello Hello) string { } return frame.Error } + +func TestWireMismatchDoesNotRecommendStoppingAttachedWindows(t *testing.T) { + conn := answered(t, AttachOptions{Open: func(Hello) (*Session, error) { t.Fatal("mismatch opened session"); return nil, nil }, Host: func(WhoIs) HostSelf { return HostSelf{Busy: true} }}) + reason := helloRefusal(t, conn, Hello{Version: Version - 1}) + if strings.Contains(reason, "--stop") || !strings.Contains(reason, " update") || !strings.Contains(reason, "newer build") { + t.Fatalf("unhelpful refusal: %s", reason) + } +} + +func TestCrossChannelMismatchNamesTheCallingProgram(t *testing.T) { + surface, engine := net.Pipe() + defer surface.Close() + go func() { + defer engine.Close() + var hello Frame + _ = json.NewDecoder(engine).Decode(&hello) + _ = json.NewEncoder(engine).Encode(Frame{Kind: "fatal", Error: "tower is on a newer build (rev-new); other-program update to match — this connection needs matching versions"}) + }() + _, err := Dial(surface, "tower", Hello{}) + self, _ := os.Executable() + want := filepath.Base(self) + " update" + if err == nil || !strings.Contains(err.Error(), want) || strings.Contains(err.Error(), "other-program update") { + t.Fatalf("wrong update road: %v, want %s", err, want) + } +} + +func TestOlderMismatchKeepsTheRemoteUpdateCommand(t *testing.T) { + surface, engine := net.Pipe() + defer surface.Close() + reason := "tower is on an older build (rev-old); run other-program update on tower, then finish work and close every window for this workspace before reopening — this connection needs matching versions" + go func() { + defer engine.Close() + var hello Frame + _ = json.NewDecoder(engine).Decode(&hello) + _ = json.NewEncoder(engine).Encode(Frame{Kind: "fatal", Error: reason}) + }() + _, err := Dial(surface, "tower", Hello{}) + if err == nil || err.Error() != reason { + t.Fatalf("remote update command was replaced: %v", err) + } +} diff --git a/internal/tui3/updateoffer.go b/internal/tui3/updateoffer.go index c50cc71416..643ddc98a4 100644 --- a/internal/tui3/updateoffer.go +++ b/internal/tui3/updateoffer.go @@ -348,7 +348,7 @@ func updateAheadNotice(available codeupdate.Available) string { if running == "" { running = "an unstamped build" } - return "codeaf " + available.Latest + " is out · you have " + running + return available.ProgramName() + " " + available.Latest + " is out · you have " + running } // ── THE ANSWERS ───────────────────────────────────────────────────────────── diff --git a/internal/tui3/updateoffer_test.go b/internal/tui3/updateoffer_test.go index e09f04ad4e..63b3e1a8f7 100644 --- a/internal/tui3/updateoffer_test.go +++ b/internal/tui3/updateoffer_test.go @@ -1065,3 +1065,10 @@ func TestATypedNamedTagRecordsTheLookedUpPublishMoment(t *testing.T) { t.Fatalf("the typed install recorded %+v, ok = %t; want the looked-up moment %s", record, ok, publishedAt) } } + +func TestUpdateNoticeNamesTheInstalledProgram(t *testing.T) { + got := updateAheadNotice(codeupdate.Available{Latest: "dev-new", Running: "dev-old", Name: "devaf"}) + if got != "devaf dev-new is out · you have dev-old" { + t.Fatalf("notice %q", got) + } +} diff --git a/internal/update/check.go b/internal/update/check.go index 573198a41f..85c5031971 100644 --- a/internal/update/check.go +++ b/internal/update/check.go @@ -33,6 +33,8 @@ const ( // Available is the release comparison shown at launch or by --check. type Available struct { + // Name is the executable whose update was checked, independent of channel. + Name string Latest string Running string LatestPublished time.Time @@ -88,7 +90,16 @@ func (a Available) Notice() string { if curl == "" { curl = CurlCommand } - return "codeaf " + a.Latest + " is out · you have " + a.Running + " · /update installs it for the next launch · or: " + curl + return a.ProgramName() + " " + a.Latest + " is out · you have " + a.Running + " · /update installs it for the next launch · or: " + curl +} + +// ProgramName names this installation; older callers without a name keep the +// stable product name. +func (a Available) ProgramName() string { + if strings.TrimSpace(a.Name) == "" { + return "codeaf" + } + return a.Name } type checkCache struct { @@ -162,6 +173,7 @@ func CheckLaunch(ctx context.Context, options CheckOptions) (Available, bool) { // Every answer leaves through here, so a newer release always arrives with // the pin that decides what a launch may do about it. finish := func(answer Available) (Available, bool) { + answer.Name = InstallName(options.Executable) newer := answer.Newer() if newer { answer.Pinned = PinnedRelease(options.Executable) diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 225a2992c2..762af8135a 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -129,7 +129,7 @@ func TestV3LaunchNoticeForChannelBuilds(t *testing.T) { t.Fatalf("requests = %q, want one ending in %q", paths, row.wantPath) } if row.name == "older dev" { - want := "codeaf " + newDev + " is out · you have " + oldDev + " · /update installs it for the next launch · or: curl -fsSL https://agentfield.ai/get/devaf | bash" + want := "devaf " + newDev + " is out · you have " + oldDev + " · /update installs it for the next launch · or: curl -fsSL https://agentfield.ai/get/devaf | bash" if got := answer.Notice(); got != want { t.Fatalf("notice = %q, want %q", got, want) }