From: Anderson A. Giacomolli Date: Wed, 11 Nov 2026 14:49:23 +0000 (-0300) Subject: Add local context to chat operation failures X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/bd7da3557dc7a17020931f5a49ed5007fa81a645 Add local context to chat operation failures Daemon, chat, schedule, and worker failures identify their local operation while wrapping the original cause. Successful results keep a nil error. Privacy-safe causes and direct context errors retain their existing handling, and shutdown and retry decisions do not change. Issue: 3cdc678 --- diff --git a/chatgpt.go b/chatgpt.go index 7748190..49be015 100644 --- a/chatgpt.go +++ b/chatgpt.go @@ -116,14 +116,14 @@ func (a *ChatGPTAuth) Authorize(ctx context.Context, header http.Header) error { tokens, err := a.read() if err != nil { - return err + return fmt.Errorf("read login tokens: %w", err) } expiry, found := chatgptTokenExpiry(tokens.AccessToken) if !found || !time.Now().Before(expiry.Add(-chatgptRefreshMargin)) { tokens, err = a.refresh(ctx, tokens) if err != nil { - return err + return fmt.Errorf("refresh login tokens: %w", err) } } @@ -184,7 +184,7 @@ func (a *ChatGPTAuth) refresh(ctx context.Context, tokens ChatGPTTokenFile) (Cha err = a.write(tokens) if err != nil { - return ChatGPTTokenFile{}, err + return ChatGPTTokenFile{}, fmt.Errorf("save refreshed tokens: %w", err) } return tokens, nil @@ -199,8 +199,11 @@ func (a *ChatGPTAuth) postJSON(ctx context.Context, path string, body, result an } _, err = a.post(ctx, path, "application/json", data, result) + if err != nil { + return fmt.Errorf("post JSON request: %w", err) + } - return err + return nil } // write replaces the token file atomically. The file has mode 0600. @@ -278,17 +281,17 @@ func (a *ChatGPTAuth) Login(ctx context.Context, out io.Writer) error { grant, err := a.poll(ctx, code.DeviceAuthID, userCode, chatgptPollInterval(code.Interval)) if err != nil { - return err + return fmt.Errorf("wait for device grant: %w", err) } tokens, err := a.exchange(ctx, grant) if err != nil { - return err + return fmt.Errorf("exchange device grant: %w", err) } err = a.write(tokens) if err != nil { - return err + return fmt.Errorf("save login tokens: %w", err) } _, err = fmt.Fprintln(out, "Logged in.") @@ -443,12 +446,12 @@ func chatgptDecodeClaims(token string, claims any) bool { func chatgptLogin(ctx context.Context, out io.Writer) (err error) { dir, err := dataDir() if err != nil { - return err + return fmt.Errorf("find data directory: %w", err) } tokens, err := lockTokens(dir) if err != nil { - return err + return fmt.Errorf("lock login tokens: %w", err) } defer func() { diff --git a/command.go b/command.go index a49f1d9..02901e2 100644 --- a/command.go +++ b/command.go @@ -48,7 +48,7 @@ func parseCommand(ctx context.Context, args []string) error { case len(args) == 1 && args[0] == "login": err := chatgptLogin(ctx, os.Stdout) if err != nil { - return err + return fmt.Errorf("log in: %w", err) } // A signal is the normal stop of the daemon and the client, so their @@ -66,7 +66,7 @@ func parseCommand(ctx context.Context, args []string) error { case len(args) > 0 && args[0] == "probe": err := probe(ctx, args[1:], os.Stdout) if err != nil { - return err + return fmt.Errorf("run probe: %w", err) } default: return fmt.Errorf("unknown command %q\n\n%s", strings.Join(args, " "), strings.TrimSuffix(usage, "\n")) diff --git a/daemon.go b/daemon.go index d6aa6aa..962070a 100644 --- a/daemon.go +++ b/daemon.go @@ -213,8 +213,11 @@ func daemonRunRequest(l *TurnLoop, whatsapp *WhatsApp, request SocketRequest, an } _, err := l.Submit(terminalSource, request.Text) + if err != nil { + return fmt.Errorf("submit terminal message: %w", err) + } - return err + return nil case "image": if !filepath.IsAbs(request.Path) { return errors.New("use /image PATH [caption]") @@ -252,7 +255,11 @@ func daemonRunRequest(l *TurnLoop, whatsapp *WhatsApp, request SocketRequest, an err = fmt.Errorf("no subagent %s runs", request.Text) } - return err + if err != nil { + return fmt.Errorf("stop subagent: %w", err) + } + + return nil default: return fmt.Errorf("unknown request %q", request.Type) } @@ -268,7 +275,7 @@ func daemonRunRequest(l *TurnLoop, whatsapp *WhatsApp, request SocketRequest, an func daemon(ctx context.Context, out io.Writer) (err error) { dir, err := dataDir() if err != nil { - return err + return fmt.Errorf("find data directory: %w", err) } // Check the socket path before the daemon writes a file, so that a data @@ -276,22 +283,22 @@ func daemon(ctx context.Context, out io.Writer) (err error) { socket := filepath.Join(dir, socketFileName) err = checkSocketPath(socket) if err != nil { - return err + return fmt.Errorf("check daemon socket path: %w", err) } config, err := loadConfig(dir) if err != nil { - return err + return fmt.Errorf("load daemon config: %w", err) } botSettings, hasBot, err := loadTelegramConfig(dir) if err != nil { - return err + return fmt.Errorf("load Telegram config: %w", err) } tokens, err := lockTokens(dir) if err != nil { - return err + return fmt.Errorf("lock login tokens: %w", err) } defer func() { @@ -303,7 +310,7 @@ func daemon(ctx context.Context, out io.Writer) (err error) { logger := daemonLogger(out, "onechat: ") whatsapp, err := openWhatsApp(ctx, dir, time.Now, daemonLogger(out, "whatsapp: "), whatsappNewClient) if err != nil { - return err + return fmt.Errorf("open WhatsApp: %w", err) } defer func() { @@ -312,12 +319,12 @@ func daemon(ctx context.Context, out io.Writer) (err error) { address, auth, err := modelEndpoint(config, dir) if err != nil { - return err + return fmt.Errorf("configure model endpoint: %w", err) } agents, err := openDaemonAgents(ctx, dir, address, auth, config.Compactor, time.Now, logger) if err != nil { - return err + return fmt.Errorf("open daemon agents: %w", err) } defer func() { @@ -332,7 +339,7 @@ func daemon(ctx context.Context, out io.Writer) (err error) { listener, err := daemonListenSocket(socket) if err != nil { - return err + return fmt.Errorf("listen for daemon clients: %w", err) } linked := "not linked" @@ -363,7 +370,7 @@ func daemon(ctx context.Context, out io.Writer) (err error) { func daemonListenSocket(path string) (net.Listener, error) { err := checkSocketPath(path) if err != nil { - return nil, err + return nil, fmt.Errorf("check daemon socket path: %w", err) } err = os.Remove(path) diff --git a/daemon_test.go b/daemon_test.go index 6a4d98c..c6cc227 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -39,7 +39,7 @@ func TestClientsSeeTheTurnAsItHappens(t *testing.T) { first.Type(stopCommand) first.WaitFor(t, "[error] no turn runs") first.Type(stopCommand + " BlueFox") - first.WaitFor(t, "[error] no subagent BlueFox runs") + first.WaitFor(t, "[error] stop subagent: no subagent BlueFox runs") require.NotContains(t, second.Output(), "no turn runs") require.NoError(t, first.Close()) } diff --git a/schedule.go b/schedule.go index 7eb747f..23462d2 100644 --- a/schedule.go +++ b/schedule.go @@ -225,7 +225,7 @@ func (s *Schedules) deliver(ctx context.Context, l *TurnLoop) error { err = s.Done(schedule, now) if err != nil { - return err + return fmt.Errorf("acknowledge schedule run: %w", err) } attrs := []any{"id", schedule.ID} @@ -352,7 +352,7 @@ func (s *Schedules) Set(text string, at, cron *string) (string, error) { default: parsed, err := scheduleParseCron(*cron) if err != nil { - return "", err + return "", fmt.Errorf("parse schedule expression: %w", err) } next, found := parsed.Next(now) @@ -372,7 +372,7 @@ func (s *Schedules) Set(text string, at, cron *string) (string, error) { s.file.Schedules = append(s.file.Schedules, schedule) err := s.save() if err != nil { - return "", err + return "", fmt.Errorf("save new schedule: %w", err) } s.change() @@ -430,7 +430,7 @@ func (s *Schedules) Remove(id int) (string, error) { s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1) err := s.save() if err != nil { - return "", err + return "", fmt.Errorf("save removed schedule: %w", err) } s.change() diff --git a/schedule_cron.go b/schedule_cron.go index b982f37..bf1424f 100644 --- a/schedule_cron.go +++ b/schedule_cron.go @@ -277,7 +277,7 @@ func scheduleCronField(field string, low, high int) (uint64, error) { first, err = scheduleCronNumber(base, low, high) if err != nil { - return 0, err + return 0, fmt.Errorf("parse cron field value: %w", err) } last = first diff --git a/schedule_cron_test.go b/schedule_cron_test.go index 06d23cf..33d861b 100644 --- a/schedule_cron_test.go +++ b/schedule_cron_test.go @@ -72,7 +72,7 @@ func TestScheduleParseCronRefuses(t *testing.T) { { name: "rejects a day name", expression: "0 8 * * MON", - message: `the day of week field of "0 8 * * MON": "MON" is not a number from 0 to 7`, + message: `the day of week field of "0 8 * * MON": parse cron field value: "MON" is not a number from 0 to 7`, }, { name: "rejects a month name", diff --git a/subagent_run.go b/subagent_run.go index 24e4f82..e96975c 100644 --- a/subagent_run.go +++ b/subagent_run.go @@ -132,7 +132,7 @@ func (r *SubagentRuns) Spawn(ctx context.Context, tasks []string, effort *string view, err := r.layout.Split(r.memory.ViewLines(nil)) if err != nil { - return "", err + return "", fmt.Errorf("split worker view: %w", err) } names := make([]string, 0, len(tasks)) @@ -143,7 +143,7 @@ func (r *SubagentRuns) Spawn(ctx context.Context, tasks []string, effort *string } if err != nil { - return "", err + return "", fmt.Errorf("create worker: %w", err) } names = append(names, name) @@ -157,19 +157,19 @@ func (r *SubagentRuns) Spawn(ctx context.Context, tasks []string, effort *string func (r *SubagentRuns) create(ctx context.Context, note, task string, view []string, effort string) (string, error) { subagent, err := r.subagents.Create(ctx) if err != nil { - return "", err + return "", fmt.Errorf("create worker log: %w", err) } for _, message := range [][2]string{{"note", note}, {"user", task}} { _, err = subagent.Append(message[0], message[1]) if err != nil { - return "", err + return "", fmt.Errorf("append worker message: %w", err) } } err = subagent.Flush(ctx) if err != nil { - return "", err + return "", fmt.Errorf("flush worker log: %w", err) } err = os.MkdirAll(subagent.WorkDir(), 0o700) @@ -210,7 +210,11 @@ func (r *SubagentRuns) start(ctx context.Context, run *SubagentRun) error { r.mutex.Unlock() run.cancel() - return err + if err != nil { + return fmt.Errorf("save running workers: %w", err) + } + + return nil } r.workers.Add(1) @@ -329,12 +333,12 @@ func (r *SubagentRuns) workPath() string { func (r *SubagentRuns) chain(ctx context.Context, run *SubagentRun) (string, error) { instructions, err := r.agent.SubagentPrompt() if err != nil { - return "", err + return "", fmt.Errorf("read worker instructions: %w", err) } log, err := run.subagent.Show() if err != nil { - return "", err + return "", fmt.Errorf("read worker log: %w", err) } input := make([]any, 0, len(run.view)+2) @@ -410,13 +414,17 @@ func (r *SubagentRuns) Stop(name string) (bool, error) { func (r *SubagentRuns) Resume(ctx context.Context) error { names, err := r.load() if err != nil || len(names) == 0 { - return err + if err != nil { + return fmt.Errorf("load interrupted workers: %w", err) + } + + return nil } text := "The daemon restarted, cutting " + strings.Join(names, ", ") + "; nothing was lost: go on." err = r.note(text) if err != nil { - return err + return fmt.Errorf("record interrupted workers: %w", err) } for _, name := range names { @@ -468,7 +476,7 @@ func (r *SubagentRuns) Tell(ctx context.Context, name, message string) (string, told, err := run.subagent.Append("user", message) if err != nil { - return "", err + return "", fmt.Errorf("append running worker message: %w", err) } run.pending = append(run.pending, told) @@ -485,12 +493,12 @@ func (r *SubagentRuns) Tell(ctx context.Context, name, message string) (string, subagent, err := r.subagents.Get(ctx, name) if err != nil { - return "", err + return "", fmt.Errorf("open worker log: %w", err) } effort, err := r.effort(subagent) if err != nil { - return "", err + return "", fmt.Errorf("read worker effort: %w", err) } err = r.memory.Settle(ctx) @@ -500,7 +508,7 @@ func (r *SubagentRuns) Tell(ctx context.Context, name, message string) (string, view, err := r.layout.Split(r.memory.ViewLines(nil)) if err != nil { - return "", err + return "", fmt.Errorf("split worker view: %w", err) } _, err = subagent.Append("user", message) @@ -509,7 +517,7 @@ func (r *SubagentRuns) Tell(ctx context.Context, name, message string) (string, } if err != nil { - return "", err + return "", fmt.Errorf("save worker message: %w", err) } err = r.start(ctx, &SubagentRun{ @@ -518,7 +526,7 @@ func (r *SubagentRuns) Tell(ctx context.Context, name, message string) (string, effort: effort, }) if err != nil { - return "", err + return "", fmt.Errorf("start told worker: %w", err) } return "Sent. " + name + "'s reply will reach you as a message.", nil @@ -529,7 +537,7 @@ func (r *SubagentRuns) Tell(ctx context.Context, name, message string) (string, func (r *SubagentRuns) effort(subagent *Subagent) (string, error) { note, err := subagent.First() if err != nil { - return "", err + return "", fmt.Errorf("read worker note: %w", err) } effort, own := strings.CutPrefix(note.Text, subagentNote+subagentNoteEffort) diff --git a/subagent_run_test.go b/subagent_run_test.go index 708b515..8c9205f 100644 --- a/subagent_run_test.go +++ b/subagent_run_test.go @@ -357,7 +357,7 @@ func TestTellToAFinishedSubagentGoesOnFromItsLog(t *testing.T) { require.Equal(t, "work: "+subagentReport(name, []string{"Reply 2."}), test.Log(t)[2]) _, err = runs.Tell(t.Context(), "BlueFox", "Hello.") - require.EqualError(t, err, "no agent BlueFox") + require.EqualError(t, err, "open worker log: no agent BlueFox") } func TestStopEndsASubagentAndItsCommand(t *testing.T) { @@ -522,12 +522,12 @@ func TestRestartReportsASubagentThatCannotGoOn(t *testing.T) { events.WaitFor(t, "idle", 1) require.Equal(t, []string{ "note: The daemon restarted, cutting BlueFox; nothing was lost: go on.", - "work: " + subagentReport("BlueFox", []string{"failed: no agent BlueFox"}), + "work: " + subagentReport("BlueFox", []string{"failed: open worker log: no agent BlueFox"}), "reply: a line", }, subagentTestChat(t, memory)) require.Equal(t, subagentFailed, events.LastEvent("work").Status) - require.Equal(t, "<3>subagent lost at restart name=BlueFox: no agent BlueFox\n", logs.String()) + require.Equal(t, "<3>subagent lost at restart name=BlueFox: open worker log: no agent BlueFox\n", logs.String()) data, err := os.ReadFile(filepath.Join(agent.dir, subagentWorkFileName)) require.NoError(t, err) diff --git a/telegram.go b/telegram.go index 306f8f5..85b0f44 100644 --- a/telegram.go +++ b/telegram.go @@ -264,7 +264,7 @@ func (b *TelegramBot) send(ctx context.Context) { func (b *TelegramBot) poll(ctx context.Context, l *TurnLoop, whatsapp *WhatsApp) error { offset, err := telegramLoadOffset(b.offsetPath) if err != nil { - return err + return fmt.Errorf("load Telegram offset: %w", err) } outage := &TelegramOutage{ @@ -313,7 +313,11 @@ poll: continue poll } - return err + if err != nil { + return fmt.Errorf("handle Telegram update: %w", err) + } + + return nil } imageOutage.End() @@ -323,7 +327,7 @@ poll: offset = update.UpdateID + 1 err = telegramSaveOffset(b.offsetPath, offset) if err != nil { - return err + return fmt.Errorf("save Telegram offset: %w", err) } } } @@ -476,7 +480,7 @@ func (b *TelegramBot) handle(ctx context.Context, l *TurnLoop, whatsapp *WhatsAp default: _, err := l.Submit(telegramSource, message.Text) if err != nil { - return err + return fmt.Errorf("submit Telegram text: %w", err) } err = l.Flush(context.WithoutCancel(ctx)) diff --git a/terminal.go b/terminal.go index da0d78a..db259fb 100644 --- a/terminal.go +++ b/terminal.go @@ -30,13 +30,13 @@ var errTerminalDaemonClosed = errors.New("the daemon closed the connection") func terminal(ctx context.Context, in io.Reader, out io.Writer) error { dir, err := dataDir() if err != nil { - return err + return fmt.Errorf("find data directory: %w", err) } path := filepath.Join(dir, socketFileName) err = checkSocketPath(path) if err != nil { - return err + return fmt.Errorf("check terminal socket path: %w", err) } var dialer net.Dialer diff --git a/tools.go b/tools.go index 191bb25..24e0fdb 100644 --- a/tools.go +++ b/tools.go @@ -419,7 +419,7 @@ func (b *Toolbox) zoomAgent(ctx context.Context, name string) (string, error) { subagent, err := b.subagents.Get(ctx, name) if err != nil { - return "", err + return "", fmt.Errorf("open worker log: %w", err) } return subagent.Show() diff --git a/tools_test.go b/tools_test.go index 3075cbf..c03e7fc 100644 --- a/tools_test.go +++ b/tools_test.go @@ -185,7 +185,7 @@ func TestZoomWithANameGivesTheWholeChatOfTheAgent(t *testing.T) { output := box.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"`+subagent.name+`"}`) require.Equal(t, "0|note: subagent\n\n1|user: Count the files.", output) - require.Equal(t, "error: no agent BlueFox", box.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"BlueFox"}`)) + require.Equal(t, "error: open worker log: no agent BlueFox", box.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"BlueFox"}`)) require.Equal(t, "error: line 0+1 goes past the last message, -1", box.run(t.Context(), "zoom", `{"id":0,"n":1,"name":null}`)) } diff --git a/whatsapp.go b/whatsapp.go index df43e8e..27dd3c3 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -197,7 +197,7 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName)) if err != nil { - return nil, err + return nil, fmt.Errorf("open WhatsApp state: %w", err) } w.inbox = newWhatsAppInbox(w.state, clock, w.groups, &ImageStore{dir: filepath.Join(dir, imageDirName)}, logger) @@ -217,7 +217,7 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge err = w.openSession(ctx) if err != nil { - return nil, err + return nil, fmt.Errorf("open WhatsApp session: %w", err) } if w.device.ID != nil { @@ -239,7 +239,7 @@ func (w *WhatsApp) sessionPath() string { func (w *WhatsApp) openSession(ctx context.Context) error { address, err := whatsappSessionAddress(w.sessionPath()) if err != nil { - return err + return fmt.Errorf("find WhatsApp session address: %w", err) } container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop) @@ -323,14 +323,18 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err for { err := w.inbox.deliver(ctx, l, w.link(), false) if err != nil { - return err + return fmt.Errorf("deliver WhatsApp inbox: %w", err) } select { case <-ctx.Done(): return nil case err = <-w.inbox.failures: - return err + if err != nil { + return fmt.Errorf("receive WhatsApp inbox: %w", err) + } + + return nil case <-w.inbox.wake: case <-w.inbox.due(): case <-retry: @@ -340,7 +344,7 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err err = w.loggedOut(ctx, l) if err != nil { - return err + return fmt.Errorf("handle WhatsApp logout: %w", err) } case request := <-w.requests: cancel() @@ -355,7 +359,7 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err err = w.unlink(ctx, l, request.answer) if err != nil { - return err + return fmt.Errorf("unlink WhatsApp: %w", err) } case w.isLinked(): request.answer("WhatsApp is linked already. Run /whatsapp unlink first.") @@ -885,7 +889,7 @@ func (w *WhatsApp) newDevice(ctx context.Context) (*store.Device, error) { err = w.openSession(ctx) if err != nil { - return nil, err + return nil, fmt.Errorf("open new WhatsApp session: %w", err) } } diff --git a/whatsapp_inbox.go b/whatsapp_inbox.go index 5010a74..8bf8a97 100644 --- a/whatsapp_inbox.go +++ b/whatsapp_inbox.go @@ -129,7 +129,7 @@ func (i *WhatsAppInbox) deliver(ctx context.Context, l *TurnLoop, link WhatsAppL } if err != nil { - return err + return fmt.Errorf("close due WhatsApp batches: %w", err) } batches := i.state.Closed() @@ -183,7 +183,7 @@ func (i *WhatsAppInbox) deliver(ctx context.Context, l *TurnLoop, link WhatsAppL err = i.state.Remove(batch.Number) if err != nil { - return err + return fmt.Errorf("remove delivered WhatsApp batch: %w", err) } if batch.Turn() { diff --git a/whatsapp_send.go b/whatsapp_send.go index b3bbe95..dbbf862 100644 --- a/whatsapp_send.go +++ b/whatsapp_send.go @@ -51,7 +51,7 @@ func whatsappSend(ctx context.Context, link WhatsAppLink, groups *WhatsAppGroups jid, err := whatsappSendCheck(id, chat, text, image, link.own) if err != nil { - return "", err + return "", fmt.Errorf("check WhatsApp send: %w", err) } digest := sha256.Sum256([]byte(text)) @@ -88,7 +88,7 @@ func whatsappSend(ctx context.Context, link WhatsAppLink, groups *WhatsAppGroups record.MessageID = link.client.GenerateMessageID() err = state.PutSend(record) if err != nil { - return "", err + return "", fmt.Errorf("save pending WhatsApp send: %w", err) } message := &waE2E.Message{Conversation: new(text)} @@ -128,7 +128,7 @@ func whatsappSend(ctx context.Context, link WhatsAppLink, groups *WhatsAppGroups err = state.PutSend(record) if err != nil { - return "", err + return "", fmt.Errorf("save completed WhatsApp send: %w", err) } return record.Answer, nil diff --git a/whatsapp_send_test.go b/whatsapp_send_test.go index df82e3c..977d06b 100644 --- a/whatsapp_send_test.go +++ b/whatsapp_send_test.go @@ -190,7 +190,7 @@ func TestWhatsAppSendRefuses(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { _, err := whatsapp.Send(t.Context(), tc.id, tc.chat, tc.text, "") - require.EqualError(t, err, tc.err) + require.EqualError(t, err, "check WhatsApp send: "+tc.err) }) }