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
17 files changed:
tokens, err := a.read()
if err != nil {
tokens, err := a.read()
if err != nil {
+ 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 {
}
expiry, found := chatgptTokenExpiry(tokens.AccessToken)
if !found || !time.Now().Before(expiry.Add(-chatgptRefreshMargin)) {
tokens, err = a.refresh(ctx, tokens)
if err != nil {
+ return fmt.Errorf("refresh login tokens: %w", err)
err = a.write(tokens)
if err != nil {
err = a.write(tokens)
if err != nil {
- return ChatGPTTokenFile{}, err
+ return ChatGPTTokenFile{}, fmt.Errorf("save refreshed tokens: %w", err)
}
_, err = a.post(ctx, path, "application/json", data, result)
}
_, err = a.post(ctx, path, "application/json", data, result)
+ if err != nil {
+ return fmt.Errorf("post JSON request: %w", err)
+ }
}
// write replaces the token file atomically. The file has mode 0600.
}
// write replaces the token file atomically. The file has mode 0600.
grant, err := a.poll(ctx, code.DeviceAuthID, userCode, chatgptPollInterval(code.Interval))
if err != nil {
grant, err := a.poll(ctx, code.DeviceAuthID, userCode, chatgptPollInterval(code.Interval))
if err != nil {
+ return fmt.Errorf("wait for device grant: %w", err)
}
tokens, err := a.exchange(ctx, grant)
if err != nil {
}
tokens, err := a.exchange(ctx, grant)
if err != nil {
+ return fmt.Errorf("exchange device grant: %w", err)
}
err = a.write(tokens)
if err != nil {
}
err = a.write(tokens)
if err != nil {
+ return fmt.Errorf("save login tokens: %w", err)
}
_, err = fmt.Fprintln(out, "Logged in.")
}
_, err = fmt.Fprintln(out, "Logged in.")
func chatgptLogin(ctx context.Context, out io.Writer) (err error) {
dir, err := dataDir()
if err != nil {
func chatgptLogin(ctx context.Context, out io.Writer) (err error) {
dir, err := dataDir()
if err != nil {
+ return fmt.Errorf("find data directory: %w", err)
}
tokens, err := lockTokens(dir)
if err != nil {
}
tokens, err := lockTokens(dir)
if err != nil {
+ return fmt.Errorf("lock login tokens: %w", err)
case len(args) == 1 && args[0] == "login":
err := chatgptLogin(ctx, os.Stdout)
if err != nil {
case len(args) == 1 && args[0] == "login":
err := chatgptLogin(ctx, os.Stdout)
if err != nil {
+ return fmt.Errorf("log in: %w", err)
}
// A signal is the normal stop of the daemon and the client, so their
}
// A signal is the normal stop of the daemon and the client, so their
case len(args) > 0 && args[0] == "probe":
err := probe(ctx, args[1:], os.Stdout)
if err != nil {
case len(args) > 0 && args[0] == "probe":
err := probe(ctx, args[1:], os.Stdout)
if err != nil {
+ return fmt.Errorf("run probe: %w", err)
}
default:
return fmt.Errorf("unknown command %q\n\n%s", strings.Join(args, " "), strings.TrimSuffix(usage, "\n"))
}
default:
return fmt.Errorf("unknown command %q\n\n%s", strings.Join(args, " "), strings.TrimSuffix(usage, "\n"))
}
_, err := l.Submit(terminalSource, request.Text)
}
_, err := l.Submit(terminalSource, request.Text)
+ if err != nil {
+ return fmt.Errorf("submit terminal message: %w", err)
+ }
case "image":
if !filepath.IsAbs(request.Path) {
return errors.New("use /image PATH [caption]")
case "image":
if !filepath.IsAbs(request.Path) {
return errors.New("use /image PATH [caption]")
err = fmt.Errorf("no subagent %s runs", request.Text)
}
err = fmt.Errorf("no subagent %s runs", request.Text)
}
+ if err != nil {
+ return fmt.Errorf("stop subagent: %w", err)
+ }
+
+ return nil
default:
return fmt.Errorf("unknown request %q", request.Type)
}
default:
return fmt.Errorf("unknown request %q", request.Type)
}
func daemon(ctx context.Context, out io.Writer) (err error) {
dir, err := dataDir()
if err != nil {
func daemon(ctx context.Context, out io.Writer) (err error) {
dir, err := dataDir()
if err != nil {
+ return fmt.Errorf("find data directory: %w", err)
}
// Check the socket path before the daemon writes a file, so that a data
}
// Check the socket path before the daemon writes a file, so that a data
socket := filepath.Join(dir, socketFileName)
err = checkSocketPath(socket)
if err != nil {
socket := filepath.Join(dir, socketFileName)
err = checkSocketPath(socket)
if err != nil {
+ return fmt.Errorf("check daemon socket path: %w", err)
}
config, err := loadConfig(dir)
if err != nil {
}
config, err := loadConfig(dir)
if err != nil {
+ return fmt.Errorf("load daemon config: %w", err)
}
botSettings, hasBot, err := loadTelegramConfig(dir)
if err != nil {
}
botSettings, hasBot, err := loadTelegramConfig(dir)
if err != nil {
+ return fmt.Errorf("load Telegram config: %w", err)
}
tokens, err := lockTokens(dir)
if err != nil {
}
tokens, err := lockTokens(dir)
if err != nil {
+ return fmt.Errorf("lock login tokens: %w", err)
logger := daemonLogger(out, "onechat: ")
whatsapp, err := openWhatsApp(ctx, dir, time.Now, daemonLogger(out, "whatsapp: "), whatsappNewClient)
if err != nil {
logger := daemonLogger(out, "onechat: ")
whatsapp, err := openWhatsApp(ctx, dir, time.Now, daemonLogger(out, "whatsapp: "), whatsappNewClient)
if err != nil {
+ return fmt.Errorf("open WhatsApp: %w", err)
address, auth, err := modelEndpoint(config, dir)
if err != nil {
address, auth, err := modelEndpoint(config, dir)
if err != nil {
+ return fmt.Errorf("configure model endpoint: %w", err)
}
agents, err := openDaemonAgents(ctx, dir, address, auth, config.Compactor, time.Now, logger)
if err != nil {
}
agents, err := openDaemonAgents(ctx, dir, address, auth, config.Compactor, time.Now, logger)
if err != nil {
+ return fmt.Errorf("open daemon agents: %w", err)
listener, err := daemonListenSocket(socket)
if err != nil {
listener, err := daemonListenSocket(socket)
if err != nil {
+ return fmt.Errorf("listen for daemon clients: %w", err)
func daemonListenSocket(path string) (net.Listener, error) {
err := checkSocketPath(path)
if err != nil {
func daemonListenSocket(path string) (net.Listener, error) {
err := checkSocketPath(path)
if err != nil {
+ return nil, fmt.Errorf("check daemon socket path: %w", err)
first.Type(stopCommand)
first.WaitFor(t, "[error] no turn runs")
first.Type(stopCommand + " BlueFox")
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())
}
require.NotContains(t, second.Output(), "no turn runs")
require.NoError(t, first.Close())
}
err = s.Done(schedule, now)
if err != nil {
err = s.Done(schedule, now)
if err != nil {
+ return fmt.Errorf("acknowledge schedule run: %w", err)
}
attrs := []any{"id", schedule.ID}
}
attrs := []any{"id", schedule.ID}
default:
parsed, err := scheduleParseCron(*cron)
if err != nil {
default:
parsed, err := scheduleParseCron(*cron)
if err != nil {
+ return "", fmt.Errorf("parse schedule expression: %w", err)
}
next, found := parsed.Next(now)
}
next, found := parsed.Next(now)
s.file.Schedules = append(s.file.Schedules, schedule)
err := s.save()
if err != nil {
s.file.Schedules = append(s.file.Schedules, schedule)
err := s.save()
if err != nil {
+ return "", fmt.Errorf("save new schedule: %w", err)
s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
err := s.save()
if err != nil {
s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
err := s.save()
if err != nil {
+ return "", fmt.Errorf("save removed schedule: %w", err)
first, err = scheduleCronNumber(base, low, high)
if err != nil {
first, err = scheduleCronNumber(base, low, high)
if err != nil {
+ return 0, fmt.Errorf("parse cron field value: %w", err)
{
name: "rejects a day name",
expression: "0 8 * * MON",
{
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",
},
{
name: "rejects a month name",
view, err := r.layout.Split(r.memory.ViewLines(nil))
if err != nil {
view, err := r.layout.Split(r.memory.ViewLines(nil))
if err != nil {
+ return "", fmt.Errorf("split worker view: %w", err)
}
names := make([]string, 0, len(tasks))
}
names := make([]string, 0, len(tasks))
+ return "", fmt.Errorf("create worker: %w", err)
}
names = append(names, name)
}
names = append(names, name)
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 {
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 "", 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 {
}
for _, message := range [][2]string{{"note", note}, {"user", task}} {
_, err = subagent.Append(message[0], message[1])
if err != nil {
+ return "", fmt.Errorf("append worker message: %w", err)
}
}
err = subagent.Flush(ctx)
if err != nil {
}
}
err = subagent.Flush(ctx)
if err != nil {
+ return "", fmt.Errorf("flush worker log: %w", err)
}
err = os.MkdirAll(subagent.WorkDir(), 0o700)
}
err = os.MkdirAll(subagent.WorkDir(), 0o700)
r.mutex.Unlock()
run.cancel()
r.mutex.Unlock()
run.cancel()
+ if err != nil {
+ return fmt.Errorf("save running workers: %w", err)
+ }
+
+ return nil
func (r *SubagentRuns) chain(ctx context.Context, run *SubagentRun) (string, error) {
instructions, err := r.agent.SubagentPrompt()
if err != nil {
func (r *SubagentRuns) chain(ctx context.Context, run *SubagentRun) (string, error) {
instructions, err := r.agent.SubagentPrompt()
if err != nil {
+ return "", fmt.Errorf("read worker instructions: %w", err)
}
log, err := run.subagent.Show()
if err != nil {
}
log, err := run.subagent.Show()
if err != nil {
+ return "", fmt.Errorf("read worker log: %w", err)
}
input := make([]any, 0, len(run.view)+2)
}
input := make([]any, 0, len(run.view)+2)
func (r *SubagentRuns) Resume(ctx context.Context) error {
names, err := r.load()
if err != nil || len(names) == 0 {
func (r *SubagentRuns) Resume(ctx context.Context) error {
names, err := r.load()
if err != nil || len(names) == 0 {
+ 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 {
}
text := "The daemon restarted, cutting " + strings.Join(names, ", ") + "; nothing was lost: go on."
err = r.note(text)
if err != nil {
+ return fmt.Errorf("record interrupted workers: %w", err)
}
for _, name := range names {
}
for _, name := range names {
told, err := run.subagent.Append("user", message)
if err != nil {
told, err := run.subagent.Append("user", message)
if err != nil {
+ return "", fmt.Errorf("append running worker message: %w", err)
}
run.pending = append(run.pending, told)
}
run.pending = append(run.pending, told)
subagent, err := r.subagents.Get(ctx, name)
if err != nil {
subagent, err := r.subagents.Get(ctx, name)
if err != nil {
+ return "", fmt.Errorf("open worker log: %w", err)
}
effort, err := r.effort(subagent)
if err != nil {
}
effort, err := r.effort(subagent)
if err != nil {
+ return "", fmt.Errorf("read worker effort: %w", err)
}
err = r.memory.Settle(ctx)
}
err = r.memory.Settle(ctx)
view, err := r.layout.Split(r.memory.ViewLines(nil))
if err != nil {
view, err := r.layout.Split(r.memory.ViewLines(nil))
if err != nil {
+ return "", fmt.Errorf("split worker view: %w", err)
}
_, err = subagent.Append("user", message)
}
_, err = subagent.Append("user", message)
+ return "", fmt.Errorf("save worker message: %w", err)
}
err = r.start(ctx, &SubagentRun{
}
err = r.start(ctx, &SubagentRun{
effort: effort,
})
if err != nil {
effort: effort,
})
if err != nil {
+ return "", fmt.Errorf("start told worker: %w", err)
}
return "Sent. " + name + "'s reply will reach you as a message.", nil
}
return "Sent. " + name + "'s reply will reach you as a message.", nil
func (r *SubagentRuns) effort(subagent *Subagent) (string, error) {
note, err := subagent.First()
if err != nil {
func (r *SubagentRuns) effort(subagent *Subagent) (string, error) {
note, err := subagent.First()
if err != nil {
+ return "", fmt.Errorf("read worker note: %w", err)
}
effort, own := strings.CutPrefix(note.Text, subagentNote+subagentNoteEffort)
}
effort, own := strings.CutPrefix(note.Text, subagentNote+subagentNoteEffort)
require.Equal(t, "work: "+subagentReport(name, []string{"Reply 2."}), test.Log(t)[2])
_, err = runs.Tell(t.Context(), "BlueFox", "Hello.")
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) {
}
func TestStopEndsASubagentAndItsCommand(t *testing.T) {
events.WaitFor(t, "idle", 1)
require.Equal(t, []string{
"note: The daemon restarted, cutting BlueFox; nothing was lost: go on.",
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)
"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)
data, err := os.ReadFile(filepath.Join(agent.dir, subagentWorkFileName))
require.NoError(t, err)
func (b *TelegramBot) poll(ctx context.Context, l *TurnLoop, whatsapp *WhatsApp) error {
offset, err := telegramLoadOffset(b.offsetPath)
if err != nil {
func (b *TelegramBot) poll(ctx context.Context, l *TurnLoop, whatsapp *WhatsApp) error {
offset, err := telegramLoadOffset(b.offsetPath)
if err != nil {
+ return fmt.Errorf("load Telegram offset: %w", err)
}
outage := &TelegramOutage{
}
outage := &TelegramOutage{
+ if err != nil {
+ return fmt.Errorf("handle Telegram update: %w", err)
+ }
+
+ return nil
offset = update.UpdateID + 1
err = telegramSaveOffset(b.offsetPath, offset)
if err != nil {
offset = update.UpdateID + 1
err = telegramSaveOffset(b.offsetPath, offset)
if err != nil {
+ return fmt.Errorf("save Telegram offset: %w", err)
default:
_, err := l.Submit(telegramSource, message.Text)
if err != nil {
default:
_, err := l.Submit(telegramSource, message.Text)
if err != nil {
+ return fmt.Errorf("submit Telegram text: %w", err)
}
err = l.Flush(context.WithoutCancel(ctx))
}
err = l.Flush(context.WithoutCancel(ctx))
func terminal(ctx context.Context, in io.Reader, out io.Writer) error {
dir, err := dataDir()
if err != nil {
func terminal(ctx context.Context, in io.Reader, out io.Writer) error {
dir, err := dataDir()
if err != nil {
+ return fmt.Errorf("find data directory: %w", err)
}
path := filepath.Join(dir, socketFileName)
err = checkSocketPath(path)
if err != nil {
}
path := filepath.Join(dir, socketFileName)
err = checkSocketPath(path)
if err != nil {
+ return fmt.Errorf("check terminal socket path: %w", err)
subagent, err := b.subagents.Get(ctx, name)
if err != nil {
subagent, err := b.subagents.Get(ctx, name)
if err != nil {
+ return "", fmt.Errorf("open worker log: %w", err)
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)
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}`))
}
require.Equal(t, "error: line 0+1 goes past the last message, -1", box.run(t.Context(), "zoom", `{"id":0,"n":1,"name":null}`))
}
w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName))
if err != nil {
w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName))
if err != nil {
+ return nil, fmt.Errorf("open WhatsApp state: %w", err)
}
w.inbox = newWhatsAppInbox(w.state, clock, w.groups, &ImageStore{dir: filepath.Join(dir, imageDirName)}, logger)
}
w.inbox = newWhatsAppInbox(w.state, clock, w.groups, &ImageStore{dir: filepath.Join(dir, imageDirName)}, logger)
err = w.openSession(ctx)
if err != nil {
err = w.openSession(ctx)
if err != nil {
+ return nil, fmt.Errorf("open WhatsApp session: %w", err)
}
if w.device.ID != nil {
}
if w.device.ID != nil {
func (w *WhatsApp) openSession(ctx context.Context) error {
address, err := whatsappSessionAddress(w.sessionPath())
if err != nil {
func (w *WhatsApp) openSession(ctx context.Context) error {
address, err := whatsappSessionAddress(w.sessionPath())
if err != nil {
+ return fmt.Errorf("find WhatsApp session address: %w", err)
}
container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop)
}
container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop)
for {
err := w.inbox.deliver(ctx, l, w.link(), false)
if err != nil {
for {
err := w.inbox.deliver(ctx, l, w.link(), false)
if err != nil {
+ return fmt.Errorf("deliver WhatsApp inbox: %w", err)
}
select {
case <-ctx.Done():
return nil
case err = <-w.inbox.failures:
}
select {
case <-ctx.Done():
return nil
case err = <-w.inbox.failures:
+ if err != nil {
+ return fmt.Errorf("receive WhatsApp inbox: %w", err)
+ }
+
+ return nil
case <-w.inbox.wake:
case <-w.inbox.due():
case <-retry:
case <-w.inbox.wake:
case <-w.inbox.due():
case <-retry:
err = w.loggedOut(ctx, l)
if err != nil {
err = w.loggedOut(ctx, l)
if err != nil {
+ return fmt.Errorf("handle WhatsApp logout: %w", err)
}
case request := <-w.requests:
cancel()
}
case request := <-w.requests:
cancel()
err = w.unlink(ctx, l, request.answer)
if err != nil {
err = w.unlink(ctx, l, request.answer)
if err != nil {
+ return fmt.Errorf("unlink WhatsApp: %w", err)
}
case w.isLinked():
request.answer("WhatsApp is linked already. Run /whatsapp unlink first.")
}
case w.isLinked():
request.answer("WhatsApp is linked already. Run /whatsapp unlink first.")
err = w.openSession(ctx)
if err != nil {
err = w.openSession(ctx)
if err != nil {
+ return nil, fmt.Errorf("open new WhatsApp session: %w", err)
+ return fmt.Errorf("close due WhatsApp batches: %w", err)
}
batches := i.state.Closed()
}
batches := i.state.Closed()
err = i.state.Remove(batch.Number)
if err != nil {
err = i.state.Remove(batch.Number)
if err != nil {
+ return fmt.Errorf("remove delivered WhatsApp batch: %w", err)
jid, err := whatsappSendCheck(id, chat, text, image, link.own)
if err != nil {
jid, err := whatsappSendCheck(id, chat, text, image, link.own)
if err != nil {
+ return "", fmt.Errorf("check WhatsApp send: %w", err)
}
digest := sha256.Sum256([]byte(text))
}
digest := sha256.Sum256([]byte(text))
record.MessageID = link.client.GenerateMessageID()
err = state.PutSend(record)
if err != nil {
record.MessageID = link.client.GenerateMessageID()
err = state.PutSend(record)
if err != nil {
+ return "", fmt.Errorf("save pending WhatsApp send: %w", err)
}
message := &waE2E.Message{Conversation: new(text)}
}
message := &waE2E.Message{Conversation: new(text)}
err = state.PutSend(record)
if err != nil {
err = state.PutSend(record)
if err != nil {
+ return "", fmt.Errorf("save completed WhatsApp send: %w", err)
}
return record.Answer, nil
}
return record.Answer, nil
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
_, err := whatsapp.Send(t.Context(), tc.id, tc.chat, tc.text, "")
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)