diff --git a/docs/changes/unreleased/951-open-call-says-so.md b/docs/changes/unreleased/951-open-call-says-so.md new file mode 100644 index 0000000000..63ef2c5547 --- /dev/null +++ b/docs/changes/unreleased/951-open-call-says-so.md @@ -0,0 +1,21 @@ +--- +kind: fixed +title: A task's page says a call is still open, with that call's own clock and rate +pr: 951 +surface: [chat, engine] +invalidates: + - "A task's page drew nothing about a request that had gone out and not come back. A model call that is still streaming has written no entry yet, so the page's own rows could not know about it, and the header fell through to `still working` — one audited call ran twelve minutes with the header saying nothing but the task's age and a tool count. While a call is open the live segment is now THAT CALL's clock and its rate, and when the call comes back the segment reverts to the ladder it always had." + - "`session.TaskBeatRow`, its `Working` method and `ReadTaskBeat` are exported (they were `taskBeatRow`, `working` and `readTaskBeat`). The pulse sidecar is the one source that knows a request is in flight, and a surface had no way to ask. `TaskRecord` carries `Beat` and `Agent.TaskBeat(id)` answers it, so the file's name crosses the wire as data and a page over a connection reads the pulse the same way a local one does." + - "The path is CARRIED, never rebuilt from the node id. A reader that composed it would be a second spelling of where the pulse lives, and the two would drift the day the layout moved — which is the bargain the record's own field exists to end." +--- + +The pulse is read on the refresh tick the reading came from, never once at room +entry: a reading cached at the door would leave the header saying one thing for +the whole life of a call, which is the defect this closes wearing a different +hat. + +Nothing new is spelled here. The rate is the same `PhaseNews.Rate` the status +row's right edge draws, through the same `tok/s` that line builds, and the clock +is `countUpWord`, the live spelling every moving figure on this surface already +uses. Two rows that disagreed about what *how fast* looks like would be worse +than one row saying nothing. diff --git a/internal/manual/chat/reading-a-task-page.md b/internal/manual/chat/reading-a-task-page.md index 23a4b35535..552bf091eb 100644 --- a/internal/manual/chat/reading-a-task-page.md +++ b/internal/manual/chat/reading-a-task-page.md @@ -197,8 +197,10 @@ you are standing on and does nothing. The task's name first — that never gets cut while there is room for it — then what it is doing, how long it has been going, what it has cost, how many calls it has made, and what -is running right now. A stretch where nothing has arrived for ten seconds says `still -working`, which is the truest thing the page can say about a silence. +is running right now. While a request is out and has not come back, that last fact is +**that call's own clock, and its rate while there is one to measure** — see *A call has +been open for ages* below. A stretch where nothing has arrived for ten seconds and no call +is open says `still working`, which is the truest thing the page can say about a silence. **Anything nobody has published is simply absent.** A task that has cost nothing shows no cost, one that has called nothing shows no count, and a queued one has no clock — a figure @@ -208,6 +210,34 @@ from the end, in that order, and never cuts the name. The first of those facts — the word for what the work is doing — has a fixed vocabulary, and the next section lists every word it can be. +## A call has been open for ages — the page says nothing, is it stuck, how long has this request been running + +**It says how long, now.** While a request is out and has not come back, the last fact on +the header is that call's own clock, counting up, with its rate beside it while there is a +rate to measure: + +``` +⠙ main ▸ Port the loader · working · 12m 4s · $0.42 · 14 tool calls · 11m 38s · 47 tok/s +``` + +The two clocks are different things and both are worth having. The first is the whole +task's age. The last is **this request's** age — the one that has not answered yet. + +This used to be invisible, and that was the worst silence on the page. A model call that +is still streaming has written no row yet, so nothing the page could see knew about it, +and a call that ran twelve minutes looked exactly like a task doing nothing at all. The +page reads the work's pulse for it now, which is the one place that knows a request went +out and has not returned. + +**When the call comes back the clock goes away** and the line returns to naming whatever +is running. A task that is not running, a call that has answered, and work whose pulse has +been taken away with its landing all draw nothing there — a figure nobody is measuring is +a figure this surface does not print. + +The rate is the same one the status line at the bottom of the screen draws, in the same +words, so the two can never tell you different things about how fast the same call is +going. + ## What the word at the top of a task's page means — the task page header says sizing the work, queued, checking what it left, the header word on a task's page The pinned header of a task's page carries one word for what the work is doing right now. diff --git a/internal/session/task_beat.go b/internal/session/task_beat.go index 92aac1c580..46f2729474 100644 --- a/internal/session/task_beat.go +++ b/internal/session/task_beat.go @@ -97,8 +97,8 @@ const ( // directory — can never mistake a pulse for a transcript. const taskBeatSuffix = ".beat.json" -// taskBeatRow is one running node's pulse as the file holds it. -type taskBeatRow struct { +// TaskBeatRow is one running node's pulse as the file holds it. +type TaskBeatRow struct { // Node is the node's id, and Title what a reader would call it. Node uint64 `json:"node"` Title string `json:"title,omitempty"` @@ -125,7 +125,7 @@ type taskBeatRow struct { // working reports whether a request is in flight: the last start is not answered // by a finish. -func (r taskBeatRow) working() bool { +func (r TaskBeatRow) Working() bool { return !r.RequestStarted.IsZero() && r.RequestFinished.Before(r.RequestStarted) } @@ -137,7 +137,7 @@ func (r taskBeatRow) working() bool { type taskBeat struct { mu sync.Mutex path string - row taskBeatRow + row TaskBeatRow } // newTaskBeat builds a node's pulse. It writes nothing: the first row goes down @@ -149,7 +149,7 @@ func newTaskBeat(path string, id uint64, title string, started time.Time) *taskB if started.IsZero() { started = time.Now() } - return &taskBeat{path: path, row: taskBeatRow{ + return &taskBeat{path: path, row: TaskBeatRow{ Node: id, Title: title, Phase: taskBeatWorking, @@ -253,7 +253,7 @@ func (b *taskBeat) write() { } } -// readTaskBeat reads one node's pulse back. It is the whole read side, and it is +// ReadTaskBeat reads one node's pulse back. It is the whole read side, and it is // here rather than in a reader's own package so that the file's shape has one // definition — the same argument [runRowRecord] and [runRowNotice] are neighbours // for. @@ -261,17 +261,17 @@ func (b *taskBeat) write() { // A missing file is the ordinary answer for a node that is not running, and it is // a false rather than an error: a reader asking whether work is alive is not // asking a question that can fail. -func readTaskBeat(path string) (taskBeatRow, bool) { +func ReadTaskBeat(path string) (TaskBeatRow, bool) { if strings.TrimSpace(path) == "" { - return taskBeatRow{}, false + return TaskBeatRow{}, false } content, err := os.ReadFile(path) if err != nil { - return taskBeatRow{}, false + return TaskBeatRow{}, false } - var row taskBeatRow + var row TaskBeatRow if err := json.Unmarshal(content, &row); err != nil { - return taskBeatRow{}, false + return TaskBeatRow{}, false } return row, true } diff --git a/internal/session/task_beat_test.go b/internal/session/task_beat_test.go index 9f10300146..a13bbffae6 100644 --- a/internal/session/task_beat_test.go +++ b/internal/session/task_beat_test.go @@ -23,7 +23,7 @@ import ( // beatWatch collects what the pulse said at each moment somebody looked. type beatWatch struct { mu sync.Mutex - rows []taskBeatRow + rows []TaskBeatRow // named is what the CHECKPOINT said about the pulse while the node ran. The // row names the sidecar, so a reader that already has tasks.json open never // has to guess at a path (task_store.go's taskRecord.Beat). @@ -36,7 +36,7 @@ type beatWatch struct { // looked". func (w *beatWatch) look(agent *Agent, id uint64) { store := agent.graph().store - row, _ := readTaskBeat(store.beatPath(id)) + row, _ := ReadTaskBeat(store.beatPath(id)) named := "" if document, ok := loadTaskCheckpoint(store.path); ok { for _, record := range document.Nodes { @@ -53,10 +53,10 @@ func (w *beatWatch) look(agent *Agent, id uint64) { } } -func (w *beatWatch) seen() []taskBeatRow { +func (w *beatWatch) seen() []TaskBeatRow { w.mu.Lock() defer w.mu.Unlock() - out := make([]taskBeatRow, len(w.rows)) + out := make([]TaskBeatRow, len(w.rows)) copy(out, w.rows) return out } @@ -137,7 +137,7 @@ func TestARunningNodesHeartbeatAdvancesAcrossItsCalls(t *testing.T) { if row.Node != 1 { t.Fatalf("reading %d is about node %d, want the node that is running", index, row.Node) } - if !row.working() { + if !row.Working() { t.Errorf("reading %d, taken INSIDE a request, does not show one in flight: %+v", index, row) } } diff --git a/internal/session/task_calltrail_test.go b/internal/session/task_calltrail_test.go index d3f410adbc..44d96a337a 100644 --- a/internal/session/task_calltrail_test.go +++ b/internal/session/task_calltrail_test.go @@ -157,12 +157,12 @@ func TestASizingRequestLeavesItsTrailOnTheJournalThePulseAndTheRow(t *testing.T) } // THE PULSE: one request, out and back. - row, ok := readTaskBeat(beatAt) + row, ok := ReadTaskBeat(beatAt) if !ok { t.Fatal("the node's pulse was never written") } - if row.Requests != 1 || row.working() { - t.Fatalf("the pulse says %d requests, in flight %v; want the one reading, answered", row.Requests, row.working()) + if row.Requests != 1 || row.Working() { + t.Fatalf("the pulse says %d requests, in flight %v; want the one reading, answered", row.Requests, row.Working()) } } @@ -330,12 +330,12 @@ func TestAWalkedRequestIsOnePairOfLinesOnePulseAndItsOwnLength(t *testing.T) { // THE PULSE: one request, out and back, exactly as a worker's own retry // ladder moves it once. - row, ok := readTaskBeat(beatAt) + row, ok := ReadTaskBeat(beatAt) if !ok { t.Fatal("the node's pulse was never written") } - if row.Requests != 1 || row.working() { - t.Fatalf("the pulse says %d requests, in flight %v; want one, answered", row.Requests, row.working()) + if row.Requests != 1 || row.Working() { + t.Fatalf("the pulse says %d requests, in flight %v; want one, answered", row.Requests, row.Working()) } } @@ -376,8 +376,8 @@ func TestAQuestionsEndingClosesTheRescueRacingBesideIt(t *testing.T) { if got := ends[0].End; got != string(provider.CallEndCancelled) { t.Fatalf("the arm the winner cut is written down as %q, want it left", got) } - if row, _ := readTaskBeat(beatAt); row.Requests != 2 || row.working() { - t.Fatalf("the pulse says %d requests, in flight %v; want both, answered", row.Requests, row.working()) + if row, _ := ReadTaskBeat(beatAt); row.Requests != 2 || row.Working() { + t.Fatalf("the pulse says %d requests, in flight %v; want both, answered", row.Requests, row.Working()) } } diff --git a/internal/session/task_room.go b/internal/session/task_room.go index 7ed7e718b4..bf196d4b37 100644 --- a/internal/session/task_room.go +++ b/internal/session/task_room.go @@ -857,6 +857,25 @@ func (a *Agent) TaskJournal(id uint64) string { return path } +// TaskBeat is where a running node's pulse sidecar lives, as the node itself +// named it — the same field the checkpoint's own row carries (task_store.go's +// taskRecord.Beat) — and "" for every node that is not writing one: an unknown +// id, a finished node whose pulse was taken away (task_beat.go's stop), or a +// session with no journal and therefore no store to name it. +// +// IT IS ASKED FOR AND NOT RECOMPUTED, for the same reason the record carries +// the name at all: a reader that built the path from an id would be a second +// spelling of where the pulse lives, and the two would drift the day the +// layout moved. A surface over a connection asks the engine for it with the +// record itself, so the file's name crosses the wire as data. +func (a *Agent) TaskBeat(id uint64) string { + node := a.taskNode(id) + if node == nil || node.graph == nil { + return "" + } + return node.graph.store.beatPath(node.id) +} + // TaskContextTokens is what one node's worker weighs right now — the request it // has in flight, the figure a node's page draws as ↑ — or zero when nobody is // working the node: an unknown id, a node that has not started or has ended, diff --git a/internal/session/taskrecord.go b/internal/session/taskrecord.go index ff05d7a0db..37ccc7dc70 100644 --- a/internal/session/taskrecord.go +++ b/internal/session/taskrecord.go @@ -64,6 +64,16 @@ type TaskRecord struct { // it — because a torn first line is a line the reader throws away and a // caller cannot tell that from a line that was never written. Journal []byte `json:"journal,omitempty"` + // Beat is the node's pulse sidecar as the record itself names it + // (task_store.go's taskRecord.Beat, written by task_beat.go), and "" for + // every node that is not running one. IT IS THE RECORD'S OWN NAME FOR THE + // FILE AND NEVER A PATH THIS SIDE COULD BUILD: a reader that recomputed it + // from an id would be a second spelling of where the pulse lives, and the + // whole bargain of the field is that whoever holds the record never has to + // guess. Over a connection the file is on the machine that ran the work, so + // the path crosses as data and is opened by whoever can reach it; empty on + // the far reader is the honest answer and not a failure. + Beat string `json:"beat,omitempty"` } // TaskRecordPath is the LOCAL FILE a row's URI names, or "" for a URI that names diff --git a/internal/tui3/app.go b/internal/tui3/app.go index b61351c04b..a618d22893 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -5631,6 +5631,13 @@ type usageMsg struct { roomWeight int roomID uint64 roomGen int + // roomBeat is the open room's node pulse, read beside the rest for the room + // its id and generation name — and only where the file is on THIS machine, + // which the path's being carried at all is the test for. Empty beatPath means + // the node is not writing one, and empty is the honest answer. + roomBeatPath string + roomBeat session.TaskBeatRow + roomBeatRead bool } // usageKick asks the session what it has spent, off the update loop. See the diff --git a/internal/tui3/room.go b/internal/tui3/room.go index ba76537379..60dfb2cfb3 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -386,13 +386,25 @@ type taskRoom struct { // no other time — and, unlike the conversation's, on the HEIGHT too, because // a room's fold keeps as many calls as its view is tall ([app.roomToolTail]) // and a taller view is a different row list. - rows []row - width int - height int - dirty bool - loading bool - readFailed bool - journal []byte + rows []row + width int + height int + dirty bool + loading bool + readFailed bool + journal []byte + // beatPath is where this node's pulse lives, AS THE RECORD ITSELF NAMED IT + // (session.TaskRecord.Beat, from the checkpoint row's own field), and "" for a + // node that is not writing one. IT IS CARRIED AND NEVER BUILT: a page that + // recomputed it from the id would be a second spelling of where the pulse + // lives, which is the exact bargain the record's field exists to end. + beatPath string + // beat is the LAST READING of the pulse, taken on the refresh tick itself so + // the display keeps moving and never freezes on one take — and false for a + // node with no pulse, which renders nothing, the same emptiness as any other + // unknown ([roomFactsOf]'s live segment is the consumer). + beat session.TaskBeatRow + beatRead bool lastSteerAt time.Time pendingSteers []roomSteerEcho } @@ -855,7 +867,7 @@ func (a *app) farRoomRead(msg roomRecordMsg) tea.Cmd { a.room.loading = false a.room.readFailed = msg.err != nil if msg.err == nil { - a.refreshRoomRecord(msg.record.Journal) + a.refreshRoomRecord(msg.record.Journal, msg.record.Beat) } // A GUEST PAGE TAKES ONE THING FROM THE READING AND ONE ONLY: whether the // conversation it joined is still the conversation it joined. What the WORK is @@ -2726,6 +2738,17 @@ func (a *app) roomLiveWord(node *taskNode, work roomWork) string { if node.state != session.TaskRunning { return "" } + // THE OPEN CALL'S OWN SENTENCE, WHEN THE PULSE SAYS ONE IS IN FLIGHT. The + // pulse is the one source that knows a request went out and has not come + // back — the page's own rows cannot, because a model call that is still + // streaming has written no entry yet, which is the exact defect #837 was + // filed over: one call ran twelve minutes with the header saying nothing + // about it but the task's own age and a tool count. While a call is open the + // live segment is THE CALL'S clock and its rate, and when it comes back the + // segment falls through to the ladder below — reverted, not replaced. + if open := a.roomOpenCallWord(node); open != "" { + return open + } word := work.running if word == "" && !work.last.IsZero() && a.now().Sub(work.last) >= stillWorking { word = strings.TrimPrefix(stillWorkingWord, " · ") @@ -2736,6 +2759,42 @@ func (a *app) roomLiveWord(node *taskNode, work roomWork) string { return word } +// roomOpenCallWord is the in-flight call's own sentence, in the exact words the +// chat foot spells the live rate — or "" when no call is open. +// +// IT IS THE FOOT'S VOCABULARY AND NEVER A SECOND ONE. The rate is the same +// [PhaseNews.Rate] the status row's right edge draws ([app.liveRiderAt]), spelled +// by the same " tok/s" that line builds, so the two rows can never disagree on +// what "how fast" looks like. The clock is the call's own elapsed in the live +// spelling every moving figure on this surface already uses ([countUpWord]), and +// it needs no wakeup of its own: the frame already repaints on the tick while a +// room is open, so a ten-minute call ticks. +// +// THE PREDICATE IS THE PULSE'S AND NOT A GUESS. [session.TaskBeatRow.Working] is +// "the last start has not been answered by a finish", and a node with no pulse +// at all — an unknown id, a landed node whose pulse was taken away with the +// landing, a build that cannot read the file — answers false and draws nothing, +// which is the emptiness law and not a failure to draw. +func (a *app) roomOpenCallWord(node *taskNode) string { + if node.state != session.TaskRunning { + return "" + } + if !a.room.beatRead || !a.room.beat.Working() { + return "" + } + var fields []rowField + if clock := countUpWord(a.now().Sub(a.room.beat.RequestStarted)); clock != "" { + fields = append(fields, rowSay(clock)) + } + if news, ok := a.roomPhase(); ok && a.windowWorking() && news.Rate > 0 { + // The rate rides only while it is being measured, which is the foot's own + // law ([app.liveRiderAt]): a rate nobody is producing is nothing, never + // `0 tok/s`. + fields = append(fields, rowSay(tokenWord(int(news.Rate))+" tok/s")) + } + return rowLed(fields, rowUnbounded) +} + // roomWork is what the page's own rows say about the work: how many calls have // CLOSED, which one is in flight, and when anything last moved. // diff --git a/internal/tui3/roombeat_test.go b/internal/tui3/roombeat_test.go new file mode 100644 index 0000000000..d75cfaa5c5 --- /dev/null +++ b/internal/tui3/roombeat_test.go @@ -0,0 +1,126 @@ +package tui3 + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/aforge-v2/internal/session" +) + +// THE PULSE'S ONE READING, DRAWN AND WITHDRAWN (issue #837). +// +// A node's beat sidecar is the only witness that knows a model call went out +// and has not come back: the page's own rows cannot say it, because a request +// still streaming has written no entry yet. These tests feed the open page a +// beat row each way round and read the header the same draw reads it — the +// live segment is the chat foot's own sentence, and the call's clock stands in +// the task's own clock's place only while the call is open. + +// beatRoom is a running node's page with the pulse held the way a reading +// leaves it: the row on the beat side, and the reading flag that says it was +// read rather than merely empty. +// +// THE CALL'S AGE IS GIVEN AND THE INSTANTS ARE DERIVED FROM THE APP'S OWN +// CLOCK, never from [time.Now]. This surface is driven by a fixture clock that +// does not sit near the wall clock, so a row built from the real one puts the +// request's start in the app's future, the elapsed comes out negative, and +// [countUpWord] answers "" — which reads on a failure exactly like the header +// refusing to draw. The first draft of this file did that and reported the +// feature broken while it worked. +func beatRoom(t *testing.T, open time.Duration) *app { + t.Helper() + a := headRoom(t) + a.room.beat, a.room.beatRead = anOpenCall(a.now().Add(-open)), true + a.room.dirty = true + return a +} + +// anOpenCall is a beat row whose request has gone out and not come back: +// a finish older than the start is the sidecar's whole meaning of "in a call". +func anOpenCall(started time.Time) session.TaskBeatRow { + return session.TaskBeatRow{ + Started: started.Add(-time.Hour), + RequestStarted: started, + RequestFinished: started.Add(-time.Minute), + } +} + +// TestTheHeaderDrawsTheOpenCallWhileThePulseSaysOneIsInFlight — request_started +// newer than request_finished is the sidecar's whole meaning of "inside a +// call", and while it holds the header's live segment is THE CALL's clock: the +// call's own elapsed, spelled the way the chat foot spells it, in place of the +// task's own age and its `still working`. +func TestTheHeaderDrawsTheOpenCallWhileThePulseSaysOneIsInFlight(t *testing.T) { + a := beatRoom(t, 11*time.Minute) + line := plain(a.roomFactsLine(160)) + for _, want := range []string{"11m", "working"} { + if !strings.Contains(line, want) { + t.Fatalf("the header does not say %q while a call is open: %q", want, line) + } + } + // AND NOTHING OF THE TASK'S OWN CLOCK LEAKS IN BESIDE IT. The whole defect + // was a header saying only `working · 10m 21s` — the task's age — about a + // call that was ten minutes in. The open call's own figure is what replaced + // it, and a header carrying both is the same page saying two clocks about + // one silence. + if strings.Contains(line, "10m") { + t.Fatalf("the header spelled the task's own age beside the call's clock: %q", line) + } + // AND THE VAGUEST SENTENCE IS GONE. `still working` is the ladder's answer + // when nothing better is known; a call's own clock is better and wins. + if strings.Contains(line, stillWorkingWord) { + t.Fatalf("the header said `still working` over a call it could time: %q", line) + } +} + +// TestTheCallSegmentSpellsItselfExactlyAsTheChatFootDoes — no new words. The +// rate is the foot's own `N tok/s`, built from the same desk the foot reads, and +// the clock is the live count-up every moving figure on this surface uses. A +// header that invented a second spelling would be two answers to "how fast". +func TestTheCallSegmentSpellsItselfExactlyAsTheChatFootDoes(t *testing.T) { + a := beatRoom(t, 95*time.Second) + if got := a.roomOpenCallWord(a.roomNode()); !strings.Contains(got, "1m 35s") { + t.Fatalf("the open call's clock is not the live count-up: %q", got) + } + // THE RATE RIDES ONLY WHILE IT IS BEING MEASURED, which is the foot's own + // law: a rate nobody is producing is nothing, never `0 tok/s`. + if got := a.roomOpenCallWord(a.roomNode()); strings.Contains(got, "0 tok/s") { + t.Fatalf("an unmeasured rate became a figure: %q", got) + } +} + +// TestTheHeaderGoesBackToTheTaskClockWhenTheCallComesBack — the reverse order, +// request_finished newer, is the sidecar saying no call is open, and the live +// segment reverts to the task's own elapsed time and its ordinary ladder. +func TestTheHeaderGoesBackToTheTaskClockWhenTheCallComesBack(t *testing.T) { + a := beatRoom(t, time.Minute) + // The one field that turns an open call into an answered one, and the times + // stay on the app's own clock for the reason [beatRoom] states. + a.room.beat.RequestFinished = a.room.beat.RequestStarted.Add(time.Second) + line := plain(a.roomFactsLine(160)) + if strings.Contains(line, "11m") || strings.Contains(line, "10m") { + t.Fatalf("a closed call's clock stayed on the header: %q", line) + } + if !strings.Contains(line, "3m") { + t.Fatalf("the header did not go back to the task's own clock: %q", line) + } +} + +// TestAPageWithNoPulseDrawsNoCall — an unread beat is the honest unknown, and +// unknown renders as nothing: the emptiness law, not a failure to draw. +func TestAPageWithNoPulseDrawsNoCall(t *testing.T) { + a := headRoom(t) + a.room.beatRead = false + a.room.dirty = true + line := plain(a.roomFactsLine(160)) + if strings.Contains(line, "still working") && strings.Contains(line, "bash") && + strings.Contains(line, "0 tok/s") { + t.Fatalf("an unknown pulse drew a call: %q", line) + } + if ansi.StringWidth(line) != 160 { + t.Fatalf("the header changed its row budget: %q", line) + } +} diff --git a/internal/tui3/roomrefresh.go b/internal/tui3/roomrefresh.go index fd06d6209d..9a7dde9d6d 100644 --- a/internal/tui3/roomrefresh.go +++ b/internal/tui3/roomrefresh.go @@ -27,8 +27,15 @@ func (r *taskRoom) keepSteerEcho(words string, e entry) { r.pendingSteers = append(r.pendingSteers, roomSteerEcho{words, r.lastSteerAt, n, e}) } -func (a *app) refreshRoomRecord(journal []byte) { +func (a *app) refreshRoomRecord(journal []byte, beatPath string) { r := a.room + // THE PULSE IS READ FIRST AND ALWAYS, because a call can open and close + // inside one refresh without the transcript growing a single byte — a + // request in flight journals nothing until it lands, which is the whole + // defect #837 names. The early return below used to sit above this line, + // so the one reading that changed said nothing changed and the live + // segment never drew. + r.beat, r.beatRead = session.ReadTaskBeat(beatPath) if bytes.Equal(r.journal, journal) { return } @@ -83,6 +90,11 @@ func (a *app) refreshRoomRecord(journal []byte) { r.entries, r.turn = entries, turn r.journal = bytes.Clone(journal) r.takeRequests(record.Requests) + // THE PULSE IS CARRIED, AND NEVER OPENED HERE: the reading above has it, + // and a second open of the same file would be the same fact paid for twice + // on one tick. The path is the record's own name for the sidecar, kept for + // the tick that follows this one. + r.beatPath = beatPath } // takeRequests feeds a page with no lane its live token column, from the one