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
37 changes: 13 additions & 24 deletions cmd/codeaf/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -2245,24 +2245,11 @@ func v3MemorySeam(brain *store.Store) tui3.MemoryStore {
// that made them restart to be heard would be a knob that does nothing on the
// surface that offers it.
//
// The seam is a SNAPSHOT INVALIDATED BY A GENERATION COUNTER
// ([config.SettingsGeneration]), for reasons the two obvious alternatives fail:
//
// - Re-reading config on every Resolve would put a file read on the path of
// every auxiliary call — twice a turn for the reflex pair alone — for a file
// that changes once a week. internal/config caches nothing on purpose.
// - Invalidating from the settings panel's write hook would only see the
// panel. The `change_setting` tool writes the same rows from inside a turn,
// and /crew writes four of them at once.
//
// A counter bumped by the ONE persisted writer sees all of them, costs an atomic
// load per call when nothing has moved, and holds the lock only to rebuild. A
// mid-session change is honored by the NEXT crew call, which is the promise the
// settings rows now make.
//
// What it does not see is stated where the counter is: a config file edited by
// another process, and the project layer's file at all. Both land on the next
// launch, which is what every settings row did before this.
// The snapshot follows this process's settings generation and the on-disk
// stamps of both profile and project configuration. A settings panel in another
// window is a different process from the engine: its writes must reach the next
// auxiliary call without restarting the engine. Unchanged files cost stats,
// never repeated reads or JSON decoding; internal/config memoizes their values.
func v3RolesSource(workspace, profileDir string) (func(string) (string, bool), error) {
crew := &v3Crew{workspace: workspace, profileDir: profileDir}
// The first build happens HERE rather than lazily, so a malformed pins row
Expand All @@ -2283,6 +2270,7 @@ type v3Crew struct {
// is impossible after the first build, so there is no "never read" state to
// spell separately.
generation uint64
files [2]crewConfigStamp
values map[string]string
// low is [config.CreditsLowAt] as of the snapshot. It is compared on every
// read beside the generation because the balance record is written by
Expand All @@ -2300,10 +2288,10 @@ type v3Crew struct {
err error
}

// read is [roles.Source]: one key, and the fast path is an atomic load and a
// read lock.
// read is [roles.Source]: unchanged configuration costs file metadata checks
// and a read lock, never repeated file reads or JSON decoding.
func (c *v3Crew) read(key string) (string, bool) {
if generation, low := c.stampNow(); config.SettingsGeneration() != generation || config.CreditsLowAt(c.profileDir) != low {
if generation, low, files := c.stampNow(); config.SettingsGeneration() != generation || config.CreditsLowAt(c.profileDir) != low || c.configFilesNow() != files {
// A rebuild that fails leaves the old snapshot in place, so the error is
// dropped here on purpose: this is the resolution path, and the honest
// answer to "which model" is the last one that parsed.
Expand All @@ -2320,16 +2308,17 @@ func (c *v3Crew) read(key string) (string, bool) {

// stampNow is what the snapshot was built against: the settings generation and
// whether the balance record read low.
func (c *v3Crew) stampNow() (uint64, bool) {
func (c *v3Crew) stampNow() (uint64, bool, [2]crewConfigStamp) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.generation, c.low
return c.generation, c.low, c.files
}

// rebuild reads the rows and replaces the snapshot. It reads BEFORE taking the
// write lock so a slow disk cannot hold a concurrent turn's resolution.
func (c *v3Crew) rebuild() error {
generation := config.SettingsGeneration()
files := c.configFilesNow()
low := config.CreditsLowAt(c.profileDir)
values, err := c.snapshot()
c.mu.Lock()
Expand All @@ -2338,7 +2327,7 @@ func (c *v3Crew) rebuild() error {
// retried on every single call — that would put a file read back on the hot
// path, which is the thing this seam exists to avoid — so the refusal is
// recorded and the next write is what triggers another attempt.
c.generation, c.low, c.err = generation, low, err
c.generation, c.low, c.files, c.err = generation, low, files, err
if err != nil {
return err
}
Expand Down
1 change: 1 addition & 0 deletions cmd/codeaf/chatv3_beside.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ func (b *besideAgent) Detach() error {
func hostLink(seams hostSeams) tui3.LinkSeam {
return tui3.LinkSeam{
Note: seams.Link,
EngineBuild: seams.EngineBuild,
Ping: seams.Ping,
Notice: seams.Notice,
Held: hostHeld(seams),
Expand Down
4 changes: 4 additions & 0 deletions cmd/codeaf/chatv3_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -1033,6 +1033,9 @@ func hostAttachedNote(attached int, driving bool) string {
// of facts rather than as an argument: the surface has grown
// [tui3.LinkSeam] and hostOptions hands all four straight into it.
type hostSeams struct {
// EngineBuild follows the client's latest welcome, including replacements.
EngineBuild func() string

// Link is the quiet sentence about the connection right now — empty
// whenever there is nothing to say, which is what a status-line segment
// draws as nothing at all. It reads `reconnecting to devbox — trying for up
Expand Down Expand Up @@ -1072,6 +1075,7 @@ func newHostSeams(client *remote.Client) hostSeams {
var readInitial atomic.Bool
return hostSeams{
Link: client.LinkNote, Ping: client.Ping, Notice: client.TakeNotice,
EngineBuild: func() string { return client.Welcome().Build },
Held: func() ([]remote.HeldQuestion, error) {
// Arrival and replay share one snapshot. Re-querying before drawing
// it could hide a question re-emitted since arrival: the server has
Expand Down
29 changes: 29 additions & 0 deletions cmd/codeaf/crew_refresh.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package main

import (
"os"
"time"

"github.com/Agent-Field/codeaf/internal/config"
)

// crewConfigStamp is the same freshness contract as internal/filememo: a file
// creation, removal, size change or modification time invalidates the snapshot.
// The in-process generation still covers writes on coarse timestamp clocks.
type crewConfigStamp struct {
Size int64
Modified time.Time
Exists bool
}

// configFilesNow notices writes from a settings window or editor in another
// process. Only metadata is read on the resolution path, never file contents.
func (c *v3Crew) configFilesNow() [2]crewConfigStamp {
var stamps [2]crewConfigStamp
for i, path := range []string{config.BudgetConfigPath(c.profileDir), config.ProjectConfigPath(c.workspace)} {
if info, err := os.Stat(path); err == nil {
stamps[i] = crewConfigStamp{Size: info.Size(), Modified: info.ModTime(), Exists: true}
}
}
return stamps
}
76 changes: 76 additions & 0 deletions cmd/codeaf/crew_refresh_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package main

import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"

"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/roles"
)

// An external settings writer never increments this process's counter. Atomic
// replacement also exercises the way another terminal's settings panel saves.
func writeExternalCrewSettings(t *testing.T, path string, values map[string]string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatal(err)
}
data, err := json.Marshal(values)
if err != nil {
t.Fatal(err)
}
stamp := time.Now().Add(time.Second)
if old, err := os.Stat(path); err == nil {
stamp = old.ModTime().Add(time.Second)
}
tmp := path + ".external"
if err := os.WriteFile(tmp, data, 0600); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(tmp, stamp, stamp); err != nil {
t.Fatal(err)
}
if err := os.Rename(tmp, path); err != nil {
t.Fatal(err)
}
}

func TestRunningCrewReadsSettingsChangedByAnotherProcess(t *testing.T) {
profile, workspace := t.TempDir(), t.TempDir()
source, err := v3RolesSource(workspace, profile)
if err != nil {
t.Fatal(err)
}
generation := config.SettingsGeneration()
writeExternalCrewSettings(t, config.BudgetConfigPath(profile), map[string]string{config.KeyTierReflexModel: "vendor/first", config.KeyTierLowModel: "vendor/cheap"})
if config.SettingsGeneration() != generation {
t.Fatal("fixture used in-process writer")
}
resolve := func(role roles.Role, want string) {
t.Helper()
got, err := roles.Resolve(roles.Source(source), role, "vendor/chat")
if err != nil || got != want {
t.Fatalf("role=%s model=%q want=%q error=%v", role, got, want, err)
}
}
resolve(roles.RoleReflex, "vendor/first")
resolve(roles.RoleTitle, "vendor/cheap")
// Same-length replacements must invalidate both the crew snapshot and the
// config parser without requiring a process restart or a file-size change.
writeExternalCrewSettings(t, config.BudgetConfigPath(profile), map[string]string{config.KeyTierReflexModel: "vendor/other", config.KeyTierLowModel: "vendor/newer"})
resolve(roles.RoleReflex, "vendor/other")
resolve(roles.RoleTitle, "vendor/newer")
writeExternalCrewSettings(t, config.ProjectConfigPath(workspace), map[string]string{config.KeyTierLowModel: "vendor/project"})
resolve(roles.RoleTitle, "vendor/project")
if err := os.Remove(config.ProjectConfigPath(workspace)); err != nil {
t.Fatal(err)
}
resolve(roles.RoleTitle, "vendor/newer")
if err := os.Remove(config.BudgetConfigPath(profile)); err != nil {
t.Fatal(err)
}
resolve(roles.RoleReflex, config.DefaultReflexModel)
}
55 changes: 32 additions & 23 deletions cmd/codeaf/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -396,11 +396,8 @@ func nothingHolds(workspace string) bool {
// differentEngineEnvironmentSentence names the host's workspace so the command
// stops the folder actually holding the old environment, including from Home.
func differentEngineEnvironmentSentence(workspace string) string {
stop := "codeaf engine --stop"
if workspace = strings.TrimSpace(workspace); workspace != "" {
stop += " --workspace " + shellQuote(workspace)
}
return "the engine for this folder started with a different environment than this terminal and is still in use — close its other windows or let its work finish, or run " + stop
stop := engineStopWords(workspace)
return "the background engine for this folder started with a different environment than this terminal and is still in use — after work finishes, close every window for this workspace and reopen. To stop now (interrupts active turns and tasks), run " + stop
}

// sameEngineBuild is the host being this build: the same wire, the same source,
Expand Down Expand Up @@ -461,14 +458,11 @@ func staleEngineHostSentence(busy bool, workspace string) string {
if strings.TrimSpace(name) == "" {
name = "that machine"
}
stop := "codeaf engine --stop"
if workspace = strings.TrimSpace(workspace); workspace != "" {
stop += " --workspace " + workspace
}
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", name, stop, name)
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 holding this conversation on an older codeaf — run %s on %s", name, stop, name)
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)
}

// unknownOlderEngineHostSentence is what a person reads when the process holding
Expand All @@ -485,7 +479,7 @@ func unknownOlderEngineHostSentence(workspace string) string {
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", name, stop, name)
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)
}

// otherProtocolEngineHostSentence is the refusal for an older engine on a wire
Expand All @@ -499,14 +493,21 @@ func otherProtocolEngineHostSentence(workspace string) string {
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", name, stop, name)
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)
}

// engineStopWords is the command a refusal tells a person to run: the explicit
// stop, naming and quoting the workspace so a folder with a space in it survives
// being copied out of a sentence and pasted into a shell.
func engineStopWords(workspace string) string {
stop := "codeaf engine --stop"
return engineStopWordsAs(enginehost.ThisBinary(), workspace)
}

func engineStopWordsAs(binary, workspace string) string {
if strings.TrimSpace(binary) == "" {
binary = "codeaf"
}
stop := shellQuote(binary) + " engine --stop"
if workspace = strings.TrimSpace(workspace); workspace != "" {
stop += " --workspace " + shellQuote(workspace)
}
Expand Down Expand Up @@ -560,10 +561,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)
if waits {
return fmt.Sprintf("the engine on %s is an older codeaf and is still holding work — it keeps that work and picks up this build once it goes quiet", name)
note += " — the engine will step aside when all windows and work are gone"
}
return fmt.Sprintf("the engine on %s is an older codeaf and is still holding work — it keeps that work, and your next launch once the work is done opens from this build", name)
return note + ". Or run " + engineStopWords(workspace) + " on " + name + " (interrupts active turns and tasks); reconnect with this build"
}

// replacedEngineHostSentence is the ONE LINE a takeover owes: which engine was
Expand Down Expand Up @@ -658,10 +660,16 @@ func writeEngineStatus(out io.Writer, workspace string, held enginehost.Holder,
verdict := "this build"
switch {
case !held.Answered:
verdict = "an older build — the next codeaf launched here replaces it"
verdict = "an older build — its activity is unknown; a new window will not replace it"
case sameEngineBuild(host, me):
case host.BuiltAt.Before(me.BuiltAt):
verdict = "an older build — the next codeaf launched here replaces it"
if host.Busy && host.Version != remote.Version {
verdict = "an older incompatible build — still in use; a new window cannot join it"
} else if host.Busy {
verdict = "an older build — still in use; a new window keeps using it"
} else {
verdict = "an older build — idle; the next launch replaces it"
}
default:
verdict = "a newer build than this binary"
}
Expand All @@ -681,17 +689,18 @@ func writeEngineStatus(out io.Writer, workspace string, held enginehost.Holder,
if held.Answered {
fmt.Fprintf(out, " windows %d attached · %s open\n", host.Surfaces, countWord(host.Conversations, "conversation", "conversations"))
if host.Busy {
fmt.Fprintln(out, " working yes — a turn, a task or a question is in flight")
fmt.Fprintln(out, " in use yes — an attached window, turn, task or question keeps it running")
}
}
if enginehost.EnvironmentDiffers(workspace) {
fmt.Fprintln(out, " environment differs from this terminal's")
}
stop := "codeaf engine --stop"
if strings.TrimSpace(workspace) != "" {
stop += " --workspace " + workspace
fmt.Fprintf(out, " stop it %s (interrupts active turns and tasks)\n", engineStopWords(workspace))
if !held.Answered {
fmt.Fprintln(out, " update activity is unknown; when ready, explicitly stop this workspace engine and reconnect with this build")
} else if !sameEngineBuild(host, me) && host.BuiltAt.Before(me.BuiltAt) && host.Busy {
fmt.Fprintln(out, " update after work finishes, close every window for this workspace and reopen with this build; restarting one window is insufficient")
}
fmt.Fprintf(out, " stop it %s\n", stop)
}

// roughAge is a duration the way a person reads one on a status line.
Expand Down
19 changes: 15 additions & 4 deletions cmd/codeaf/engine_environment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -460,12 +460,23 @@ func TestEnvironmentStopCommandQuotesTheWorkspaceForTheShell(t *testing.T) {
for _, workspace := range []string{"/tmp/my project", "/tmp/it's project"} {
t.Run(workspace, func(t *testing.T) {
note := differentEngineEnvironmentSentence(workspace)
_, command, ok := strings.Cut(note, ", or run ")
if !ok || !strings.Contains(note, "and is still in use — close its other windows or let its work finish") {
_, command, ok := strings.Cut(note, "), run ")
if !ok || !strings.Contains(note, "after work finishes, close every window for this workspace and reopen") || !strings.Contains(note, "To stop now (interrupts active turns and tasks)") {
t.Fatalf("busy note does not explain the recovery: %s", note)
}
// A shell function records the arguments instead of stopping a host.
out, err := exec.Command("bash", "-c", "codeaf() { printf '%s\\n' \"$@\"; }; "+command).Output()
// Keep the production command's complete shell argument text. Only
// replace its executable with a recorder, so this test cannot stop a
// real engine and also exercises executable paths needing quoting.
recorder := filepath.Join(t.TempDir(), "devaf's test recorder")
if err := os.WriteFile(recorder, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil {
t.Fatal(err)
}
binary := shellQuote(enginehost.ThisBinary())
if !strings.HasPrefix(command, binary+" engine --stop ") {
t.Fatalf("stop command does not use the installed executable: %q", command)
}
command = shellQuote(recorder) + strings.TrimPrefix(command, binary)
out, err := exec.Command("bash", "-c", command).Output()
if err != nil || string(out) != "engine\n--stop\n--workspace\n"+workspace+"\n" {
t.Fatalf("copied command arguments = %q, error %v", out, err)
}
Expand Down
Loading
Loading