Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion cli/goa.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions cli/goa_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
16 changes: 16 additions & 0 deletions cli/goa_task_status_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
2 changes: 1 addition & 1 deletion cmd/enowx/mygo.json
Original file line number Diff line number Diff line change
@@ -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"],
Expand Down
43 changes: 35 additions & 8 deletions core/tokens/adapt_opencode.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,43 +88,70 @@ 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
var created, updated int64
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)
if c, ok := s.call(m, time.UnixMilli(created)); ok {
out = append(out, c)
}
}
return out, rows.Err()
return n, out, rows.Err()
}

// readOpenCodeFile reads a step part of the older layout, its message
Expand Down
36 changes: 36 additions & 0 deletions core/tokens/adapt_opencode_page_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
6 changes: 4 additions & 2 deletions core/tokens/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
62 changes: 62 additions & 0 deletions daemon/goa_stale.go
Original file line number Diff line number Diff line change
@@ -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."
}
92 changes: 92 additions & 0 deletions daemon/goa_stale_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 6 additions & 1 deletion daemon/goa_tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading