Skip to content

Commit d84479d

Browse files
author
gluck
committed
status: an aria remembers how its last turn ended
`figaro status 94f0752b` said: state: idle and that was the whole answer, while three prompts sat in the log with nothing after them and the last turn had died on a provider error. Nothing was lying. "idle" is a statement about the INBOX; it says nothing about whether the last thing the user asked for worked, and reporting only the first is exactly how a failed turn gets reported as a hang. The outcome was already computed - finishTurn(reason) has always known, and fans it out on turn.done - and then dropped on the floor, visible only to whoever happened to be attached at that instant. Retaining one string and one timestamp turns "it is not responding" into "its last turn failed nine minutes ago, and here is what the provider said": figaro 94f0752b state: idle last-turn: error: anthropic: rate_limit_error (429): the provider asked us to wait 1h0m0s ... (9m0s ago) unanswered: 3 prompt(s) with no reply unanswered counts input messages at the tail with no assistant message after them: work taken and nothing produced. It is distinct from a queue backlog - these are already committed to the log, which is why `queue ls` came back empty while three questions went unanswered. The scan is bounded; only the tail can be unanswered. A turn that simply completed earns no row. A status panel that reports every success teaches people to skim past the one line that mattered. Also: `figaro listen` now surfaces an error reason through lt.report rather than writing it straight to stderr, which is what `send` has done since the pager landed. Straight-to-stderr lands ON the footer while the pager owns the terminal and scrolls the grid out from under the painter, so a turn that failed while you were tailing it could leave no visible trace at all. report picks the right door and puts it in the status row's red notice.
1 parent 0ab7ae2 commit d84479d

8 files changed

Lines changed: 284 additions & 18 deletions

File tree

‎internal/angelus/protocol.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1482,6 +1482,11 @@ func (h *handlers) list(ctx context.Context, params json.RawMessage) (interface{
14821482
Mantra: info.Mantra,
14831483
Cwd: info.Cwd,
14841484
BoundPIDs: boundPIDs[info.ID],
1485+
LastTurnReason: info.LastTurnReason,
1486+
UnansweredInputs: info.UnansweredInputs,
1487+
}
1488+
if !info.LastTurnAt.IsZero() {
1489+
entry.LastTurnAt = info.LastTurnAt.UnixMilli()
14851490
}
14861491
result = append(result, entry)
14871492
}

‎internal/cli/listen.go‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,15 @@ func tailFigaro(ctx context.Context, cancel context.CancelFunc, ep transport.End
160160
_ = json.Unmarshal(params, &d)
161161
lt.finishTurn(d.Reason)
162162
if strings.HasPrefix(d.Reason, "error:") {
163-
sessionLine(os.Stderr, "\r\n"+d.Reason)
163+
// Through lt.report, not straight to stderr: report picks
164+
// the right door for whichever renderer owns the terminal,
165+
// so the reason lands in the status row's red notice while
166+
// the pager is up instead of being written over the footer
167+
// (and scrolling the grid out from under the painter). send
168+
// has done it this way since the pager landed; listen was
169+
// left behind, which meant a turn that failed while you were
170+
// tailing it could leave no visible trace at all.
171+
lt.report(d.Reason)
164172
}
165173
}
166174
}

