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
83 changes: 82 additions & 1 deletion cmd/aforge/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ func runRemoteEngine(args []string) error {
// refusal below sends them to it: something older is holding this
// workspace and has to be let go of before a current build can hold it.
stop := flags.Bool("stop", false, "stop whatever is holding this workspace's conversations on this machine")
// --stop-all IS THE ONE A PERSON REACHES FOR WHEN THEY DO NOT KNOW WHICH
// WORKSPACE IS THE PROBLEM, and that is the ordinary case: the refusal names
// a machine, a person has run aforge in six folders this month, and finding
// the one that will not let go means reading a directory of hashes. It is
// the same stand-down as --stop, asked of every workspace this machine has a
// host directory for, one at a time and named as it goes.
stopAll := flags.Bool("stop-all", false, "stop every engine this machine is holding, in every workspace")
if err := parseCommandFlags(flags, args); err != nil {
return err
}
Expand All @@ -93,12 +100,18 @@ func runRemoteEngine(args []string) error {
// and nothing a person accomplishes by typing it, so the usage line
// offers the two flags somebody might mean and stays quiet about the
// one they would only ever mean by accident.
return fmt.Errorf("usage: aforge engine [--workspace path] [--session path] [--no-host] [--stop]")
return fmt.Errorf("usage: aforge engine [--workspace path] [--session path] [--no-host] [--stop] [--stop-all]")
}

if *daemon {
return runEngineHost(*workspace, *file)
}
if *stopAll {
// THE TWO FLAGS ARE NOT COMBINED, they are ordered: --stop-all is a
// superset of --stop, so a person who typed both meant the larger one
// and being refused for saying it twice would be pedantry.
return runEngineStopAll()
}
if *stop {
return runEngineStop(*workspace)
}
Expand Down Expand Up @@ -399,6 +412,74 @@ func runEngineStop(workspaceFlag string) error {
return nil
}

// runEngineStopAll is `aforge engine --stop-all`: every engine this machine is
// holding, in every workspace, let go of.
//
// IT EXISTS BECAUSE THE REMEDY USED TO REQUIRE KNOWING THE ANSWER. A stale host
// announces itself by refusing a launch, and the fix is `--stop --workspace
// <path>` — but the person reading that has run aforge in six folders and the
// state root names them by hash. On 2026-09-12 a host on an older wire held one
// checkout for twenty-two hours and eight rebuilds, and clearing it took reading
// a directory of hashes to find which one it was. This is that reading, done by
// the program.
//
// EVERY WORKSPACE IS NAMED AS IT GOES, and one that refuses does not stop the
// sweep: the whole point is the workspace you did not know about, so a failure
// on the third of five must not hide the fourth. The refusals are collected and
// reported together at the end, and the exit code says whether any of them
// happened.
//
// A DIRECTORY WHOSE HOST HAS GONE IS NOT A FAILURE. [enginehost.Stop] answers
// false for a socket nobody is listening on, which is the ordinary state of
// every workspace anybody has ever opened and closed, so those are counted and
// summarised rather than printed one by one — a sweep that listed thirteen
// "nothing there" lines would bury the one line that mattered.
func runEngineStopAll() error {
held, err := enginehost.Held()
if err != nil {
return err
}
if len(held) == 0 {
fmt.Println("nothing is holding any workspace here")
return nil
}
var stopped, quiet int
var refused []string
for _, workspace := range held {
went, err := enginehost.Stop(workspace)
switch {
case err != nil:
refused = append(refused, fmt.Sprintf("%s: %v", workspace, err))
case went:
stopped++
fmt.Printf("stopped holding %s\n", workspace)
default:
quiet++
}
}
if stopped == 0 && len(refused) == 0 {
fmt.Printf("nothing is holding any of the %s here\n", placesWord(quiet))
return nil
}
if quiet > 0 {
fmt.Printf("%s had nothing holding them\n", placesWord(quiet))
}
if len(refused) > 0 {
return fmt.Errorf("could not stop %s:\n %s", placesWord(len(refused)), strings.Join(refused, "\n "))
}
fmt.Println("the next connection in any of them starts fresh from this build")
return nil
}

// placesWord counts workspaces the way a sentence does, because "1 workspaces"
// is the kind of line that makes a person doubt the number beside it.
func placesWord(n int) string {
if n == 1 {
return "1 workspace"
}
return fmt.Sprintf("%d workspaces", n)
}

// runEngineHost is this process being the host: it moves into the workspace
// once, the way every engine does, and then holds that workspace's
// conversations until the idle policy retires it.
Expand Down
26 changes: 26 additions & 0 deletions docs/changes/unreleased/1000-home-input-three-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
kind: changed
title: Home's box is three rows tall, typed in or not, and one command clears every engine
pr: 1000
surface: [chat, engine, docs]
invalidates:
- "Home's foot box grew to fit its draft and no further, so the commonest state — a question one sentence long — drew a SINGLE row between the rule above it and the hint below it. The owner's reading was that it is too thin to notice and does not look like somewhere to type, and the cause is that one row of text bounded by two rows of chrome has no mass of its own. It now stands three rows high from the first character (`homeDraftFloor`), padded below the draft so the first line typed stays on the first row."
- "The box no longer changes height while somebody types. The floor and the ceiling are both three, so a sentence that wraps to a second row does not grow the box and does not step the list above it down a row. Anything that remembers home's box as growing with its content is wrong; past three rows the window scrolls under the caret exactly as it did."
- "An empty box is three rows as well, not one. The place's dim sentence is on the first row and the other two are held open under it, so the one thing on the screen a person types into is a block they can see BEFORE they have typed anything — somebody who cannot find the box has nothing to type into it. It also means the foot does not move on the first keystroke, where a box that jumped from one row to three would shift the list up under the hand reaching for it. The resting rows are the box's silhouette and not its surface: the press span stays empty, so a click on them falls through to the place underneath exactly as a click on the resting row always has."
- "The floor is spent out of rows the frame has OVER an eighty by twenty-four terminal, never out of that frame's own body — so it appears at twenty-six rows and taller and below that the box is the single row it has always been. Held open unconditionally it cost three rows everywhere, and at twenty-four those three are not spare: home dropped a panel off the bottom of its column, an empty place drew its rule where its whisper had been, and the rail's standing section was squeezed out by a long roster. Anything that remembers this as a height the box is always three rows at is wrong."
- "The composer's floor is the CONVERSATION'S floor too, not home's alone. The chat's foot and a place's foot are the same rows, and `esc` between them may not move any of them — a law that was broken and fixed once already. `boxFloor` is asked by the chat's `inputBlock`, which its height and its drawing both go through, and by every place's frame. On a frame tall enough for it the conversation's transcript is two rows shorter than it was."
---

`aforge engine --stop-all` is here too, because it is the other half of the same
afternoon: the stale-engine refusal names one workspace, and a person who does
not know WHICH of their folders is the problem was left reading a directory of
hashes. `enginehost.Held` reads the plain-text workspace each host writes beside
its socket, and the sweep stands every one of them down, naming each as it goes.
A directory whose host has already gone is counted rather than printed, so a
sweep does not bury the one line that matters under thirteen saying nothing was
there.

The caret arithmetic in `pages.go` is untouched. It derives the caret's row by
subtracting the block's height from the rows placed, so padding at the bottom
moves both by the same amount — verified by driving the real binary against the
demo home, where the caret lands on the last character of a two-row draft.
44 changes: 44 additions & 0 deletions internal/enginehost/enginehost.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -266,6 +267,49 @@ func Ask(workspace string, ask remote.WhoIs) (remote.HostSelf, error) {
// asks for it regardless, which is one person's own `aforge engine --stop` and
// nothing else. Either way the ending is the host's own shutdown — every
// conversation closed, every journal flushed — and never a signal from outside.
// Held is every workspace this machine has a host directory for, in the plain
// text each host wrote there ([placeName]).
//
// IT READS THE DIRECTORIES AND NOT THE PROCESS TABLE. A host is known by the
// state it left under ~/.aforge/v3/hosts, so this answers for hosts started by
// any build and by any terminal, including one whose process has gone and left
// its socket behind. Whether anybody is actually listening is the caller's next
// question, asked through [Dial] or [Stop] — and a directory whose host is gone
// answers "nothing was holding it", which is the truth a sweep wants.
//
// A directory with no workspace file is SKIPPED rather than guessed at: the
// name is a hash and there is no way back from it to a path, so the honest
// answer for one is nothing at all. Nothing here is an error a caller should
// stop for — a state root that cannot be read is a machine with no hosts.
func Held() ([]string, error) {
root, err := home.Join("v3", "hosts"), error(nil)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("engine host: %w", err)
}
var held []string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name, err := os.ReadFile(filepath.Join(root, entry.Name(), placeName))
if err != nil {
continue
}
if workspace := strings.TrimSpace(string(name)); workspace != "" {
held = append(held, workspace)
}
}
sort.Strings(held)
return held, nil
}

