From affc9f68cef69760e5b82ef705cd917ab3f9f672 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 18:31:00 -0400 Subject: [PATCH 1/3] engine: an idle conversation on an older build is named on the way in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host one build behind holding only an IDLE conversation — the turn finished, the person stepped away — was asked to go, went, and the window opened on the fresh build without a word. The busy-host notice from #691 covered work in flight, but a conversation merely kept warm read as not busy, so the person was never told the engine answering them had been a build behind. They debugged a fix that had simply not reached the conversation yet. The notice is now owed whenever the older same-wire build is the one answering, and it names what was held: still holding work, or only holding the conversation. A host that went quietly says it was an older build and that it is current now. --- cmd/aforge/chatv3_local_test.go | 2 +- cmd/aforge/engine.go | 57 ++++++++++++++++--- cmd/aforge/engine_stale_test.go | 44 +++++++++++++- .../manual/chat/staying-on-that-machine.md | 21 +++++++ 4 files changed, 115 insertions(+), 9 deletions(-) diff --git a/cmd/aforge/chatv3_local_test.go b/cmd/aforge/chatv3_local_test.go index 35248cc3a2..1f2e9ea1ba 100644 --- a/cmd/aforge/chatv3_local_test.go +++ b/cmd/aforge/chatv3_local_test.go @@ -529,7 +529,7 @@ func TestALongStatePathIsSaidInWordsAndNotInTheEnginesOwn(t *testing.T) { t.Fatalf("the %s speaks in machinery words: %q", name, said) } } - written := busyEngineHostSentence() + written := busyEngineHostSentence(true) if got := hostFallbackReason(errors.New(written)); got != written { t.Fatalf("a sentence already written for a person became %q, want %q", got, written) } diff --git a/cmd/aforge/engine.go b/cmd/aforge/engine.go index 78fe91e033..5117414688 100644 --- a/cmd/aforge/engine.go +++ b/cmd/aforge/engine.go @@ -234,6 +234,15 @@ func clearStaleEngineHost(workspace string) (string, error) { // back one line for the entry notice saying which state the machine is in. A // DIFFERENT wire version keeps the refusal it has always had, because there is // no attaching to a peer whose frames this build cannot read. +// +// AND THE NOTICE IS OWED WHENEVER THE OLDER BUILD IS THE ONE ANSWERING, not only +// when it is holding work. It used to speak only for a busy host: a host whose +// conversation had gone quiet — the turn finished, the person stepped away — was +// asked to go, went, and the window opened on the fresh build WITHOUT A WORD, so +// the person whose rebuild had not yet reached the conversation they were +// reading was never told which build they had been talking to. That is the ghost +// this line exists to name: whatever the older build was holding, the person is +// told it was an older build, and told it is gone the moment it lets go. func clearStaleEngineHostFor(workspace, thisBuild string) (string, error) { host, err := enginehost.Ask(workspace, remote.WhoIs{}) switch { @@ -253,13 +262,22 @@ func clearStaleEngineHostFor(workspace, thisBuild string) (string, error) { // Another build, and it is answering, so it can be asked to go. if err := enginehost.Retire(workspace, false); err != nil { if errors.Is(err, enginehost.ErrHostBusy) && host.Version == remote.Version { - return busyEngineHostSentence(), nil + return busyEngineHostSentence(host.Busy), nil } if errors.Is(err, enginehost.ErrHostBusy) { return "", &staleHost{reason: staleEngineHostSentence(true)} } return "", &staleHost{reason: staleEngineHostSentence(false)} } + // IT WENT, and it went without a fight: whatever it was holding, it was + // holding nothing that could not be let go. The window opens on the fresh + // host either way — but it was the OLDER build answering until this moment, + // and a person whose rebuild had not reached the conversation they were + // reading deserves to be told so. A DIFFERENT wire keeps its silence: there + // the refusal above already said the machine was behind. + if host.Version == remote.Version { + return olderEngineHostSentence(host.Busy), nil + } return "", nil } @@ -284,16 +302,41 @@ func staleEngineHostSentence(busy bool) string { } // busyEngineHostSentence is the one line a person reads when their conversation -// comes back on a host that is one build behind. It is [staleEngineHostSentence]'s -// voice and its opposite in every other way: nothing is wrong, nothing is owed, -// and the sentence exists so a surface never quietly runs against a binary that -// is not the one on disk. -func busyEngineHostSentence() string { +// comes back on a host that is one build behind and could not be let go of yet. +// It is [staleEngineHostSentence]'s voice and its opposite in every other way: +// nothing is wrong, nothing is owed, and the sentence exists so a surface never +// quietly runs against a binary that is not the one on disk. +// +// IT NAMES WHAT IS ACTUALLY HELD. busy is the host's own answer, and it is the +// difference between a turn still running and a conversation that is only being +// kept warm — the two are not the same ghost, and the person being told deserves +// to know which one is between them and the rebuild. +func busyEngineHostSentence(busy bool) string { name := remote.MachineName() if strings.TrimSpace(name) == "" { name = "this machine" } - return fmt.Sprintf("the engine on %s is an older aforge and is still holding work — it picks up this build the moment it goes quiet", name) + if busy { + return fmt.Sprintf("the engine on %s is an older aforge and is still holding work — it picks up this build the moment it goes quiet", name) + } + return fmt.Sprintf("the engine on %s is an older aforge — it is holding this conversation and picks up this build the moment you leave it", name) +} + +// olderEngineHostSentence is the same notice for the host that went quietly: an +// older build was answering until the moment this window arrived, and it has +// already stepped aside. Nothing is owed and nothing is still pinned — the +// sentence exists so a person whose rebuild had not yet reached the conversation +// they were reading is told which build they had been talking to, rather than +// finding it out by the fix not being there. +func olderEngineHostSentence(busy bool) string { + name := remote.MachineName() + if strings.TrimSpace(name) == "" { + name = "this machine" + } + if busy { + return fmt.Sprintf("the engine on %s was an older aforge until just now — it has picked up this build", name) + } + return fmt.Sprintf("the engine on %s was an older aforge holding this conversation — it has picked up this build", name) } // runEngineStop is `aforge engine --stop`: whatever is holding this workspace diff --git a/cmd/aforge/engine_stale_test.go b/cmd/aforge/engine_stale_test.go index 289adda99f..469b776daf 100644 --- a/cmd/aforge/engine_stale_test.go +++ b/cmd/aforge/engine_stale_test.go @@ -233,15 +233,25 @@ func TestNothingHoldingTheWorkspaceIsNotARefusal(t *testing.T) { } // Protocol compatibility cannot establish that a daemon includes today's fixes. +// The host goes — and the person is TOLD it was an older build that went, +// because a window that opens on the fresh build without a word leaves whoever +// was reading the old one to debug a fix that simply had not reached it yet. func TestASameProtocolHostOfAnotherBuildIsRetired(t *testing.T) { for _, stamp := range []string{"previous-build", ""} { t.Run(stamp, func(t *testing.T) { shortEngineHome(t) workspace := "/home/somebody/api" standIn(t, workspace, remote.HostSelf{Version: remote.Version, Build: stamp}, false) - if _, err := clearStaleEngineHost(workspace); err != nil { + note, err := clearStaleEngineHost(workspace) + if err != nil { t.Fatal(err) } + if !strings.Contains(note, "older aforge") { + t.Fatalf("the notice did not say it was an older build: %q", note) + } + if !strings.Contains(note, "picked up this build") { + t.Fatalf("the notice did not say it is current now: %q", note) + } if conn, err := enginehost.Dial(workspace); err == nil { _ = conn.Close() t.Fatal("stale same-protocol host still answers") @@ -331,6 +341,38 @@ func TestASameProtocolBusyOlderBuildIsAttachedToAndSaysSo(t *testing.T) { _ = conn.Close() } +// A HOST ONE BUILD BEHIND, HOLDING ONLY AN IDLE CONVERSATION, IS STILL NAMED. +// This is the ghost the notice exists for: the turn had finished and the person +// had stepped away, so the host was not busy — and before, it was asked to go, +// went, and the window opened on the fresh build WITHOUT A WORD. The person was +// never told the engine answering them had been a build behind, so they debugged +// a fix that had simply not reached the conversation yet. The notice says which +// build was answering and that it is current now. +func TestASameProtocolIdleOlderBuildIsRetiredAndSaysSo(t *testing.T) { + shortEngineHome(t) + workspace := "/home/somebody/api" + // Busy is false: the conversation is being kept warm, nothing is running. + standIn(t, workspace, remote.HostSelf{Version: remote.Version, Build: "previous-build", Busy: false}, false) + note, err := clearStaleEngineHost(workspace) + if err != nil { + t.Fatalf("an idle host on this wire was refused: %v", err) + } + if !strings.Contains(note, "older aforge") { + t.Fatalf("the notice did not say it was an older build: %q", note) + } + if !strings.Contains(note, "picked up this build") { + t.Fatalf("the notice did not say it is current now: %q", note) + } + if strings.Contains(note, "--stop") { + t.Fatalf("the notice still sends somebody off to stop their own work: %q", note) + } + // AND IT WENT: the fresh host is what the next connection starts. + if conn, err := enginehost.Dial(workspace); err == nil { + _ = conn.Close() + t.Fatal("the idle older host was still answering") + } +} + // AND A DIFFERENT WIRE STILL IS REFUSED, busy or not: there is no attaching to a // peer whose frames this build cannot read. func TestABusyHostOnAnotherWireIsStillRefused(t *testing.T) { diff --git a/internal/manual/chat/staying-on-that-machine.md b/internal/manual/chat/staying-on-that-machine.md index 9e8975f76f..532c52904f 100644 --- a/internal/manual/chat/staying-on-that-machine.md +++ b/internal/manual/chat/staying-on-that-machine.md @@ -393,6 +393,27 @@ for `aforge engine` itself. The reason to type it is that a connection told you refusals about an older aforge still holding a machine name this command (*Running on another machine*). +## Rebuilt aforge but your conversation was still on the old engine — how aforge tells you + +A plain `aforge` does not run your conversation inside the window — the session host does, +in a process of its own, so it survives the terminal closing. That process also outlives the +build that started it. Rebuild with `make build` while a host is holding a conversation and +the host of the **older** build keeps answering until it is holding nothing — a turn still +running, a card waiting for you — and then retires, so the next window opens on the build +now on disk. + +You are told, once, on the way in, which state the machine was in: + +- **It was still holding work** — `the engine on is an older aforge and is still + holding work — it picks up this build the moment it goes quiet`. +- **It was only keeping your conversation warm** (the turn had finished and you had stepped + away) — `the engine on was an older aforge holding this conversation — it has + picked up this build`. + +Both are the same fact at two moments: the build you installed was not the one answering +until now, so a fix you expected may simply not have reached the conversation you were +reading yet. Nothing is owed in either case — the older build steps aside on its own. + ## What still does not work, even though the session stays open Two things. From f6a150374d00a1eea181b7183fd55b04b8af9e5f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 18:42:52 -0400 Subject: [PATCH 2/3] changes: entry for #966 --- .../unreleased/966-engine-idle-stale-notice.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/changes/unreleased/966-engine-idle-stale-notice.md diff --git a/docs/changes/unreleased/966-engine-idle-stale-notice.md b/docs/changes/unreleased/966-engine-idle-stale-notice.md new file mode 100644 index 0000000000..a1098afe54 --- /dev/null +++ b/docs/changes/unreleased/966-engine-idle-stale-notice.md @@ -0,0 +1,16 @@ +--- +kind: fixed +title: an idle conversation on an older engine build is named on the way in +pr: 966 +surface: [chat, engine] +invalidates: + - "The stale-host entry notice (cmd/aforge's clearStaleEngineHostFor) used to fire only when the older same-wire host reported Busy (work in flight). A host holding only an IDLE conversation — the turn finished, the person stepped away — was retired silently, and the window opened on the fresh build without a word. It no longer does: the notice is owed whenever the older same-wire build is the one answering, and it names what was held — still holding work, only holding the conversation, or already gone and current now." +--- + +A session host outlives the build that started it by design, and the busy-host +notice from #691 covered the case where the older build was still holding work. +The owner's ordinary case is the other one: rebuild, leave the conversation +idle, come back — the older host reads as not busy, so it was asked to go, went, +and nobody was told the engine answering had been a build behind. The notice now +speaks for that case too, so a person stops debugging a fix that had simply not +reached the conversation yet. From 06d48c2df87d62e28166ac9873639c5ef2e66f83 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 18:47:42 -0400 Subject: [PATCH 3/3] engine: the headless --once road carries the stale-host notice on stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notice is joined into the surface's entry notice, but --once opens no surface, so a scripted caller against an idle stale host heard nothing — the same silence the interactive road just fixed. It goes to stderr, the channel a scripted caller reads, leaving stdout to the answer alone. --- cmd/aforge/chatv3_local.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmd/aforge/chatv3_local.go b/cmd/aforge/chatv3_local.go index 000ac4dd30..647f26d0a5 100644 --- a/cmd/aforge/chatv3_local.go +++ b/cmd/aforge/chatv3_local.go @@ -213,6 +213,14 @@ func openChatV3Local(launch localLaunch) error { correctHostChoices(agent, hostLaunch{model: launch.model, level: launch.level}, welcome) if launch.once != "" { + // A HEADLESS MESSAGE HAS NO TRANSCRIPT TO CARRY THE NOTICE. The stale-host + // line the dial left ([localLink.said]) is joined into the surface's entry + // notice below, and --once never opens a surface — so it goes to stderr, + // the channel a scripted caller reads, leaving stdout to the answer alone + // (the same split engine.go makes between the protocol and the person). + if said := link.said(); said != "" { + fmt.Fprintln(os.Stderr, said) + } return runHostOnce(agent, launch.once) } // ANOTHER CONVERSATION IS ANOTHER CONNECTION TO THE SAME HOST, on the same