‎internal/cli/status.go‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,20 @@ func printStatusPanel(out *os.File, f *rpc.FigaroInfoResponse, more bool, forkTu
290290

291291
fmt.Fprintf(w, "figaro\t%s\n", f.ID)
292292
row("state", dash(f.State))
293+
// STATE IS NOT AN OUTCOME. "idle" says the inbox is empty; it says
294+
// nothing about whether the last thing the user asked for worked. An
295+
// aria whose last turn died on a provider error reported "idle" and
296+
// nothing else, and that gap is precisely how a failed turn gets
297+
// reported as a hang. When the last turn failed, say so on the line
298+
// under state, where it cannot be missed.
299+
if reason := lastTurnRow(f); reason != "" {
300+
row("last-turn", reason)
301+
}
302+
if f.UnansweredInputs > 0 {
303+
// Work taken and nothing produced. Distinct from a queue backlog:
304+
// these are already committed to the log.
305+
row("unanswered", fmt.Sprintf("%d prompt(s) with no reply", f.UnansweredInputs))
306+
}
293307
row("mantra", dash(f.Mantra))
294308
row("provider", dash(f.Provider))
295309
row("model", dash(f.Model))
@@ -364,3 +378,23 @@ func truncateDuration(d time.Duration) string {
364378
return d.Round(time.Minute).String()
365379
}
366380
}
381+
382+
// lastTurnRow renders how the last turn ended, or "" when there is nothing
383+
// worth saying. A turn that simply completed needs no row: state already
384+
// covers the healthy case, and a status panel that reports every success
385+
// teaches people to skim past the one line that mattered.
386+
func lastTurnRow(f *rpc.FigaroInfoResponse) string {
387+
reason := strings.TrimSpace(f.LastTurnReason)
388+
if reason == "" {
389+
return ""
390+
}
391+
low := strings.ToLower(reason)
392+
failed := strings.HasPrefix(low, "error:")
393+
if !failed && !strings.Contains(low, "interrupt") {
394+
return ""
395+
}
396+
if f.LastTurnAt != 0 {
397+
return fmt.Sprintf("%s (%s ago)", reason, truncateDuration(time.Since(time.UnixMilli(f.LastTurnAt))))
398+
}
399+
return reason
400+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package cli
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"time"
7+
8+
"github.com/jack-work/figaro/internal/rpc"
9+
)
10+
11+
// "idle" is a statement about the inbox. It says nothing about whether the
12+
// last thing you asked for worked, and reporting only the first is exactly
13+
// how a failed turn gets reported as a hang: three prompts in the log,
14+
// nothing produced, and every surface saying idle.
15+
func TestStatusReportsAFailedLastTurn(t *testing.T) {
16+
f := &rpc.FigaroInfoResponse{
17+
ID: "94f0752b",
18+
State: "idle",
19+
LastTurnReason: "error: anthropic: rate_limit_error (429): the provider asked us to wait 1h0m0s",
20+
LastTurnAt: time.Now().Add(-9 * time.Minute).UnixMilli(),
21+
}
22+
got := lastTurnRow(f)
23+
if !strings.Contains(got, "rate_limit_error") {
24+
t.Fatalf("the reason must survive to the panel: %q", got)
25+
}
26+
if !strings.Contains(got, "ago") {
27+
t.Errorf("when it failed matters as much as that it failed: %q", got)
28+
}
29+
}
30+
31+
// A status panel that reports every success teaches people to skim past the
32+
// one line that mattered.
33+
func TestStatusSaysNothingAboutAHealthyTurn(t *testing.T) {
34+
for _, reason := range []string{"", "end_turn", "stop_sequence", "max_tokens"} {
35+
f := &rpc.FigaroInfoResponse{State: "idle", LastTurnReason: reason}
36+
if got := lastTurnRow(f); got != "" {
37+
t.Errorf("reason %q produced a row %q; only trouble earns one", reason, got)
38+
}
39+
}
40+
}
41+
42+
// An interrupt is not a failure but it is not a completion either, and a user
43+
// coming back to a conversation deserves to know which one happened.
44+
func TestStatusReportsAnInterruptedTurn(t *testing.T) {
45+
f := &rpc.FigaroInfoResponse{State: "idle", LastTurnReason: "interrupted"}
46+
if got := lastTurnRow(f); !strings.Contains(got, "interrupted") {
47+
t.Errorf("interrupted turn not reported: %q", got)
48+
}
49+
}
50+
51+
func TestStatusRowSurvivesAMissingTimestamp(t *testing.T) {
52+
f := &rpc.FigaroInfoResponse{State: "idle", LastTurnReason: "error: boom"}
53+
if got := lastTurnRow(f); got != "error: boom" {
54+
t.Errorf("got %q", got)
55+
}
56+
}

‎internal/figaro/agent.go‎

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -203,23 +203,28 @@ type Agent struct {
203203
// is the single source of both figaro.aria pushes and figaro.read pulls.
204204
ariaSrv *aria.Server
205205

206-
createdAt time.Time
207-
lastActive time.Time
208-
tokensIn int
209-
tokensOut int
210-
cacheRead int
211-
cacheWrite int
212-
messageCount int
213-
turnCount int
214-
metricsLT uint64
215-
contextTokens int
216-
contextLimit int
217-
contextExact bool
218-
model string
219-
mantra string
220-
cwd string
221-
outfitName string
222-
outfitVer string
206+
createdAt time.Time
207+
lastActive time.Time
208+
// lastTurnReason is how the most recent turn ENDED - a stop reason, or
209+
// "interrupted", or "error: ...". State says whether a turn is running;
210+
// this says whether the last one worked.
211+
lastTurnReason string
212+
lastTurnAt time.Time
213+
tokensIn int
214+
tokensOut int
215+
cacheRead int
216+
cacheWrite int
217+
messageCount int
218+
turnCount int
219+
metricsLT uint64
220+
contextTokens int
221+
contextLimit int
222+
contextExact bool
223+
model string
224+
mantra string
225+
cwd string
226+
outfitName string
227+
outfitVer string
223228

224229
cancel context.CancelFunc
225230
done chan struct{}
@@ -780,8 +785,11 @@ func (a *Agent) Info() FigaroInfo {
780785
OutfitName: a.outfitName,
781786
OutfitVersion: a.outfitVer,
782787
LastFigaroLT: a.metricsLT,
788+
LastTurnReason: a.lastTurnReason,
789+
LastTurnAt: a.lastTurnAt,
783790
}
784791
a.mu.RUnlock()
792+
info.UnansweredInputs = a.unansweredInputs()
785793
return info
786794
}
787795

