From b728a14d94097abf87372cadd3dfddefa938fe6e Mon Sep 17 00:00:00 2001 From: enowdev Date: Fri, 6 Nov 2026 11:48:15 +0700 Subject: [PATCH 1/4] Ask for a terminal's size again when its view comes back in sight Another view of the same session (a GOA tab, the Canvas page's agent panel) can resize the session; the terminal asks for its size only when its own changes, so going back to the first view left the session at the other's size, drawn in a corner, until the window was resized (#44). Co-Authored-By: Claude Opus 5.5 --- ui/app.go | 17 +++++++++++++++-- ui/canvaschat.go | 1 + ui/canvaschat_region.go | 1 + ui/goa_tabs.go | 13 ++++++++++--- ui/settledresize.go | 31 +++++++++++++++++++++++++++++++ ui/settledresize_test.go | 36 ++++++++++++++++++++++++++++++++++++ ui/workspace.go | 1 + 7 files changed, 95 insertions(+), 5 deletions(-) diff --git a/ui/app.go b/ui/app.go index a70d1d71..6d97d83e 100644 --- a/ui/app.go +++ b/ui/app.go @@ -355,6 +355,14 @@ const termPadX, termPadY = 10, 8 type pane struct { term *terminal.Terminal stream *client.Stream + size *settledResize // what it asks of the session's size +} + +// shown says a view draws p now: back in sight, it asks for its size again. +func (p *pane) shown() { + if p.size != nil { + p.size.Shown(time.Now()) + } } // LocalHost is this machine as a host. @@ -788,11 +796,15 @@ func (a *App) attach(c *client.Client, gen int, id string) { // exists; resizes wait until it is assigned. ready := make(chan struct{}) var term *terminal.Terminal + var sized *settledResize st.OnResize = func(cols, rows int) { <-ready if term != nil { term.Resize(cols, rows) } + if sized != nil { + sized.Heard(cols, rows) + } } a.onMain(func() { defer close(ready) @@ -817,7 +829,8 @@ func (a *App) attach(c *client.Client, gen int, id string) { }, } } - conn = &settledResize{ReadWriteCloser: conn, resize: st.ResizePixels} + sized = &settledResize{ReadWriteCloser: conn, resize: st.ResizePixels} + conn = sized t, err := terminal.New(terminal.Options{ Transparent: true, // the view's own ground shows, as the slider has it Conn: conn, @@ -850,7 +863,7 @@ func (a *App) attach(c *client.Client, gen int, id string) { return } term = t - a.panes[id] = &pane{term: t, stream: st} + a.panes[id] = &pane{term: t, stream: st, size: sized} }) } diff --git a/ui/canvaschat.go b/ui/canvaschat.go index b6cd70c6..b35046db 100644 --- a/ui/canvaschat.go +++ b/ui/canvaschat.go @@ -149,6 +149,7 @@ func (a *App) canvasAssistant(c *ui.Context, cv proto.Canvas) { }) }) case p != nil: + p.shown() terminal.View(c.Key("term:"+s.ID), p.term).Grow(1).FillWidth().Background(theme.TermGround) default: ui.Text(c, "Connecting…").FontSize(theme.TextUI).TextColor(theme.TextSoft).Padding(theme.S4) diff --git a/ui/canvaschat_region.go b/ui/canvaschat_region.go index 1a8f14d3..5081081a 100644 --- a/ui/canvaschat_region.go +++ b/ui/canvaschat_region.go @@ -55,6 +55,7 @@ func (a *App) canvasRegionView(c *ui.Context, cv proto.Canvas, hc *hostCanvasCha a.chatViewSending(c.Key("chat:"+s.ID), s, shown, chatTweaks{showUser: canvasRegionPrompt}) }) case p != nil: + p.shown() terminal.View(c.Key("term:"+s.ID), p.term).Grow(1).FillWidth().Background(theme.TermGround) default: ui.Text(c, "Connecting…").FontSize(theme.TextUI).TextColor(theme.TextSoft).Padding(theme.S4) diff --git a/ui/goa_tabs.go b/ui/goa_tabs.go index 08e21de2..88e512eb 100644 --- a/ui/goa_tabs.go +++ b/ui/goa_tabs.go @@ -3,6 +3,7 @@ package ui import ( "slices" "strings" + "time" "github.com/egoist/mygo/plugins/terminal" "github.com/egoist/mygo/ui" @@ -26,6 +27,7 @@ type goaTab struct { cl *client.Client term *terminal.Terminal stream *client.Stream + size *settledResize err string closed bool // seen is the session as the view was last attached or looked at, and attaching @@ -113,9 +115,10 @@ func (a *App) goaTabFollow(t *goaTab) { } func (a *App) attachGOATab(t *goaTab, st *client.Stream) { + sized := &settledResize{ReadWriteCloser: st, resize: st.ResizePixels} term, err := terminal.New(terminal.Options{ Transparent: true, // the view's own ground shows, as the slider has it - Conn: &settledResize{ReadWriteCloser: st, resize: st.ResizePixels}, + Conn: sized, ResizeByConn: true, Theme: theme.Terminal(), DarkTheme: theme.Terminal(), @@ -128,8 +131,11 @@ func (a *App) attachGOATab(t *goaTab, st *client.Stream) { t.err = err.Error() return } - st.OnResize = func(cols, rows int) { a.onMain(func() { term.Resize(cols, rows) }) } - t.term, t.stream = term, st + st.OnResize = func(cols, rows int) { + sized.Heard(cols, rows) + a.onMain(func() { term.Resize(cols, rows) }) + } + t.term, t.stream, t.size = term, st, sized } // closeGOATab closes the view alone: the session runs on. @@ -268,6 +274,7 @@ func (a *App) goaTabView(c *ui.Context, key string) { ui.Text(c, "Connecting…").TextColor(theme.TextSoft) }) default: + t.size.Shown(time.Now()) terminal.View(c.Key("goa-term:"+key), t.term).Grow(1).FillWidth().Background(theme.TermGround) } }) diff --git a/ui/settledresize.go b/ui/settledresize.go index 02f701d5..f0ecbdcb 100644 --- a/ui/settledresize.go +++ b/ui/settledresize.go @@ -18,6 +18,37 @@ type settledResize struct { mu sync.Mutex timer *time.Timer size [4]int + // heard is the size the session last said it has, and shown when a view last drew it. + heard [2]int + shown time.Time +} + +// showGap is how long a view goes undrawn before its drawing counts as coming back. +const showGap = time.Second + +// Heard keeps the size the session says it now has, which another view of it may have set. +func (s *settledResize) Heard(cols, rows int) { + s.mu.Lock() + s.heard = [2]int{cols, rows} + s.mu.Unlock() +} + +// Shown says a view drew the terminal at now. Back in sight with the session at another +// view's size, it asks for its own again: its own size didn't change, so the terminal, +// which asks only on a change, wouldn't. +func (s *settledResize) Shown(now time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + back := !s.shown.IsZero() && now.Sub(s.shown) > showGap + s.shown = now + if !back || s.size[0] == 0 || s.heard == [2]int{s.size[0], s.size[1]} { + return + } + if s.timer == nil { + s.timer = time.AfterFunc(resizeSettle, s.send) + } else { + s.timer.Reset(resizeSettle) + } } // resizeSettle is how long a size must hold to be passed on. diff --git a/ui/settledresize_test.go b/ui/settledresize_test.go index 61b93c00..84da5088 100644 --- a/ui/settledresize_test.go +++ b/ui/settledresize_test.go @@ -35,3 +35,39 @@ func TestTerminalSizeIsPassedOnOnceSettled(t *testing.T) { t.Fatalf("resized %v", got) } } + +// A view back in sight asks for its size again when another view of the session has +// changed it meanwhile, and only then: the terminal itself asks only when its own size +// changes, so the session stayed at the other view's size (issue #44). +func TestViewBackInSightAsksItsSizeAgain(t *testing.T) { + sent := make(chan [2]int, 4) + s := &settledResize{resize: func(cols, rows, _, _ int) error { sent <- [2]int{cols, rows}; return nil }} + start := time.Now() + _ = s.ResizePixels(120, 40, 8, 16) + s.Shown(start) + if got := <-sent; got != [2]int{120, 40} { + t.Fatalf("first ask: %v", got) + } + s.Heard(120, 40) + + // Drawn on: nothing more. + s.Shown(start.Add(500 * time.Millisecond)) + // Out of sight a while, the session still at this size: nothing either. + s.Shown(start.Add(5 * time.Second)) + // Another view made it small, and this one comes back. + s.Heard(40, 12) + s.Shown(start.Add(10 * time.Second)) + select { + case got := <-sent: + if got != [2]int{120, 40} { + t.Fatalf("asked %v", got) + } + case <-time.After(time.Second): + t.Fatal("back in sight, it didn't ask for its size") + } + select { + case got := <-sent: + t.Fatalf("asked again: %v", got) + case <-time.After(300 * time.Millisecond): + } +} diff --git a/ui/workspace.go b/ui/workspace.go index 99298f17..db5d2516 100644 --- a/ui/workspace.go +++ b/ui/workspace.go @@ -356,6 +356,7 @@ func (a *App) sessionBody(c *ui.Context, s proto.SessionInfo) { case a.showsChat(s.ID): a.chatView(c.Key("chat"), s) case p != nil: + p.shown() v := terminal.View(c.Key("term"), p.term).Grow(1).FillWidth().Background(theme.TermGround).Padding(theme.S2, theme.S4) if text, ok := widget.TakeTextDrag(v); ok { p.term.Paste(text + " ") From 4f161c2025cb59f0e3a1988d3b742971d3a200aa Mon Sep 17 00:00:00 2001 From: enowdev Date: Fri, 6 Nov 2026 11:56:55 +0700 Subject: [PATCH 2/4] Keep an idle session's stale task from holding its queue A task GOA set back to working, or whose turn's end went unheard on an agent without hooks, stayed open on an idle session: every next task was queued behind it and never went in (#45). An idle session past the idle limit now has that one task marked turn-ended, GOA told, and the new task goes in. A queue names the task holding it and how to settle it, and goa task takes only done, failed and stopped. Co-Authored-By: Claude Opus 5.5 --- cli/goa.go | 9 +++- cli/goa_mcp.go | 4 +- cli/goa_task_status_test.go | 16 +++++++ daemon/goa_stale.go | 62 +++++++++++++++++++++++++ daemon/goa_stale_test.go | 92 +++++++++++++++++++++++++++++++++++++ daemon/goa_tasks.go | 7 ++- docs/cli.md | 2 +- docs/goa.md | 4 ++ 8 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 cli/goa_task_status_test.go create mode 100644 daemon/goa_stale.go create mode 100644 daemon/goa_stale_test.go diff --git a/cli/goa.go b/cli/goa.go index 9004f7ce..7e153934 100644 --- a/cli/goa.go +++ b/cli/goa.go @@ -35,7 +35,7 @@ const goaUsage = `usage: enowx-app goa COMMAND --title T the task's title on the board; --yolo skips the worker's permission prompts --doc NOTE[#SECTION] a Project Docs document: refused while its project's gate is closed, its words prepended to the prompt - task ID [--status working|waiting|done|failed|stopped] [--result TEXT] + task ID [--status done|failed|stopped] [--result TEXT] settle a task, or say where it stands report --outcome done|partly|nothing|blocked|refused|failed TEXT a session's report of its turn, whatever came of it @@ -284,6 +284,13 @@ func (r runner) goaTask(args []string) error { return errors.New("usage: enowx-app goa task ID [--status S] [--result TEXT]") } p.ID = words[0] + switch p.Status { + case "", proto.GOATaskDone, proto.GOATaskFailed, proto.GOATaskStopped: + default: + // Working and waiting are what its session says: set by hand, a task stayed open + // on an idle session and held its queue (issue #45). + return fmt.Errorf("goa task: a task is settled done, failed or stopped; %q is what its session says, not set by hand", p.Status) + } var t proto.GOATask if err := r.c.Call(proto.MethodGOATask, p, &t); err != nil { return err diff --git a/cli/goa_mcp.go b/cli/goa_mcp.go index f2d2c894..f2629b24 100644 --- a/cli/goa_mcp.go +++ b/cli/goa_mcp.go @@ -37,8 +37,8 @@ var goaTools = []mcpTool{ } return append(withFlag(args, a, "no_route", "no-route"), "--", str(a, "prompt")) }}, - {name: "goa_task", description: "Settle a task on the board, or say where it stands: its status (working, waiting, done, failed, stopped) and result.", - props: map[string]mcpProp{"id": {"string", "The task's ID"}, "status": {"string", "working, waiting, done, failed or stopped"}, "result": {"string", "What came of it, in a line or two"}}, + {name: "goa_task", description: "Settle a task on the board: done, failed or stopped, with its result. Working and waiting are what its session says, not set by hand.", + props: map[string]mcpProp{"id": {"string", "The task's ID"}, "status": {"string", "done, failed or stopped"}, "result": {"string", "What came of it, in a line or two"}}, required: []string{"id"}, args: func(a map[string]any) []string { return withFlag(withFlag([]string{"task", str(a, "id")}, a, "status", "status"), a, "result", "result") diff --git a/cli/goa_task_status_test.go b/cli/goa_task_status_test.go new file mode 100644 index 00000000..603f608c --- /dev/null +++ b/cli/goa_task_status_test.go @@ -0,0 +1,16 @@ +package cli + +import ( + "strings" + "testing" +) + +// GOA settles a task; it doesn't set one at work by hand (issue #45). +func TestGOATaskTakesOnlySettlingStatuses(t *testing.T) { + for _, status := range []string{"working", "waiting", "queued", "turn-ended"} { + err := runner{}.goaTask([]string{"t1", "--status", status}) + if err == nil || !strings.Contains(err.Error(), "done, failed or stopped") { + t.Errorf("%s: %v", status, err) + } + } +} diff --git a/daemon/goa_stale.go b/daemon/goa_stale.go new file mode 100644 index 00000000..eee4aacd --- /dev/null +++ b/daemon/goa_stale.go @@ -0,0 +1,62 @@ +package daemon + +import ( + "fmt" + "time" + + "github.com/enowx/enowx/core/agent" + "github.com/enowx/enowx/core/goa" + "github.com/enowx/enowx/proto" +) + +// goaStaleWork is the task that holds session target's queue though the session sits +// idle (issue #45): its only open task, at work on the board, with the session not at +// work and its screen still past the idle limit. An agent with no hooks says nothing of +// its turns, so its screen's stillness alone decides, over the whole limit; one whose +// hooks said it is done needs only the quiet a queued task waits for. False when the +// session is busy, or still too short a while to tell. +func (s *Server) goaStaleWork(target proto.SessionInfo, now time.Time) (proto.GOATask, bool) { + if goa.Busy(target) || target.Active || target.Exited || target.Queued || target.FinishedAt == 0 { + return proto.GOATask{}, false + } + var open []proto.GOATask + for _, t := range s.goa.board.List() { + if t.Session == target.ID && !goa.Settled(t.Status) && t.Status != proto.GOATaskTurnEnded { + open = append(open, t) + } + } + if len(open) != 1 || open[0].Status != proto.GOATaskWorking && open[0].Status != proto.GOATaskStalled { + return proto.GOATask{}, false + } + need := stallSeconds(s.goaStall().Idle) + if target.State == proto.StateDone && agent.ReportsItself(target.Agent) { + need = queuedQuiet + } + if need <= 0 || now.Sub(time.UnixMilli(target.FinishedAt)) < need { + return proto.GOATask{}, false + } + return open[0], true +} + +// goaEndStale marks stale task old turn-ended, as its session sits idle, for task next to +// go in, and tells GOA so: it is GOA's to settle. +func (s *Server) goaEndStale(old proto.GOATask, next string, idle time.Duration) { + _, _ = s.goa.board.Update(old.ID, func(t *proto.GOATask) { + if t.Status == proto.GOATaskWorking || t.Status == proto.GOATaskStalled { + t.Status = proto.GOATaskTurnEnded + } + }) + s.goaTell(fmt.Sprintf("[enowX GOA] Task %s %q was still open though its session %s has sat idle for %s: it is marked turn-ended, and %s goes in now. Check what it did (chat %s), then settle it: goa task %s --status done|failed --result \"…\".", + old.ID, old.Title, old.Session, idle.Round(time.Second), next, old.Session, old.ID)) + s.goaChanged() +} + +// goaQueueHolder is what holds session id's queue, said so it can be settled. +func (s *Server) goaQueueHolder(id string) string { + for _, t := range s.goa.board.List() { + if t.Session == id && (t.Status == proto.GOATaskWorking || t.Status == proto.GOATaskWaiting || t.Status == proto.GOATaskStalled) { + return fmt.Sprintf(" Queued behind task %s, still open on the board: once it is over, settle it (goa task %s --status done|failed|stopped) and this one goes in.", t.ID, t.ID) + } + } + return " Queued: its session is busy, and gets it when its turn ends." +} diff --git a/daemon/goa_stale_test.go b/daemon/goa_stale_test.go new file mode 100644 index 00000000..cecd1170 --- /dev/null +++ b/daemon/goa_stale_test.go @@ -0,0 +1,92 @@ +package daemon + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/enowx/enowx/proto" +) + +// An idle worker with no hooks whose earlier task the board still has at work (as GOA +// set it, or its turn's end went unheard) takes the next task now, in the same session: +// that older task is marked turn-ended and GOA is told (issue #45). +func TestGOADeliversToAnIdleWorkerWithAStaleTask(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses sh") + } + bin := t.TempDir() + script := "#!/bin/sh\n[ \"$1\" = --version ] && { echo 1.18.35; exit 0; }\necho '> '\nwhile read -r line; do echo \"heard: $line\"; done\n" + if err := os.WriteFile(filepath.Join(bin, "opencode"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "claude"), []byte("#!/bin/sh\nwhile read -r line; do :; done\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + srv := startServer(t) + c := connect(t, srv, nil) + if err := c.Call(proto.MethodGOAStallSave, proto.GOAStall{Blank: 60, Idle: 1, Stuck: 60}, nil); err != nil { + t.Fatal(err) + } + if _, err := c.CreateSession(proto.SessionCreateParams{Agent: "claude", GOA: true, Cols: 80, Rows: 24}); err != nil { + t.Fatal(err) // GOA, to be told + } + var info proto.SessionInfo + if err := c.Call(proto.MethodSessionCreate, proto.SessionCreateParams{Agent: "opencode", Project: t.TempDir(), BranchMode: proto.BranchNone, Cols: 80, Rows: 24}, &info); err != nil { + t.Fatal(err) + } + sess, err := srv.sessions.Get(info.ID) + if err != nil { + t.Fatal(err) + } + old, err := srv.goa.board.Add(proto.GOATask{Title: "earlier", Session: info.ID, Prompt: "earlier", Status: proto.GOATaskWorking}) + if err != nil { + t.Fatal(err) + } + waitFor(t, "its screen to settle past the idle limit", func() bool { + i := sess.Info() + return !i.Active && i.FinishedAt > 0 && time.Since(time.UnixMilli(i.FinishedAt)) > 1500*time.Millisecond + }) + before := len(srv.sessions.List()) + + var task proto.GOATask + if err := c.Call(proto.MethodGOADelegate, proto.GOADelegateParams{Session: info.ID, Prompt: "the next task", NoRoute: true}, &task); err != nil { + t.Fatal(err) + } + if task.Status == proto.GOATaskQueued || task.Session != info.ID { + t.Fatalf("queued, or elsewhere: %+v", task) + } + if got := taskStatus(t, srv, old.ID).Status; got != proto.GOATaskTurnEnded { + t.Fatalf("the earlier task: %s", got) + } + waitFor(t, "the task reaching it", func() bool { return heardTimes(sess, "the next task") == 1 }) + if n := len(srv.sessions.List()); n != before { + t.Fatalf("%d sessions, was %d", n, before) + } + if !strings.Contains(strings.Join(srv.goaWaitingLines(), "\n"), old.ID) { + t.Fatalf("GOA wasn't told of %s: %q", old.ID, srv.goaWaitingLines()) + } +} + +// A session really at work still queues the task, its reason naming what holds it. +func TestGOAQueuesForABusySessionNamingWhatHoldsIt(t *testing.T) { + queueServerAgent(t) + srv := startServer(t) + c := connect(t, srv, nil) + sess := busySession(t, srv, c, t.TempDir()) + old, err := srv.goa.board.Add(proto.GOATask{Title: "at work", Session: sess.ID(), Prompt: "at work", Status: proto.GOATaskWorking}) + if err != nil { + t.Fatal(err) + } + task := queuedTask(t, c, proto.GOADelegateParams{Session: sess.ID(), Prompt: "after it"}) + if !strings.Contains(task.Why, old.ID) || !strings.Contains(task.Why, "goa task "+old.ID+" --status done") { + t.Fatalf("why: %q", task.Why) + } + if got := taskStatus(t, srv, old.ID).Status; got != proto.GOATaskWorking { + t.Fatalf("the task at work: %s", got) + } +} diff --git a/daemon/goa_tasks.go b/daemon/goa_tasks.go index 61dd8208..e9cfc733 100644 --- a/daemon/goa_tasks.go +++ b/daemon/goa_tasks.go @@ -94,10 +94,15 @@ func (s *Server) goaDelegate(p proto.GOADelegateParams) (proto.GOATask, error) { } t.Woken = woken s.goaSkipOn(p, target) + // Idle past the limit with only an old task open: that task is over, whatever + // the board still says, and this one goes in. + if old, ok := s.goaStaleWork(target, time.Now()); !t.Woken && ok { + s.goaEndStale(old, "the new task", time.Since(time.UnixMilli(target.FinishedAt))) + } // Busy: it takes the task at its turn's end, in order (goaDeliver). if !t.Woken && (goa.Busy(target) || s.goaOpenTask(t.Session)) { t.Status = proto.GOATaskQueued - t.Why = strings.TrimSpace(t.Why + " Queued: its session is busy, and gets it when its turn ends.") + t.Why = strings.TrimSpace(t.Why + s.goaQueueHolder(t.Session)) } if t.Status == proto.GOATaskQueued { return s.goaAddTask(p, t, target) diff --git a/docs/cli.md b/docs/cli.md index 535678a3..8ad6f019 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -142,7 +142,7 @@ GOA's commands ([goa.md](goa.md)): a session handing tasks to others, on any hos |---|---| | `goa delegate [--session S \| --agent A --project P [--branch B]] [--host H] [--model M] [--effort E] [--config C] [--no-route] [--title T] [--yolo] PROMPT` | Hands a task to a session (`goa.delegate`): an existing one, its model and effort switched first, or a new one opened in P (added as a project). The judge's route picks the model and effort (a step cheaper near a token budget) unless given; prints the task, its model, effort and why. | | `goa board [--json]` | The tasks, each with its session and host, model and effort and why, status (working, waiting, done, failed, stopped), result, what it asks and whether that is the user's. | -| `goa task ID [--status S] [--result TEXT]` | Settles a task, or says where it stands. | +| `goa task ID [--status done\|failed\|stopped] [--result TEXT]` | Settles a task: done, failed or stopped. Working and waiting are what its session says. | | `goa remember [--kind task\|delegation\|outcome\|decision\|preference] TEXT` | Keeps it in GOA's own RAG collection, never a project's. | | `goa recall QUERY [--project P [--host H]]...` | Searches GOA's collection (by meaning once indexed, else by words), and projects' read-only. | | `goa stall [--blank D] [--idle D] [--stuck D]` | How long a task may not move before GOA is told, on this host ([goa.md](goa.md)); a limit given changes it: `90s`, `10m`, `1h`, or `0` for its default. | diff --git a/docs/goa.md b/docs/goa.md index 4e4ff3a1..6b8deb13 100644 --- a/docs/goa.md +++ b/docs/goa.md @@ -86,6 +86,10 @@ There is no limit on workers; rate limits fall to accounts' fallback ([accounts. **A task's to-dos.** `goa delegate --note NOTE --items "one\nper line"` shows the worker the to-dos its task finishes, with the rule to tick each in the same turn as its commit. Settling the task done doesn't tick them: it says which aren't ticked (`Open` on the board), as a to-do done but not committed is the worker's to say. GOA never tells a worker to leave a to-do open. +**A task for a session with one open.** It is queued while the session is really at work or asking, and its reason names the task that holds it and how to settle it (`goa task ID --status done|failed|stopped`). A session not at work whose screen has been still past the idle limit (`stall` limits; for an agent whose hooks said its turn ended, the 20 seconds a queued task waits) has its one open task marked turn-ended, GOA told so, and the new task goes in. An agent without hooks says nothing of its turns, so only that whole limit counts for it. + +**GOA settles tasks; it doesn't set them at work.** `goa task` and `goa_task` take done, failed or stopped: working and waiting are what a task's session says, and one set at work by hand held its session's queue though the session was idle. + **Which session a project's task goes to** (`goa.PickSession`): an agent's session of its own in the project's folder, not in a worktree, whose branch is another task's; the user's, not a teammate's; of `--agent` when given, and the same `--config` when given. Not one working, asking the user, or with an open task on the board, and not a running one of an agent without hooks, as it may be working unseen. Of those, a running one first, then a hibernated one, then one that ended; among equals, the newest. With none, a new session. A running session keeps its own config when the judge's route names another provider's. An agent that quit to the shell in its terminal counts as running, as the daemon can't tell it from one at rest. ## Every session reports to GOA From 4bdda07df1ed55e0cfa600e576a8be618e0e5066 Mon Sep 17 00:00:00 2001 From: enowdev Date: Fri, 6 Nov 2026 11:58:51 +0700 Subject: [PATCH 3/4] Read OpenCode's database briefly, and only while it runs Its token records were read every 20 seconds with a LIKE over every part, in one statement, whether or not OpenCode ran; OpenCode reported its own writes timing out on a locked database (#47). Parts are now read after the last one by update time and id, 200 at a time, with a one-second lock wait, and only while one of its sessions runs and once just after. Co-Authored-By: Claude Opus 5.5 --- core/tokens/adapt_opencode.go | 43 ++++++++++++++++++++----- core/tokens/adapt_opencode_page_test.go | 36 +++++++++++++++++++++ core/tokens/scan.go | 6 ++-- daemon/tokens.go | 6 +++- daemon/tokens_live.go | 36 +++++++++++++++++++++ daemon/tokens_live_test.go | 41 +++++++++++++++++++++++ docs/tokens.md | 3 ++ 7 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 core/tokens/adapt_opencode_page_test.go create mode 100644 daemon/tokens_live.go create mode 100644 daemon/tokens_live_test.go diff --git a/core/tokens/adapt_opencode.go b/core/tokens/adapt_opencode.go index 71cc9f8e..9511c789 100644 --- a/core/tokens/adapt_opencode.go +++ b/core/tokens/adapt_opencode.go @@ -88,24 +88,47 @@ func (s opencodeStep) call(m opencodeMessage, at time.Time) (Call, bool) { Input: t.Input, CacheRead: t.Cache.Read, CacheWrite: t.Cache.Write, Output: t.Output + t.Reasoning, Reasoning: t.Reasoning}, true } +// opencodePage is how many parts one read of OpenCode's database takes: each read is a +// short statement, so the database's own writers never wait on it long (issue #47). +var opencodePage = 200 + +// readOpenCode reads the step parts written since st's mark, a page at a time, in the +// order they were written: by time_updated, then id, as several parts share a time. Only +// read, with a short wait for a lock, never holding one between pages. func readOpenCode(path string, st *FileState) ([]Call, error) { if strings.HasSuffix(path, ".json") { return readOpenCodeFile(path) } - db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(3000)") + db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(1000)") if err != nil { return nil, err } defer db.Close() - // A step's part is written once its call is done; those written - // since the last read. + db.SetMaxOpenConns(1) + var out []Call + for { + n, calls, err := readOpenCodePage(db, st) + if err != nil { + return out, err + } + out = append(out, calls...) + if n < opencodePage { + return out, nil + } + } +} + +// readOpenCodePage reads one page of parts after st's mark, moving the mark past them. +func readOpenCodePage(db *sql.DB, st *FileState) (int, []Call, error) { rows, err := db.Query(`SELECT p.id, p.session_id, p.time_created, p.time_updated, p.data, m.data FROM part p JOIN message m ON m.id = p.message_id - WHERE p.time_updated > ? AND p.data LIKE '%step-finish%' ORDER BY p.time_updated`, st.Mark) + WHERE p.time_updated > ? OR (p.time_updated = ? AND p.id > ?) + ORDER BY p.time_updated, p.id LIMIT ?`, st.Mark, st.Mark, st.MarkID, opencodePage) if err != nil { - return nil, err + return 0, nil, err } defer rows.Close() + n := 0 var out []Call for rows.Next() { var id, session, part, message string @@ -113,10 +136,14 @@ func readOpenCode(path string, st *FileState) ([]Call, error) { if rows.Scan(&id, &session, &created, &updated, &part, &message) != nil { continue } - st.Mark = max(st.Mark, updated) + n++ + st.Mark, st.MarkID = updated, id var s opencodeStep + if json.Unmarshal([]byte(part), &s) != nil || s.Type != "step-finish" { + continue + } var m opencodeMessage - if json.Unmarshal([]byte(part), &s) != nil || json.Unmarshal([]byte(message), &m) != nil { + if json.Unmarshal([]byte(message), &m) != nil { continue } s.ID, s.SessionID = cmp.Or(s.ID, id), cmp.Or(s.SessionID, session) @@ -124,7 +151,7 @@ func readOpenCode(path string, st *FileState) ([]Call, error) { out = append(out, c) } } - return out, rows.Err() + return n, out, rows.Err() } // readOpenCodeFile reads a step part of the older layout, its message diff --git a/core/tokens/adapt_opencode_page_test.go b/core/tokens/adapt_opencode_page_test.go new file mode 100644 index 00000000..5fdcf4e0 --- /dev/null +++ b/core/tokens/adapt_opencode_page_test.go @@ -0,0 +1,36 @@ +package tokens + +import ( + "path/filepath" + "testing" +) + +// Read a page at a time, even of one part, OpenCode's database gives the same steps as +// read whole, and a second read gives none again (issue #47). +func TestOpenCodeReadInPagesCountsTheSame(t *testing.T) { + path := filepath.Join(t.TempDir(), "opencode.db") + openCodeDB(t, path) + whole, err := readOpenCode(path, &FileState{}) + if err != nil { + t.Fatal(err) + } + saved := opencodePage + t.Cleanup(func() { opencodePage = saved }) + opencodePage = 1 + st := &FileState{} + paged, err := readOpenCode(path, st) + if err != nil { + t.Fatal(err) + } + if len(whole) == 0 || len(paged) != len(whole) { + t.Fatalf("whole %d, paged %d", len(whole), len(paged)) + } + for i := range whole { + if whole[i].ID != paged[i].ID || whole[i].Input != paged[i].Input || whole[i].Output != paged[i].Output { + t.Errorf("%d: %+v, paged %+v", i, whole[i], paged[i]) + } + } + if again, _ := readOpenCode(path, st); len(again) != 0 { + t.Fatalf("read again: %d", len(again)) + } +} diff --git a/core/tokens/scan.go b/core/tokens/scan.go index eae33cb6..8bb5e87f 100644 --- a/core/tokens/scan.go +++ b/core/tokens/scan.go @@ -36,8 +36,10 @@ type FileState struct { Provider string `json:"provider,omitempty"` Dir string `json:"dir,omitempty"` Conv string `json:"conv,omitempty"` - // Mark is an adapter's own place: OpenCode's last update read. - Mark int64 `json:"mark,omitempty"` + // Mark is an adapter's own place: OpenCode's last update read, and MarkID the part + // read last at that time, as several share one. + Mark int64 `json:"mark,omitempty"` + MarkID string `json:"markID,omitempty"` // Running is Codex's running total as last read, to take the next from when a // record has only that; ForkAt, in a fork, when the fork began: what is dated before // is its parent's. diff --git a/daemon/tokens.go b/daemon/tokens.go index ee93f77e..e6006947 100644 --- a/daemon/tokens.go +++ b/daemon/tokens.go @@ -44,6 +44,8 @@ type tokenState struct { poke chan struct{} // recountTried says this run asked for a recount; runTokens' goroutine owns it. recountTried bool + // dbSeen is when a session of each of tokenDBAgents last ran; scanMu owns it. + dbSeen map[string]time.Time mu sync.Mutex hostDays time.Time // when the synced totals were last written @@ -133,11 +135,13 @@ func (s *Server) scanTokens(since time.Time) (added []tokens.Call, files int) { t.scanMu.Lock() defer t.scanMu.Unlock() st := t.settings.Get() + var only func(string) bool // a backfill asked for reads everything if since.IsZero() { since = time.Now().AddDate(0, 0, -st.BackfillDays) + only = s.tokenAgentsLive(time.Now()) } home, _ := os.UserHomeDir() - calls, files := t.scanner.Scan(tokens.Env{Home: home, Dirs: s.tokenDirs()}, since, nil) + calls, files := t.scanner.Scan(tokens.Env{Home: home, Dirs: s.tokenDirs()}, since, only) if st.Screen { calls = append(calls, s.screenCalls()...) } diff --git a/daemon/tokens_live.go b/daemon/tokens_live.go new file mode 100644 index 00000000..0a0354d3 --- /dev/null +++ b/daemon/tokens_live.go @@ -0,0 +1,36 @@ +package daemon + +import ( + "time" + + "github.com/enowx/enowx/core/agent" +) + +// tokenDBAgents keep their records in a database their running program writes to +// (OpenCode's opencode.db and its kin): it is read while one of their sessions runs and +// once more just after, never between, so its writers aren't kept waiting by a reader +// with nothing new to find (issue #47). +var tokenDBAgents = map[string]bool{"opencode": true, "opencode2": true, "kilo": true, "mimo-code": true} + +// tokenAgentsLive says which agents' records a scan at now reads: every agent's but +// those of tokenDBAgents with no session running, or stopped more than a scan ago. It +// runs with scanMu held, which owns dbSeen. +func (s *Server) tokenAgentsLive(now time.Time) func(string) bool { + t := &s.tok + if t.dbSeen == nil { + t.dbSeen = map[string]time.Time{} + } + for _, sess := range s.sessions.List() { + info := sess.Info() + if id := agent.Base(info.Agent); !info.Exited && tokenDBAgents[id] { + t.dbSeen[id] = now + } + } + return func(id string) bool { + if !tokenDBAgents[id] { + return true + } + seen, ok := t.dbSeen[id] + return ok && now.Sub(seen) <= 2*scanEvery + } +} diff --git a/daemon/tokens_live_test.go b/daemon/tokens_live_test.go new file mode 100644 index 00000000..0a7016f8 --- /dev/null +++ b/daemon/tokens_live_test.go @@ -0,0 +1,41 @@ +package daemon + +import ( + "testing" + "time" + + "github.com/enowx/enowx/core/session" +) + +// OpenCode's database is read only while an OpenCode session runs, and once just after: +// other agents' records always (issue #47). +func TestOpenCodeRecordsReadOnlyWhileItRuns(t *testing.T) { + srv := startServer(t) + srv.tok.scanMu.Lock() + defer srv.tok.scanMu.Unlock() + now := time.Now() + live := srv.tokenAgentsLive(now) + if live("opencode") || live("kilo") || !live("claude") || !live("codex") { + t.Fatal("with no session running") + } + sess, err := srv.sessions.Create(session.Spec{Command: []string{"sh", "-c", "cat"}, Dir: t.TempDir(), Cols: 80, Rows: 24, Meta: session.Meta{Agent: "opencode"}}) + if err != nil { + t.Fatal(err) + } + if !srv.tokenAgentsLive(now)("opencode") { + t.Fatal("with an OpenCode session running") + } + if err := srv.sessions.Kill(sess.ID()); err != nil { + t.Fatal(err) + } + waitFor(t, "it ended", func() bool { + _, err := srv.sessions.Get(sess.ID()) + return err != nil || sess.Info().Exited + }) + if !srv.tokenAgentsLive(now.Add(scanEvery))("opencode") { + t.Fatal("the scan just after it stopped") + } + if srv.tokenAgentsLive(now.Add(3 * scanEvery))("opencode") { + t.Fatal("long after it stopped") + } +} diff --git a/docs/tokens.md b/docs/tokens.md index 7e85ec44..ecab7ad3 100644 --- a/docs/tokens.md +++ b/docs/tokens.md @@ -13,6 +13,9 @@ Each agent is tracked by the best layer it allows, and the app says which (Token 3. **Its screen.** With **Read the counts agents print** on, the screens of running agents that have no records and aren't metered are read for the lines agents print their counts in. Only Aider's line is known (`Tokens: 4.4k sent, … received`); no other agent here prints per-call counts. 4. **None.** Shown as not tracked, never guessed. + +**OpenCode's database** (`opencode.db`, and Kilo Code's and MiMo Code's of the same kind) is read only while one of that agent's sessions runs, and once in the scan after it stops: the program writing it is never kept waiting by a read with nothing new. Each read is short: parts after the last one read, by their update time, 200 at a time, read-only with a one-second wait for a lock. OpenCode run outside enowX, with none of its sessions running, is counted when one next runs. + ## What "tokens" means The headline **Tokens**, everywhere (the Usage page, a session's line, `enowx usage`, `usage_summary` and `usage_session`, token budgets), is **input + output + cache write**. **Cache read is shown beside it and never added**: it is the same context read again on every call, on one Mac 4.8B against 70M of the rest over a week. **Input** means new input, without the cache. **Cost still prices every type**, cache read included. From 2711956b321834ceb7246657a5d396f99fd6cced Mon Sep 17 00:00:00 2001 From: enowdev Date: Fri, 6 Nov 2026 12:12:22 +0700 Subject: [PATCH 4/4] Release 0.7.1 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +++++++++++++ cmd/enowx/mygo.json | 2 +- version/version.go | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 763e722e..5f529475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ Each version's section is also what the app's update dialog shows. +## 0.7.1 + +Three fixes from @sicknewbie's reports on Windows 11 with OpenCode: a terminal drawn in a corner after another view, GOD of Agents queueing tasks for an idle session, and enowX's reads of OpenCode's database. + +- **A terminal no longer stays drawn in a corner after another view of its session** ([#44](https://github.com/enowdev/enowx/issues/44)). A session can be shown in two places at once, such as a GOD of Agents tab and the Canvas page's agent panel, and whichever resized it last set its size. Going back to the first view didn't ask for its size again, so the agent kept drawing at the other's until the window was resized. A view that comes back into sight now asks for its size again. +- **GOD of Agents no longer queues tasks behind an idle session's stale task** ([#45](https://github.com/enowdev/enowx/issues/45)). A task GOA set back to working, or whose turn's end went unheard on an agent without hooks such as OpenCode, stayed open on an idle session, and every next task was queued behind it. A session not at work whose screen has been still past the idle limit now has that task marked turn-ended, GOA is told to settle it, and the new task goes in. A queue names the task holding it and how to settle it, and `goa task` takes only done, failed or stopped. +- **enowX reads OpenCode's database briefly, and only while it runs** ([#47](https://github.com/enowdev/enowx/issues/47)). Its token counts were read every 20 seconds, one long statement over every part, whether or not OpenCode ran. They are now read 200 parts at a time, read-only with a one-second wait for a lock, only while an OpenCode session runs and once just after. OpenCode reported its own writes timing out on a locked database; several OpenCode processes still share it, so this lightens enowX's part only. + +### What has been tested + +- Each fix has tests that run on Linux and in CI, and the token counts read in pages match those read whole. +- **Not verified on Windows, where all three were reported, nor with a real OpenCode 1.18.** If one comes back, the diagnostics bundle (`enowx-app diagnostics --bundle FILE`) helps. + ## 0.7.0 Claude Platform, enowX's own agent; Project Docs; the community library at https://ui.enowx.ai; Claude Code now always running in bypass; a fifth of the scrollback; and much less memory for the app and the daemon. diff --git a/cmd/enowx/mygo.json b/cmd/enowx/mygo.json index e1d5820b..9a648c36 100644 --- a/cmd/enowx/mygo.json +++ b/cmd/enowx/mygo.json @@ -1,7 +1,7 @@ { "name": "enowX", "identifier": "ai.enowx.app", - "version": "0.7.0", + "version": "0.7.1", "copyright": "© 2026 enowX contributors", "out": "../../build", "urlSchemes": ["enowx"], diff --git a/version/version.go b/version/version.go index ffa3c01f..019234bc 100644 --- a/version/version.go +++ b/version/version.go @@ -3,4 +3,4 @@ package version // String is the release version. The app replaces daemons of any other. -const String = "0.7.0" +const String = "0.7.1"