From: Anderson A. Giacomolli Date: Wed, 11 Nov 2026 14:57:25 +0000 (-0300) Subject: Complete context on forwarded local errors X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/e86df5fad9aa2f280aad845adeefd239b0e944ae Complete context on forwarded local errors Forwarded failures from local setup, scans, persistence, and command execution identify their operation without losing the original cause. Successful results remain unchanged, and privacy-safe Telegram diagnostics keep their existing contract. Tests cover nil results for missing nodes and cause identity after read or sync failures. Issue: 3cdc678 --- diff --git a/agent.go b/agent.go index fbea57f..da4b0b7 100644 --- a/agent.go +++ b/agent.go @@ -61,7 +61,12 @@ func openAgent(dir string) (*Agent, error) { path := filepath.Join(dir, "config.json") data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { - return createAgent(dir) + agent, err := createAgent(dir) + if err != nil { + return agent, fmt.Errorf("create agent: %w", err) + } + + return agent, nil } if err != nil { @@ -348,7 +353,12 @@ func createAgent(dir string) (*Agent, error) { return nil, fmt.Errorf("prepare agent defaults: %w", err) } - return writeAgent(dir, config) + agent, err := writeAgent(dir, config) + if err != nil { + return agent, fmt.Errorf("write new agent: %w", err) + } + + return agent, nil } // defaultAgentConfig returns the settings of a new agent, without an ID. The diff --git a/chatgpt.go b/chatgpt.go index 49be015..a5d9ee3 100644 --- a/chatgpt.go +++ b/chatgpt.go @@ -458,5 +458,10 @@ func chatgptLogin(ctx context.Context, out io.Writer) (err error) { err = errors.Join(err, tokens.Close()) }() - return newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer).Login(ctx, out) + err = newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer).Login(ctx, out) + if err != nil { + return fmt.Errorf("complete device login: %w", err) + } + + return nil } diff --git a/command.go b/command.go index 02901e2..fefb662 100644 --- a/command.go +++ b/command.go @@ -54,15 +54,40 @@ func parseCommand(ctx context.Context, args []string) error { // A signal is the normal stop of the daemon and the client, so their // result does not include ctx.Err(). case len(args) == 1 && args[0] == "daemon": - return daemon(ctx, os.Stdout) + err := daemon(ctx, os.Stdout) + if err != nil { + return fmt.Errorf("run daemon: %w", err) + } + + return nil case len(args) == 0 || len(args) == 1 && args[0] == "chat": - return terminal(ctx, os.Stdin, os.Stdout) + err := terminal(ctx, os.Stdin, os.Stdout) + if err != nil { + return fmt.Errorf("run terminal: %w", err) + } + + return nil case len(args) == 2 && args[0] == "backup": - return backup(ctx, args[1], os.Stdout) + err := backup(ctx, args[1], os.Stdout) + if err != nil { + return fmt.Errorf("run backup: %w", err) + } + + return nil case len(args) == 2 && args[0] == "restore": - return restore(ctx, args[1], os.Stdout) + err := restore(ctx, args[1], os.Stdout) + if err != nil { + return fmt.Errorf("run restore: %w", err) + } + + return nil case len(args) > 0 && args[0] == "import": - return whatsappImport(ctx, args[1:], os.Stdout) + err := whatsappImport(ctx, args[1:], os.Stdout) + if err != nil { + return fmt.Errorf("import WhatsApp history: %w", err) + } + + return nil case len(args) > 0 && args[0] == "probe": err := probe(ctx, args[1:], os.Stdout) if err != nil { diff --git a/daemon.go b/daemon.go index 962070a..7945c2b 100644 --- a/daemon.go +++ b/daemon.go @@ -359,9 +359,14 @@ func daemon(ctx context.Context, out io.Writer) (err error) { logger.Info("Telegram bot started") } - return daemonServe(ctx, listener, telegram, whatsapp, schedules, func(notify func(Event)) (*TurnLoop, error) { + err = daemonServe(ctx, listener, telegram, whatsapp, schedules, func(notify func(Event)) (*TurnLoop, error) { return root.Loop(whatsapp, notify) }, agents[1:]...) + if err != nil { + return fmt.Errorf("serve daemon: %w", err) + } + + return nil } // daemonListenSocket listens on the Unix socket at path, with mode 0600. The diff --git a/journal.go b/journal.go index 6251e8c..d048913 100644 --- a/journal.go +++ b/journal.go @@ -215,7 +215,12 @@ func journalScanFile(ctx context.Context, path string, day uint32, offset uint64 } if errors.Is(err, io.EOF) && last { - return journalCutFile(path, offset) + err = journalCutFile(path, offset) + if err != nil { + return fmt.Errorf("cut incomplete day line: %w", err) + } + + return nil } if errors.Is(err, io.EOF) { diff --git a/log.go b/log.go index 31e1789..61f4d4c 100644 --- a/log.go +++ b/log.go @@ -121,7 +121,7 @@ func (l *Log) catchUp(ctx context.Context) error { } } - return l.journal.Scan(ctx, start, func(line []byte, place Position) error { + err := l.journal.Scan(ctx, start, func(line []byte, place Position) error { var message Message err := json.Unmarshal(line, &message) @@ -138,8 +138,18 @@ func (l *Log) catchUp(ctx context.Context) error { return fmt.Errorf("message %d: %w", message.ID, err) } - return l.addIndex(place) + err = l.addIndex(place) + if err != nil { + return fmt.Errorf("index scanned message: %w", err) + } + + return nil }) + if err != nil { + return fmt.Errorf("scan message journal: %w", err) + } + + return nil } func (l *Log) addIndex(place Position) error { diff --git a/memory.go b/memory.go index be58105..97be6a1 100644 --- a/memory.go +++ b/memory.go @@ -270,7 +270,12 @@ func (m *Memory) catchUp() error { } } - return m.save() + err := m.save() + if err != nil { + return fmt.Errorf("save caught-up memory: %w", err) + } + + return nil } // dispatch starts the builds that are ready, up to the limit. A build whose @@ -311,7 +316,12 @@ func (m *Memory) build(node Node) error { m.chat.Refresh(node) m.cview.Refresh(node) - return m.settle(NodeKey{node.Level, node.Index}) + err = m.settle(NodeKey{node.Level, node.Index}) + if err != nil { + return fmt.Errorf("settle built node: %w", err) + } + + return nil } // settle looks at the parent of the built node key when both children are diff --git a/model.go b/model.go index f630b3e..78836fe 100644 --- a/model.go +++ b/model.go @@ -344,7 +344,12 @@ func (c *ModelClient) send(httpRequest *http.Request, record *ModelUsageRecord) return ModelResponse{}, errors.Join(err, readErr) } - return modelReadStream(httpResponse.Body) + response, err = modelReadStream(httpResponse.Body) + if err != nil { + return response, fmt.Errorf("read model response stream: %w", err) + } + + return response, nil } // Write appends record to the file with one write. diff --git a/probe.go b/probe.go index a1ee38d..d896fcb 100644 --- a/probe.go +++ b/probe.go @@ -42,7 +42,12 @@ func (s ProbeSlotSummarizer) Summarize(ctx context.Context, task Task) (string, <-s.slots }() - return s.inner.Summarize(ctx, task) + text, err := s.inner.Summarize(ctx, task) + if err != nil { + return text, fmt.Errorf("summarize probe node: %w", err) + } + + return text, nil } // probe runs "onechat probe LOG COUNT [BUILDS]". It appends the first COUNT diff --git a/schedule.go b/schedule.go index 23462d2..3c3456d 100644 --- a/schedule.go +++ b/schedule.go @@ -286,7 +286,12 @@ func (s *Schedules) Done(schedule Schedule, now time.Time) error { if schedule.Cron == "" { s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1) - return s.save() + err := s.save() + if err != nil { + return fmt.Errorf("save completed one-time schedule: %w", err) + } + + return nil } cron, err := scheduleParseCron(schedule.Cron) @@ -298,12 +303,22 @@ func (s *Schedules) Done(schedule Schedule, now time.Time) error { if !found { s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1) - return s.save() + err = s.save() + if err != nil { + return fmt.Errorf("save exhausted schedule: %w", err) + } + + return nil } s.file.Schedules[index].Due = next - return s.save() + err = s.save() + if err != nil { + return fmt.Errorf("save next schedule run: %w", err) + } + + return nil } // sorted returns a copy of the schedules by due time, then by ID. diff --git a/subagent.go b/subagent.go index 37e9b3a..9976a4a 100644 --- a/subagent.go +++ b/subagent.go @@ -89,7 +89,12 @@ func (s *Subagents) Create(ctx context.Context) (*Subagent, error) { return nil, fmt.Errorf("create subagent directory: %w", err) } - return s.Get(ctx, name) + subagent, err := s.Get(ctx, name) + if err != nil { + return subagent, fmt.Errorf("open created worker: %w", err) + } + + return subagent, nil } return nil, fmt.Errorf("no free subagent name after %d draws", subagentNameDraws) diff --git a/subagent_run.go b/subagent_run.go index e96975c..54fa430 100644 --- a/subagent_run.go +++ b/subagent_run.go @@ -177,11 +177,16 @@ func (r *SubagentRuns) create(ctx context.Context, note, task string, view []str return "", fmt.Errorf("create work directory: %w", err) } - return subagent.name, r.start(ctx, &SubagentRun{ + err = r.start(ctx, &SubagentRun{ subagent: subagent, view: view, effort: effort, }) + if err != nil { + return subagent.name, fmt.Errorf("start created worker: %w", err) + } + + return subagent.name, nil } // start runs run in the background and logs its start. Its context keeps the @@ -369,7 +374,12 @@ func (r *SubagentRuns) chain(ctx context.Context, run *SubagentRun) (string, err }, } - return chain.Run(ctx, input) + text, err := chain.Run(ctx, input) + if err != nil { + return text, fmt.Errorf("run worker chain: %w", err) + } + + return text, nil } // take returns the messages of Tell that wait for a request of run, and removes @@ -440,7 +450,12 @@ func (r *SubagentRuns) Resume(ctx context.Context) error { r.mutex.Lock() defer r.mutex.Unlock() - return r.save() + err = r.save() + if err != nil { + return fmt.Errorf("save resumed workers: %w", err) + } + + return nil } // load reads the names of work.json. A missing file names none. diff --git a/subagent_run_test.go b/subagent_run_test.go index 8c9205f..047e6a8 100644 --- a/subagent_run_test.go +++ b/subagent_run_test.go @@ -200,12 +200,12 @@ func TestFullContextGivesAFailedReport(t *testing.T) { name := test.events.Last("subagent") require.Equal(t, []string{ - "work: [" + name + "]\nfailed: create model response: model call: HTTP 400: " + subagentTestFullContext + "\n\nFull chat: zoom(\"" + name + "\")", + "work: [" + name + "]\nfailed: run worker chain: create model response: model call: HTTP 400: " + subagentTestFullContext + "\n\nFull chat: zoom(\"" + name + "\")", "reply: It failed.", }, test.Log(t)) require.Equal(t, subagentFailed, test.events.LastEvent("work").Status) - require.Equal(t, "<6>subagent started name="+name+"\n<3>subagent failed name="+name+" after=0s: create model response: model call: HTTP 400: "+subagentTestFullContext+"\n", test.logs.String()) + require.Equal(t, "<6>subagent started name="+name+"\n<3>subagent failed name="+name+" after=0s: run worker chain: create model response: model call: HTTP 400: "+subagentTestFullContext+"\n", test.logs.String()) } func TestSubagentHasItsOwnEffortAndNoSpawn(t *testing.T) { diff --git a/syncer_test.go b/syncer_test.go index d463eec..8eeb1ee 100644 --- a/syncer_test.go +++ b/syncer_test.go @@ -76,16 +76,17 @@ func TestFlushWithoutWritesNeedsNoSync(t *testing.T) { } func TestAFailedSyncFailsEachLaterFlush(t *testing.T) { + failure := errors.New("disk error") s := newSyncer(func() error { - return errors.New("disk error") + return failure }) s.Written() - require.ErrorContains(t, s.Flush(t.Context()), "disk error") + require.ErrorIs(t, s.Flush(t.Context()), failure) s.Written() - require.ErrorContains(t, s.Flush(t.Context()), "disk error") - require.ErrorContains(t, s.Close(), "disk error") + require.ErrorIs(t, s.Flush(t.Context()), failure) + require.ErrorIs(t, s.Close(), failure) } func TestMemoryFlushSyncsTheChat(t *testing.T) { diff --git a/terminal.go b/terminal.go index db259fb..340a8aa 100644 --- a/terminal.go +++ b/terminal.go @@ -46,7 +46,12 @@ func terminal(ctx context.Context, in io.Reader, out io.Writer) error { return fmt.Errorf(`connect to the daemon (start it with "onechat daemon"): %w`, err) } - return terminalSession(ctx, conn, in, out) + err = terminalSession(ctx, conn, in, out) + if err != nil { + return fmt.Errorf("run terminal session: %w", err) + } + + return nil } // terminalSession runs the client on conn until the input ends, the daemon diff --git a/tools.go b/tools.go index 24e0fdb..24e745b 100644 --- a/tools.go +++ b/tools.go @@ -422,7 +422,12 @@ func (b *Toolbox) zoomAgent(ctx context.Context, name string) (string, error) { return "", fmt.Errorf("open worker log: %w", err) } - return subagent.Show() + text, err := subagent.Show() + if err != nil { + return text, fmt.Errorf("read worker log: %w", err) + } + + return text, nil } // zoom returns message id whole for n = 1, or else the two lines under the line diff --git a/tree.go b/tree.go index db93b1d..765016e 100644 --- a/tree.go +++ b/tree.go @@ -122,7 +122,7 @@ func (t *Tree) open(ctx context.Context, total int) error { } } - return t.journal.Scan(ctx, start, func(line []byte, place Position) error { + err = t.journal.Scan(ctx, start, func(line []byte, place Position) error { var node Node err := json.Unmarshal(line, &node) @@ -132,15 +132,25 @@ func (t *Tree) open(ctx context.Context, total int) error { err = t.check(node) if err != nil { - return err + return fmt.Errorf("validate scanned node: %w", err) } if nodeEnd(node.Level, node.Index) > total { return fmt.Errorf("node (%d, %d) covers messages after the log", node.Level, node.Index) } - return t.addIndex(NodeKey{node.Level, node.Index}, place) + err = t.addIndex(NodeKey{node.Level, node.Index}, place) + if err != nil { + return fmt.Errorf("index scanned node: %w", err) + } + + return nil }) + if err != nil { + return fmt.Errorf("scan node journal: %w", err) + } + + return nil } // Close closes the files of the tree. @@ -188,7 +198,12 @@ func (t *Tree) writeLevel(node NodeKey, place Position) error { record := make([]byte, positionBytes) encodePosition(record, place) - return level.Write(node.index, record) + err = level.Write(node.index, record) + if err != nil { + return fmt.Errorf("write node level record: %w", err) + } + + return nil } // check makes sure that node is valid and can join the tree now. @@ -240,7 +255,12 @@ func (t *Tree) addIndex(node NodeKey, place Position) error { return fmt.Errorf("append node order: %w", err) } - return t.writeLevel(node, place) + err = t.writeLevel(node, place) + if err != nil { + return fmt.Errorf("write indexed node level: %w", err) + } + + return nil } // level returns the index of level, and opens it at first use. diff --git a/tree_test.go b/tree_test.go index 2f23d4d..37ecc94 100644 --- a/tree_test.go +++ b/tree_test.go @@ -61,6 +61,36 @@ func TestTreeBuildsEachNodeOnce(t *testing.T) { require.Equal(t, "user: a", treeReadNode(t, tree, 0, 0).Text) } +func TestTreeMissingNodeHasNoErrorAfterWrapping(t *testing.T) { + tree, err := openTree(t.Context(), t.TempDir(), 0, newTestClock().Now) + require.NoError(t, err) + + t.Cleanup(func() { + require.NoError(t, tree.Close()) + }) + + _, built, err := tree.Node(0, 0) + require.NoError(t, err) + require.False(t, built) +} + +func TestTreeReadFailureKeepsItsCause(t *testing.T) { + tree, err := openTree(t.Context(), t.TempDir(), 1, newTestClock().Now) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, tree.Close()) + }) + + require.NoError(t, tree.Add(treeTestNode(0, 0, "user: a"))) + require.NoError(t, tree.levels[0].Close()) + + _, built, err := tree.Node(0, 0) + delete(tree.levels, 0) + require.ErrorIs(t, err, os.ErrClosed) + require.False(t, built) + require.ErrorContains(t, err, "find node position") +} + func TestTreeReopenLoadsTheSameNodes(t *testing.T) { root := t.TempDir() clock := newTestClock() diff --git a/view.go b/view.go index 8877462..9af1c21 100644 --- a/view.go +++ b/view.go @@ -94,7 +94,12 @@ func (v *View) Append(tree ViewNodeReader) (bool, error) { v.lines = append(v.lines, line) v.end++ - return v.fit(tree) + merged, err := v.fit(tree) + if err != nil { + return merged, fmt.Errorf("fit appended view: %w", err) + } + + return merged, nil } // load keeps the node of line when it is built, and tells if it is.