adventure: give the summary long enough to wake the model up

The run summary had sixty seconds, which is a generation budget, and
this call almost never gets to just generate. Runs end hours apart,
Ollama drops an idle model after about five minutes, so the steady state
is weights on disk and a cold load before the first token. The old
budget expired during that load every time and filed the empty beat that
means "no summary, ever" — there is no retry, the beat itself is the row
that stops the sweep re-picking the run. Five minutes now, sized for
load-then-generate, so a timeout means what the comment always claimed
it meant: the box is down.

That can't sit on a two-minute ticker, so it doesn't. The sweep starts
beside the ticker behind a single-flight flag; ticks that land during a
load skip instead of queueing. Nothing reorders — the summary is written
to the local buffer with the next seq and still ships behind the run's
own log.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 22:07:47 -07:00
parent 509df7fadf
commit 34519c9145
2 changed files with 42 additions and 7 deletions
+38 -5
View File
@@ -8,6 +8,7 @@ import (
"os"
"sort"
"strings"
"sync/atomic"
"time"
"gogobee/internal/db"
@@ -51,19 +52,51 @@ const runSummaryMaxBeats = 120
// length alone. Byte count, matching Pete's len() check.
const maxRunSummary = 1200
// runSummaryTimeout is deliberately four times the dispatch budget.
// runSummaryTimeout has to cover a COLD model, not just a generation.
//
// dispatchLLMTimeout is tight because authoring runs on a game chokepoint and a
// template dispatch now beats a voiced one late. Nothing here is waiting on this:
// it is a background ticker, the run ended minutes ago, and the page it feeds is
// it is a background sweep, the run ended minutes ago, and the page it feeds is
// already serving without it. The cost of being impatient is the opposite of
// there — a timeout files an empty summary beat, and that run never gets another
// chance at one. So this waits long enough that a timeout means the box is down
// rather than that the model was thinking.
const runSummaryTimeout = 60 * time.Second
// chance at one.
//
// And impatience is the live risk, because this call is almost always the one
// that pays the load. Ollama evicts an idle model after about five minutes, and
// runs end far further apart than that, so the steady state is: model on disk,
// nothing resident, weights to page in before the first token. A budget sized
// for generation alone would expire during the load on every single run and file
// an empty beat that says the box is down when the box is fine. So this is sized
// for load-then-generate, and a timeout here really does mean the box is down.
const runSummaryTimeout = 5 * time.Minute
var runSummaryHTTP = &http.Client{Timeout: runSummaryTimeout}
// runSummaryBusy is the whole concurrency story: at most one sweep in flight,
// ever. The ticker starts one and moves on, so a cold model loading for minutes
// costs the board nothing, and the ticks that fire meanwhile find the flag set
// and skip rather than queue.
var runSummaryBusy atomic.Bool
// sweepRunSummariesAsync starts a sweep off the caller's goroutine if one isn't
// already running.
//
// It has to be off the ticker: runSummaryTimeout is minutes and the tick is two,
// so a synchronous call would hold the roster, details, siege and beat pushes
// behind a model load and put the live board permanently a tick or more behind
// the game. Ordering against those pushes is not lost by going async — the beat
// this files is written to the local buffer with the next seq, and the pusher
// ships it by seq on whichever tick comes after, still behind the run's own log.
func (p *AdventurePlugin) sweepRunSummariesAsync() {
if !runSummaryBusy.CompareAndSwap(false, true) {
return // one still working; the next tick will find it done or still busy
}
go func() {
defer runSummaryBusy.Store(false)
p.sweepRunSummaries()
}()
}
// sweepRunSummaries authors the summary for at most one finished run per call.
// Called from the roster ticker, after the beats themselves have been pushed —
// the summary is the last beat of a run's story and there is no rush to have it