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
1 change: 1 addition & 0 deletions PERF.md
Original file line number Diff line number Diff line change
Expand Up @@ -1824,6 +1824,7 @@ and the run met none of them.

| Law | The bound, and where it comes from | Where it is pinned |
| --- | --- | --- |
| **A connection nobody is using is kept for as long as the ROUTER itself keeps one, and pinged so neither end races the other at the edge of that window.** The pool's idle timeout was `http.DefaultTransport`'s inherited 90 seconds, which is shorter than a person reads for: someone who took two minutes over an answer and then typed again paid DNS, TCP and TLS inside the next request's own `Do`, invisibly, because `ttft_ms` folds a handshake into the model's wait. | `idleConnTimeout`, 6m, is the FAR END'S OWN figure, measured against the live router on 2026-09-11 by holding connections idle and asking `httptrace` whether the next request rode the same socket: 30s, 2m, 5m and 6m reused; 7m, 8m and 9m opened fresh. Adopting the router's answer means neither end is guessing. `http2KeepAlive`, 2m, is what stops the far end closing first INSIDE that window, which is known to lie between six and seven minutes and not to the second — at least two pings must land inside the shortest window the measurement proves, so the period is at most half of it; 15m and 20m of idle both reused with a ping every 2m. The same ping is the health check that closes a socket whose answer never comes, which is what makes a pool held for minutes safe on a laptop that slept. `tlsHandshakeTimeout` 10s, `expectContinueTimeout` 1s and `pingAnswerTimeout` 30s are stated rather than inherited for the same reason, each with its own line in the table at the top of `transport.go`. The row that separates a cold pool from a slow machine is `conn_reused` / `dns_ms` / `connect_ms` / `tls_ms` on the call log. | `internal/provider/conntrace_test.go` (`TestTheSharedTransportStatesEveryValueItRelieson` asserts every value and the two-pings-inside-the-window inequality between the named constants) |
| **A completion is asked for as a STREAM whether or not anybody is watching it.** The observer decides who is TOLD; it never decided whether the call is guarded, and until this it silently did. | No bound of its own. It is what arms the three below on a headless call. | `internal/provider/unwatched_test.go` |
| **A request that has produced no token is cut.** | `firstDeltaBound`, 90s — past every healthy first token this adapter has measured, and under the streaming transport's `responseHeaderTimeout` so a stall is named rather than surfacing as a torn connection. | `internal/provider/streamguard_test.go` |
| **A stream that has gone quiet is cut**, with keepalives buying bounded patience and no more. | `midStreamGapBound`, 45s, is what a lane nothing is known about gets — half the first bound, because a model that has started writing has finished deciding. A lane whose RATE this process has measured is cut at `gapFor(rate)` instead: the time that lane takes to write `streamGapLumpTokens` (3,500 — the fourteen-kilobyte server-side lump of 2026-08-24, at the estimator's four bytes to the token), clamped to `LagGap`…`midStreamGapBound`. So 250 tok/s waits 15s where a stranger waits 45. The keepalive extension window stays the FLAT bound at every rate, and `bufferedQuietBound`, 150s, still caps the total quiet. | `internal/provider/streamguard_test.go`, `internal/provider/patience_measured_test.go` |
Expand Down
51 changes: 47 additions & 4 deletions cmd/aforge-census/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,17 @@ type health struct {
clean int
millis []int64
failures map[statusClass]int
// traced is how many of these finishes said anything about their
// connection, and warm how many of those rode one the pool already had.
// The pair is kept rather than a rate because a log written by a build
// older than the tracing carries neither, and a share computed over rows
// that could not answer would read as a cold pool rather than as silence.
traced int
warm int
// opening is how long the ones that were NOT warm spent resolving,
// connecting and shaking hands — the part of `ms` that was never the
// model's.
opening []int64
}

// healthSection is the clean-answer rate and median latency of each (model,
Expand Down Expand Up @@ -376,6 +387,24 @@ func healthSection(out io.Writer, finishes []row, look settings) {
}
held.n++
counted++
// WHAT THE CONNECTION COST, kept beside the answer's own time. A cold
// pool and a slow model are the same number in `ms` and in `ttft_ms`,
// and these two columns are the whole of the difference
// (internal/provider's conntrace.go).
if r.ConnReused != nil {
held.traced++
if *r.ConnReused {
held.warm++
} else {
// A CONNECTION OPENED FRESH COUNTS EVEN WHEN IT COST NOTHING
// MEASURABLE. On a fast link the three parts together round to
// zero milliseconds, and dropping those rows would leave the
// median describing only the expensive handshakes — the
// emptiness law is about a figure nobody measured, and this one
// was measured and came out small.
held.opening = append(held.opening, r.DNSms+r.ConnectMs+r.TLSms)
}
}
class := r.statusClass()
if class == classClean {
held.clean++
Expand All @@ -399,20 +428,34 @@ func healthSection(out io.Writer, finishes []row, look settings) {
fmt.Fprint(out, "No pair has been asked enough times to say anything about.\n\n")
return
}
fmt.Fprintln(out, "| model | served | n | ok % | median ms | top failures |")
fmt.Fprintln(out, "| --- | --- | ---: | ---: | ---: | --- |")
fmt.Fprintln(out, "| model | served | n | ok % | median ms | warm % | opening ms | top failures |")
fmt.Fprintln(out, "| --- | --- | ---: | ---: | ---: | ---: | ---: | --- |")
for _, held := range ordered {
spelledFailures := map[string]int{}
for class, n := range held.failures {
spelledFailures[string(class)] = n
}
fmt.Fprintf(out, "| %s | %s | %d | %s | %s | %s |\n",
fmt.Fprintf(out, "| %s | %s | %d | %s | %s | %s | %s | %s |\n",
cell(held.model), cell(held.machine), held.n,
trimShare(held.clean, held.n), median(held.millis), topOf(spelledFailures, 3))
trimShare(held.clean, held.n), median(held.millis),
warmth(held.warm, held.traced), median(held.opening),
topOf(spelledFailures, 3))
}
fmt.Fprintln(out)
}

// warmth is how often this pair rode a connection the pool already had, and
// nothing at all for rows written by a build that did not measure it. THE
// EMPTINESS LAW MATTERS HERE MORE THAN ANYWHERE ELSE IN THIS TABLE: a zero
// would read as a pool that is always cold, which is exactly the finding this
// column exists to report, and reporting it from silence would be a lie.
func warmth(warm, traced int) string {
if traced == 0 {
return "—"
}
return trimShare(warm, traced)
}

// median is the middle of what was measured, and nothing at all when nothing
// was — the emptiness law, in a table cell.
func median(values []int64) string {
Expand Down
76 changes: 76 additions & 0 deletions docs/changes/unreleased/872-warm-wire-and-the-socket.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
kind: changed
title: A warm wire and a socket that never waits — the reader reads, and a keystroke crosses
pr: 872
surface: [engine, remote, chat]
invalidates:
- "\"The manual describes the typing measurement, so a person can find out
about it.\" The page said what it sends and what it costs, and left out the
one thing somebody checking a bill needs: it appears in NO money figure
aforge draws. /cost, the status line, the spend place and the end-of-run
total are all sums over call-log rows, and the probe deliberately writes
none — it hangs up at the first word, so the provider's own token count
never arrives. models-and-cost.md now says so under `Spend that /cost does
not show`, and the /cost section's claim that it counts EVERY request is
corrected. #903 is whether that absence should end."
- "\"Typing pre-warms the lane.\" It did not — anywhere, on any road, since
the completer wrapper landed — and it was broken in TWO places, both mended
here. The surface reaches for the capability through an optional interface
that *remote.Agent did not satisfy, so on every launch that is not
--no-host, --debug, first-run setup or a hostless --once the keystroke never
left the window; it crosses now, as MethodTyping. And internal/session's
probeClientLanes asserts an optional prober on a completer that
installSessionClient always wraps in sessionCompleter, which forwarded
CompleteWithMessages and FallbackModels and not ProbeLanes — so even in
process, with the keystroke arriving, no probe had ever been bought. The
test that covered it built an Agent literal and never met the wrapper."
- "\"An ordered call runs on the reader goroutine, and that is what makes it
ordered.\" Two separate things wearing one name. Ordered calls now run on
the connection's orderedLane — one goroutine, arrival order — and NOTHING
runs on the reader. staysOnReader is deleted; callClass.road() has two
values and neither is the reader. Anything that said Compact or Submit
\"holds the reader\" is stale: they hold the lane, which is the thing that
owes the order."
- "\"A pause between turns is free.\" It cost a handshake. The shared
transport inherited http.DefaultTransport's 90-second IdleConnTimeout,
which is shorter than a person reads for, so the next turn paid DNS, TCP
and TLS inside its own httpClient.Do. It was invisible because ttft_ms
folds the handshake into the model's wait. The four transport values are
now stated with a reason each, and the idle window is the router's OWN,
measured against it from the Spark on 2026-09-11: a connection held idle
for six minutes still rode the same socket and one held for seven did not.
An HTTP/2 PING every two minutes keeps it open through fifteen and twenty
minutes of silence, so the pool also carries a keep-alive rather than
racing the far end at the edge of its window."
- "\"A slow ttft_ms means a slow model.\" Not on its own. The finish row now
carries conn_reused, and dns_ms / connect_ms / tls_ms where the connection
was opened fresh, so a cold pool and a slow machine are separable. `make
census` grows a `warm %` and an `opening ms` column on the lane-health
table. Rows written before this carry none of the four, and the census
draws nothing rather than a zero."
---

Two laws land with them, and both are the same shape one layer apart: an
optional door asserted on a value somebody wraps or replaces looks exactly like
a capability that was never meant to be there, and nothing tells you when it
stops being reachable. internal/remote's
TestEverySurfaceDoorTheEngineHasCrossesTheWire holds the surface-to-wire seam
(with a ratcheted ledger of the twenty-three doors that do not cross yet), and
internal/session's TestTheCompleterWrapperForwardsEveryDoorTheAdapterOffers
holds the agent-to-adapter one.

The three findings are one sentence each and they are all about waiting for
something that was not the model. A send held the whole socket shut while the
engine assembled a prompt; a keystroke that was supposed to open the connection
early never reached the engine at all; and the pool threw the connection away
while the person was still reading. `internal/remote/orderedlane.go` is the one
mechanism the first needed, and
`TestEverySurfaceDoorTheEngineHasCrossesTheWire` is the ledger that stops the
second class coming back — twenty-three other doors the local engine answers
and the wire does not are written down in it, and the count may only fall.
Each entry says what a person loses, and the eight that do not merely go quiet
but print a REASON THAT IS NOT THE REASON — `/remember` saying memory is
switched off, `/land` saying nothing is waiting, `/autonomy` saying the
conversation has no project — carry the sentence verbatim, checked against the
surface's own source so it cannot drift into fiction. #901 is the parent issue
for landing them, grouped into families rather than doors.
31 changes: 31 additions & 0 deletions internal/calllog/calllog.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,37 @@ type Record struct {
// any row, because nothing wrote it.
RetryAfterS float64 `json:"retry_after,omitempty"`

// ── what the connection itself cost
//
// A COLD POOL AND A SLOW MODEL ARE INDISTINGUISHABLE IN `ttft_ms`, and
// separating them is what these four are for. The wait before the first
// token includes everything the transport had to do before a byte of the
// request left this machine: resolve the name, open the socket, finish the
// handshake. On a warm pool all three are zero and the whole of `ttft_ms`
// belongs to the endpoint; on a cold one a hundred to four hundred
// milliseconds of it is ours, and a census that could not see the
// difference read every think-pause as the model getting slower.
//
// They are written from `net/http/httptrace`, which costs nothing: the
// hooks fire on the transport's own goroutine and record instants.

// ConnReused is whether this attempt rode a connection the pool already
// had. It is the single most useful field of the four, and it is spelled
// as a bool rather than inferred from a zero handshake because "the pool
// was warm" and "nothing was measured" are different facts.
//
// FALSE IS WRITTEN OUT. The emptiness law leaves an unknown blank, and a
// cold connection is not unknown — it is the finding. So the row carries
// `conn_reused:false` where a fresh connection was opened, and carries the
// field not at all where no trace was taken (a probe, a document post).
ConnReused *bool `json:"conn_reused,omitempty"`
// DNSms, ConnectMs and TLSms are the three parts of opening one, in
// milliseconds, and every one of them is absent on a reused connection
// because none of them happened.
DNSms int64 `json:"dns_ms,omitempty"`
ConnectMs int64 `json:"connect_ms,omitempty"`
TLSms int64 `json:"tls_ms,omitempty"`

// ── why it waited, and what was done about it
//
// The four fields above say what was BELIEVED before the request went out.
Expand Down
53 changes: 46 additions & 7 deletions internal/manual/chat/models-and-cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -1776,13 +1776,17 @@ function, and if you ever see them differ, that is a bug worth reporting.
the provider — every step of a turn is its own request — so this figure is normally larger
than the number of times you have spoken.

It counts **every** request, not only the ones in your turns: naming the session, a judge
deciding where something should be routed, looking at a picture, every request a task's
own agent made on its own lane, and every request a harness run made while it walked its
program. That is deliberate, because the `spend` line above it is the
It counts every request that is written down, not only the ones in your turns: naming the
session, a judge deciding where something should be routed, looking at a picture, every
request a task's own agent made on its own lane, and every request a harness run made
while it walked its program. That is deliberate, because the `spend` line above it is the
sum over exactly those requests — a smaller count beside it would be a bill divided by the
wrong number.

**One request is not written down and so is not in either figure**: the one-token
measurement sent while you are typing. Your provider bills it and aforge does not count
it — there is a section on that below, `Spend that /cost does not show`.

`empty reflex answers` appears only when that failure happened. Those requests remain in
the token, call and spend totals because the provider billed them; the separate count says
that the money bought no memory decision. aforge retries one such answer with more room,
Expand Down Expand Up @@ -2740,7 +2744,7 @@ Turn it off if you are paying for every token and never mind waiting. With it of
are one row because they are one promise: aforge may spend a little extra to keep an
answer moving.

## Why aforge sends something when you start typing — the one-token measurement
## Does aforge send anything while I am typing — the one-token measurement it sends before you press enter

While you are typing, and before you press enter, aforge sends **one token** to each of
the two lanes your next message would most likely go to, and times how long the first
Expand All @@ -2754,12 +2758,47 @@ token out, twice.

**How often.** At most one pair every **twenty seconds** per model, however fast you
type — so a long message buys one, not one per keystroke. None at all when the **speed
guard** is off, when `routing` is `off`, when the lane row says `openrouter`, or when
the connection pool is already being rate-limited.
guard** is off, when `routing` is `off`, when the lane row says `openrouter`, when the
pool is already backing off a rate limit, when aforge is still recovering a dropped
connection, or when **nobody is waiting on that model** — a task working on its own and
an errand buy none, because the measurement exists to shorten a wait somebody is sitting
through.

**Nothing ever waits for it.** It is sent and forgotten; a message you send a moment
later does not wait on it, and a probe that fails teaches nothing and changes nothing.

**It is not in `/cost`.** It is a real request to a real provider and your provider bills
you for it, and aforge's own figures do not include it. The next section says why, what
it adds up to, and where to see it.

## Spend that /cost does not show — why the typing measurement is missing from the figures

There is exactly one request aforge makes that its own money figures do not count: the
**one-token measurement** it sends while you are typing, to warm the connection and time
the machine your next message is heading for. Your provider bills you for it. `/cost`,
the status line, the spend place (`alt+3`) and the total at the end of `aforge do` all
leave it out, and so do the call-log rows and `aforge-census`.

**Why it is missing.** Those figures are all counts of the **call log**, and the
measurement deliberately writes no row there — it skips the shaping, the retries and the
record on purpose, so that what it times is one clean request to one named machine and
not a retry of one. And it hangs up **at the first word**, so the token count a provider
sends at the end of a reply never arrives: there is no measured figure to add up, only
the fixed shape of the request.

**What it comes to.** Ten tokens in and one token out, twice — about **two hundredths of
a cent** a pair, at most one pair every twenty seconds per model, and only while somebody
is actually sitting there waiting. An unbroken hour of typing is a few cents. It cannot
run in the background, and a task working on its own buys none.

**How to see it anyway.** Every measurement it buys is appended to
`~/.aforge/v3/lanes.log`, one line of JSON each, with the ones bought this way marked as
probes. That file is the record of what was sent.

**How to make it zero.** Settings → Providers → **speed guard**, off. The same row governs
asking a second machine when an answer is slow to start, so turning it off stops both.
`routing off` and a lane row set to `openrouter` also stop it.

## The lane row in settings — auto, pinned, pinned but borrowable, openrouter

Settings → Providers has two rows under **routing**:
Expand Down
Loading
Loading