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
8 changes: 8 additions & 0 deletions cmd/aforge/chatv3_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/aforge/chatv3_local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
57 changes: 50 additions & 7 deletions cmd/aforge/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand All @@ -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
Expand Down
44 changes: 43 additions & 1 deletion cmd/aforge/engine_stale_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 16 additions & 0 deletions docs/changes/unreleased/966-engine-idle-stale-notice.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions internal/manual/chat/staying-on-that-machine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <machine> 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 <machine> 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.
Expand Down
Loading