From 34519c91455613451dc6f77c3afd4f12e2ed31b4 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:07:47 -0700 Subject: [PATCH] adventure: give the summary long enough to wake the model up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/plugin/pete_roster.go | 6 ++-- internal/plugin/pete_run_summary.go | 43 +++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go index 39a3cc7..bc7e2fb 100644 --- a/internal/plugin/pete_roster.go +++ b/internal/plugin/pete_roster.go @@ -60,8 +60,10 @@ func (p *AdventurePlugin) peteRosterTicker() { // After the beats, not before: the summary is the last beat of a run's // story and has no business overtaking the log it is about. It is also the // only step here that can talk to the model, which is why it lives on a - // ticker at all rather than at the moment a run ends. - p.sweepRunSummaries() + // ticker at all rather than at the moment a run ends — and why it starts + // beside the ticker rather than on it, since a cold model takes longer to + // load than the interval between two pushes. + p.sweepRunSummariesAsync() } } diff --git a/internal/plugin/pete_run_summary.go b/internal/plugin/pete_run_summary.go index 16b920e..0bfab14 100644 --- a/internal/plugin/pete_run_summary.go +++ b/internal/plugin/pete_run_summary.go @@ -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