func Retire(workspace string, anyway bool) error {
self, err := Ask(workspace, remote.WhoIs{StandDown: true, Anyway: anyway})
if err != nil {
Expand Down
23 changes: 18 additions & 5 deletions internal/manual/chat/home.md
Original file line number Diff line number Diff line change
Expand Up @@ -706,11 +706,24 @@ a fold door, the action row — always with `alt+. map · tab next place` before

## Typing a long question on home — does the box wrap, and where does a paste go

**The box wraps.** Home's foot box is drawn by the same editor as the chat's message box:
a sentence longer than the frame wraps onto continuation rows — up to three — and past
that the window scrolls with the caret, marked with `…` where the `›` was. Nothing you
type is ever truncated out of view. There is no key to open a new line here (that is the
chat box's `ctrl+j`).
**The box wraps, and it is three rows tall the whole time.** Home's foot box is drawn by
the same editor as the chat's message box: a sentence longer than the frame wraps onto
continuation rows. The moment you type anything the box stands **three rows** high and
stays there — it does not grow under your hand as a sentence wraps, and the list above it
does not step down a row when it does. Past three rows the window scrolls with the caret,
marked with `…` where the `›` was. Nothing you type is ever truncated out of view. There
is no key to open a new line here (that is the chat box's `ctrl+j`).

**An empty box is three rows too.** With nothing typed it draws the dim sentence about
what this screen does on its first row and holds the other two open under it, so the one
thing on this screen you type into is a block you can see *before* you have typed anything.
It also means the foot does not move on the first keystroke: a box that jumped from one row
to three the moment a letter landed would shift the list up under the hand reaching for it.

**On a very short terminal the box is one row again.** Under about thirteen rows there is
no room to hold three open and still leave a list worth reading above them, so the box
falls back to the single row it has always drawn and the list keeps its rows. The box is
just as usable — what gives way is the space around it, not what you came to read.

**`alt+enter` is a task and not `ask here`.** On home and on every other place the chord
opens the **composer layer**, where the three facts a task needs are settled — where, on
Expand Down
2 changes: 1 addition & 1 deletion internal/manual/chat/models-and-cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -1621,7 +1621,7 @@ with no answer, no error, no note and an idle status line, and there was no way
— on the screen, in the transcript, or in the log — to tell your own stop from a
second window taking the conversation over.

## It said nobody was left watching but I was sitting right here — the reply stopped, bash cancelled, which window did it think had gone, the engine was stopped
## It said nobody was left watching but I was sitting right here — the reply stopped, bash cancelled, which window did it think had gone, the engine holding this conversation was stopped

`nobody was left watching this conversation, so the reply stopped — ask again to pick it up` is only what you read when the conversation was let go of because nobody was watching it. A goodbye of your own says `the reply stopped when this conversation was left — ask again to pick it up`. An engine stopped on somebody's word while a window is still in the room says `the engine holding this conversation was stopped, so the reply stopped — ask again to pick it up`.

Expand Down
40 changes: 37 additions & 3 deletions internal/manual/chat/staying-on-that-machine.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,9 +389,43 @@ nothing is holding /home/you/api here
```

`--workspace` picks which one; with no flag it means your home directory, exactly as it does
for `aforge engine` itself. The reason to type it is that a connection told you to: both
refusals about an older aforge still holding a machine name this command (*Running on
another machine*).
for `aforge engine` itself. **Type the flag.** Without it you stop whatever is holding your
home directory, which is usually not the folder that refused you — and the refusal comes
back on the next launch because the engine it was about is still running. Both refusals
about an older aforge spell the workspace out in the command they give you (*Running on
another machine*); copy the line as written.

## Stop every engine on this machine — aforge engine --stop-all, I don't know which folder is stuck

When you do not know which workspace is the problem, do not go looking for it:

```
aforge engine --stop-all
```

It stands down every engine this machine is holding, in every workspace, one at a time, and
names each one as it goes:

```
stopped holding /home/you/api
stopped holding /home/you/site
11 workspaces had nothing holding them
the next connection in any of them starts fresh from this build
```

Folders you once opened and have long since closed are counted on that one line rather than
listed, so the workspaces that actually let go are the ones you read. A workspace that
refuses does not stop the sweep — the whole point is the folder you did not know about, so a
failure on the third of five must not hide the fourth — and every refusal is reported
together at the end.

This is the blunt instrument and it is safe to reach for: a host let go of this way writes
every transcript out before it goes, and a turn the sweep catches keeps the part of its
reply that had arrived. What it costs you is that everything on this machine comes back
cold on the next connection rather than staying warm.

If what you are actually chasing is a reply that stopped and said so on screen, this is
not the page — *Models and cost* has the sentence you read and what each of them means.

## Rebuilt aforge but your conversation was still on the old engine — how aforge tells you

Expand Down
8 changes: 5 additions & 3 deletions internal/tui3/depthfade_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,11 @@ func fadeWord(i int) string { return strings.Repeat("i", i%7+1) + itoa(i) }
func historyRows(a *app) []string {
width, height := a.size()
lines, _, _, _ := a.taskSheetFrame(width, height)
// The foot the router draws is four rows on every place, filtering or not:
// the note rides the rule (placebodies.go's [placeNoteRule]).
return lines[placeHeadRows : len(lines)-placeFootRows]
// The foot the router draws is the same rows on every place, filtering or
// not — the note rides the rule (placebodies.go's [placeNoteRule]) — and how
// many of them the composer is worth depends on the height ([boxFloor]), so
// the frame's own door is asked for the count.
return lines[placeHeadRows : len(lines)-placeFootRowsAt(height)]
}

// THE TAIL OF THE RECORD FADES AND ITS HEAD DOES NOT. A page holding two hundred
Expand Down
10 changes: 8 additions & 2 deletions internal/tui3/head_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,13 @@ func TestWalkingBetweenAChatAndThePlacesMovesNothingAtTheFoot(t *testing.T) {
a := headLab(t)
for _, size := range headFrameSizes {
a.width, a.height = size.w, size.h
want := footEdges{rule: size.h - placeFootRows + 1, box: size.h - 2, status: size.h - 1}
// The box row is the row the PROMPT is on, which is the block's first
// ([footOf] reads it back that way), so it stands as far above the last
// row as the floor this height can afford is deep — asked of the frame's
// own door rather than written out again here.
floor := boxFloor(size.h)
want := footEdges{rule: size.h - placeFootRowsAt(size.h) + 1,
box: size.h - 1 - floor, status: size.h - 1}
for _, to := range []page{pageNone, pageHome, pageTasks, pageNone} {
if to == pageNone {
a.showPage(pageNone)
Expand All @@ -181,7 +187,7 @@ func TestWalkingBetweenAChatAndThePlacesMovesNothingAtTheFoot(t *testing.T) {
rows := strings.Split(plain(frame(a)), "\n")
if got := footOf(rows); got != want || strings.TrimSpace(rows[want.rule-1]) != "" {
t.Fatalf("at %dx%d %s puts its foot at %+v, and every frame puts it at %+v under a blank:\n%s",
size.w, size.h, pageName(to), got, want, strings.Join(rows[len(rows)-placeFootRows-1:], "\n"))
size.w, size.h, pageName(to), got, want, strings.Join(rows[len(rows)-placeFootRowsAt(size.h)-1:], "\n"))
}
}
}
Expand Down
Loading
Loading