@@ -1233,6 +1241,15 @@ func (a *Agent) finishTurn(reason string) {
12331241
idle := a.inbox.IsIdle()
12341242
a.mu.Lock()
12351243
a.lastActive = time.Now()
1244+
// REMEMBER HOW THE TURN ENDED. The reason has always been computed here
1245+
// and fanned out to whoever happened to be attached at that instant, then
1246+
// dropped. So an aria whose last turn died on a provider error reported
1247+
// "idle" forever after, which is true and useless: idle is a statement
1248+
// about the inbox, not about whether the last thing you asked for
1249+
// worked. Retaining one string is the difference between "it is not
1250+
// responding" and "its last turn failed nine minutes ago, here is why".
1251+
a.lastTurnReason = reason
1252+
a.lastTurnAt = a.lastActive
12361253
a.mu.Unlock()
12371254
a.fanOut(rpc.Notification{
12381255
JSONRPC: "2.0",
@@ -1342,3 +1359,39 @@ func (a *Agent) DeleteQueued(epoch string, ids []uint64, all bool) (string, []rp
13421359
func (a *Agent) UpdateQueued(epoch string, id uint64, text string) (string, rpc.QueueResult) {
13431360
return a.inbox.UpdatePrompt(epoch, id, text)
13441361
}
1362+
1363+
// unansweredInputs counts input messages at the tail of the log with no
1364+
// assistant message after them.
1365+
//
1366+
// It exists because of a state figaro had no word for: three prompts sitting
1367+
// in the log, consumed off the queue (so `queue ls` is empty) and answered by
1368+
// nothing (so the conversation ends in user messages), while `status` reported
1369+
// "idle". Every surface was individually truthful and the aggregate was a lie.
1370+
// A nonzero count here means the aria took work and produced nothing - which
1371+
// is the definition of the failure people report as "it hangs".
1372+
//
1373+
// Bounded read: only the tail can be unanswered, and a healthy aria answers
1374+
// within one message, so a short page is enough. A pathological run of more
1375+
// than unansweredScanDepth inputs reports the depth, which is already alarming
1376+
// enough to act on.
1377+
func (a *Agent) unansweredInputs() int {
1378+
if a.figLog == nil {
1379+
return 0
1380+
}
1381+
page, _ := a.figLog.ReadPage(0, ^uint64(0), unansweredScanDepth)
1382+
if len(page) == 0 {
1383+
return 0
1384+
}
1385+
n := 0
1386+
// The page is ascending, so walk it backwards from the tail.
1387+
for i := len(page) - 1; i >= 0; i-- {
1388+
if page[i].Payload.Role != message.RoleInput {
1389+
return n
1390+
}
1391+
n++
1392+
}
1393+
return n
1394+
}
1395+
1396+
// unansweredScanDepth bounds the tail scan. Past this the answer is "many".
1397+
const unansweredScanDepth = 32

‎internal/figaro/figaro.go‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
package figaro
88

99
import (
10+
"strings"
1011
"time"
1112
)
1213

@@ -47,4 +48,23 @@ type FigaroInfo struct {
4748
OutfitName string `json:"outfit_name"`
4849
OutfitVersion string `json:"outfit_version"`
4950
LastFigaroLT uint64 `json:"last_figaro_lt"`
51+
52+
// LastTurnReason is how the most recent turn ENDED: a stop reason,
53+
// "interrupted", or "error: ...". State reports whether a turn is running
54+
// now; this reports whether the last one worked, and the two answer
55+
// different questions. An aria whose last turn died on a provider error
56+
// is idle, and saying only "idle" is how a failure reads as a hang.
57+
LastTurnReason string `json:"last_turn_reason,omitempty"`
58+
LastTurnAt time.Time `json:"last_turn_at,omitempty"`
59+
// UnansweredInputs counts prompts at the tail of the log that no
60+
// assistant message followed. Nonzero means the aria took work and
61+
// produced nothing.
62+
UnansweredInputs int `json:"unanswered_inputs,omitempty"`
63+
}
64+
65+
// LastTurnFailed reports whether the most recent turn ended in an error. The
66+
// reason string is the wire format the turn loop already produced; this is the
67+
// one place that knows its shape.
68+
func (i FigaroInfo) LastTurnFailed() bool {
69+
return strings.HasPrefix(i.LastTurnReason, "error:")
5070
}

‎internal/figaro/last_turn_test.go‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package figaro
2+
3+
import (
4+
"testing"
5+
6+
"github.com/jack-work/figaro/internal/message"
7+
"github.com/jack-work/figaro/internal/store"
8+
)
9+
10+
// The state figaro had no word for: prompts consumed off the queue (so
11+
// `queue ls` is empty) and answered by nothing (so the log ends in user
12+
// messages), while status reported idle. Every surface individually truthful,
13+
// the aggregate a lie.
14+
func TestUnansweredInputsCountsTheTail(t *testing.T) {
15+
a := &Agent{figLog: store.NewMemLog[message.Message]()}
16+
appendRole := func(role message.Role) {
17+
if _, err := a.figLog.Append(store.Entry[message.Message]{Payload: message.Message{Role: role}}); err != nil {
18+
t.Fatal(err)
19+
}
20+
}
21+
22+
if got := a.unansweredInputs(); got != 0 {
23+
t.Errorf("empty log: got %d, want 0", got)
24+
}
25+
26+
appendRole(message.RoleInput)
27+
appendRole(message.RoleOutput)
28+
if got := a.unansweredInputs(); got != 0 {
29+
t.Errorf("an answered prompt is not unanswered: got %d", got)
30+
}
31+
32+
appendRole(message.RoleInput)
33+
appendRole(message.RoleInput)
34+
appendRole(message.RoleInput)
35+
if got := a.unansweredInputs(); got != 3 {
36+
t.Errorf("got %d, want 3 - this is the number that says work was taken and nothing produced", got)
37+
}
38+
39+
// An assistant reply clears it.
40+
appendRole(message.RoleOutput)
41+
if got := a.unansweredInputs(); got != 0 {
42+
t.Errorf("got %d, want 0 after a reply", got)
43+
}
44+
}
45+
46+
// The scan is bounded: only the tail can be unanswered, and an aria with more
47+
// than the scan depth of unanswered prompts is already alarming enough to act
48+
// on without an unbounded read.
49+
func TestUnansweredInputsIsBounded(t *testing.T) {
50+
a := &Agent{figLog: store.NewMemLog[message.Message]()}
51+
for i := 0; i < unansweredScanDepth*2; i++ {
52+
if _, err := a.figLog.Append(store.Entry[message.Message]{Payload: message.Message{Role: message.RoleInput}}); err != nil {
53+
t.Fatal(err)
54+
}
55+
}
56+
if got := a.unansweredInputs(); got != unansweredScanDepth {
57+
t.Errorf("got %d, want the scan depth %d", got, unansweredScanDepth)
58+
}
59+
}
60+
61+
func TestUnansweredInputsToleratesNoLog(t *testing.T) {
62+
a := &Agent{}
63+
if got := a.unansweredInputs(); got != 0 {
64+
t.Errorf("got %d, want 0", got)
65+
}
66+
}
67+
68+
func TestLastTurnFailedReadsTheReason(t *testing.T) {
69+
for reason, want := range map[string]bool{
70+
"": false,
71+
"end_turn": false,
72+
"interrupted": false,
73+
"error: boom": true,
74+
"error: anthropic 429": true,
75+
} {
76+
if got := (FigaroInfo{LastTurnReason: reason}).LastTurnFailed(); got != want {
77+
t.Errorf("LastTurnFailed(%q) = %v, want %v", reason, got, want)
78+
}
79+
}
80+
}

‎internal/rpc/methods.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,16 @@ type FigaroInfoResponse struct {
495495
OutfitVer string `json:"outfit_ver,omitempty"` // "live" if the stamped hash matches the current outfit, else its short hash
496496
BoundPIDs []int `json:"bound_pids"`
497497

498+
// LastTurnReason is how the most recent turn ENDED: a stop reason,
499+
// "interrupted", or "error: ...". State answers "is a turn running"; this
500+
// answers "did the last one work", and reporting only the first is how a
501+
// failed turn reads as a hang. LastTurnAt is when it ended, unix millis.
502+
LastTurnReason string `json:"last_turn_reason,omitempty"`
503+
LastTurnAt int64 `json:"last_turn_at,omitempty"`
504+
// UnansweredInputs counts prompts at the tail of the log that no
505+
// assistant message followed: work taken and nothing produced.
506+
UnansweredInputs int `json:"unanswered_inputs,omitempty"`
507+
498508
// Fork-forest position (conversation nodes). Vector is the
499509
// child-index path (0, 0.0, 0.1, …); Trunk is the thread id that
500510
// flows down the continuation line; Parent is the aria branched from.

0 commit comments

Comments
 (0)