X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/d8f3785bb69288663f4cc46bf4d609c6770452d5..d7bc0a6ac452bfc646610fdfd7c8eccb5c55218f:/daemon_message.go diff --git a/daemon_message.go b/daemon_message.go index 7665678..f37a88f 100644 --- a/daemon_message.go +++ b/daemon_message.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -13,10 +14,10 @@ import ( ) // DaemonMessages owns the durable queue between the root and its children and -// the routed inputs. The mutex serializes queue writes and delivery. Backups -// take it before the locks of the loops, memory, and subagents. A stored log ID -// makes delivery idempotent, including a stop between the log append and the -// wake. +// the routed inputs and root notifications. The mutex serializes queue writes +// and delivery. Backups take it before the locks of the loops, memory, and +// subagents. A stored log ID makes delivery idempotent, including a stop +// between the log append and the wake. type DaemonMessages struct { mutex sync.Mutex path string @@ -29,16 +30,20 @@ type DaemonMessages struct { // DaemonMessageFile holds the sequence, unacknowledged deliveries, and receipts // for WhatsApp batches until the inbox removes them. A receipt stays when its -// turn finishes before inbox removal. +// turn finishes before inbox removal. Notification receipts stay after their +// turn finishes and do not expire in the local test interface. type DaemonMessageFile struct { Next int `json:"next"` Pending []DaemonMessage `json:"pending"` WhatsAppBatches []int `json:"whatsapp_batches,omitempty"` + Notifications []NotifyReceipt `json:"notifications,omitempty"` } // DaemonMessage is one queued delivery. LogID is set before the log append. A // nil LogID means that delivery has not started. WhatsAppBatch marks a routed // external batch, whose event source is WhatsApp rather than its queue sender. +// Notification marks root external data held outside memory until a turn +// starts. type DaemonMessage struct { ID int `json:"id"` Source string `json:"source"` @@ -47,12 +52,15 @@ type DaemonMessage struct { Text string `json:"text"` LogID *int `json:"log_id,omitempty"` WhatsAppBatch int `json:"whatsapp_batch,omitempty"` + Notification bool `json:"notification,omitempty"` } // openDaemonMessages connects the root and its children. It restores queued -// messages before the loops start. A queue entry stays until its recipient -// finishes the turn that takes it, so a stop can interrupt delivery or a turn. -// A routed batch with a removed child goes to the root. +// messages before the loops start. External events without a saved log ID stay +// unlogged until their turn boundary. Prepared log IDs are restored before +// startup writers can use them, without waking a turn. A queue entry stays +// until its recipient finishes the turn that takes it, so a stop can interrupt +// delivery or a turn. A routed batch with a removed child goes to the root. func openDaemonMessages(loops []*TurnLoop) (*DaemonMessages, error) { routes, err := openRoutes(filepath.Join(loops[0].agent.dir, "routes.json"), loops[0].logger) if err != nil { @@ -111,6 +119,23 @@ func openDaemonMessages(loops []*TurnLoop) (*DaemonMessages, error) { } } + if len(m.state.Notifications) > notifyReceipts { + return nil, errors.New("too many notification receipts") + } + + receipts := make(map[int]bool) + keys := make(map[[2]string]bool) + for _, receipt := range m.state.Notifications { + key := [2]string{receipt.Source, receipt.ID} + digest, err := hex.DecodeString(receipt.Digest) + if err != nil || len(digest) != 32 || receipt.Number <= 0 || receipt.Number > m.state.Next || receipts[receipt.Number] || keys[key] || !notifyIdentifier(receipt.Source) || !notifyIdentifier(receipt.ID) { + return nil, errors.New("invalid notification receipt") + } + + receipts[receipt.Number] = true + keys[key] = true + } + previous := 0 for i := range m.state.Pending { message := &m.state.Pending[i] @@ -126,18 +151,47 @@ func openDaemonMessages(loops []*TurnLoop) (*DaemonMessages, error) { return nil, errors.New("invalid queued agent message") } - if message.WhatsAppBatch != 0 { + switch { + case message.Notification: + if source != loops[0] || target != source || message.Kind != "work" || message.WhatsAppBatch != 0 || !receipts[message.ID] { + return nil, errors.New("invalid queued notification") + } + case message.WhatsAppBatch != 0: if message.WhatsAppBatch < 0 || message.Kind != "work" || source != loops[0] || (target != source && target.agent.config.Parent != message.Source) { return nil, errors.New("invalid queued WhatsApp batch") } - } else if (message.Kind != "user" || target.agent.config.Parent != message.Source) && (message.Kind != "work" || source.agent.config.Parent != message.Target) { + case (message.Kind != "user" || target.agent.config.Parent != message.Source) && (message.Kind != "work" || source.agent.config.Parent != message.Target): return nil, errors.New("queued agents are not parent and child") } previous = message.ID } + // Complete prepared log reservations before another restored delivery + // or startup writer can use their IDs. They belong to an interrupted + // turn, not newly accepted data. Only the next turn boundary wakes + // them. for i := range m.state.Pending { + message := &m.state.Pending[i] + if !message.Notification || message.LogID == nil { + continue + } + + loop := m.loops[message.Target] + _, err = loop.memory.AppendOnce(message.Kind, message.Text, message.LogID, func(id int) error { + message.LogID = &id + return m.save() + }) + if err != nil { + return nil, fmt.Errorf("restore external event reservation: %w", err) + } + } + + for i := range m.state.Pending { + if m.state.Pending[i].Notification { + continue + } + err = m.deliver(&m.state.Pending[i]) if err != nil { return nil, fmt.Errorf("restore agent message: %w", err) @@ -193,7 +247,7 @@ func (m *DaemonMessages) save() error { return fmt.Errorf("encode agent messages: %w", err) } - err = writeFileAtomic(m.path, append(data, '\n')) + err = writeFileDurable(m.path, append(data, '\n')) if err != nil { return fmt.Errorf("save agent messages: %w", err) } @@ -466,3 +520,129 @@ func (m *DaemonMessages) Finish(loop *TurnLoop, active []Message, reply string) return nil } + +// Notify accepts root external data without adding it to memory or an active +// chain. A receipt is durable before success. Exact retries return the same +// sequence even after completion. New keys fail when the receipt store is full. +func (m *DaemonMessages) Notify(input *NotifyInput) (Event, error) { + m.mutex.Lock() + defer m.mutex.Unlock() + + if m.failure != nil { + return Event{}, fmt.Errorf("notification queue stopped: %w", m.failure) + } + + if input == nil { + return Event{}, errors.New("missing notification data") + } + + err := notifyValidate(*input) + if err != nil { + return Event{}, fmt.Errorf("validate external event: %w", err) + } + + digest := notifyDigest(input.Text) + for _, receipt := range m.state.Notifications { + if receipt.Source != input.Source || receipt.ID != input.ID { + continue + } + + if receipt.Digest != digest { + return Event{}, errors.New("notification key conflicts with accepted text") + } + + return Event{ + Kind: "notify", + ID: receipt.Number, + Status: "duplicate", + }, nil + } + + if len(m.state.Notifications) >= notifyReceipts { + return Event{}, errors.New("notification receipt store is full") + } + + root := m.ordered[0] + text, err := notifyText(*input, root.clock()) + if err != nil { + return Event{}, fmt.Errorf("prepare external event: %w", err) + } + + name := root.agent.config.Name + m.add(name, name, "work", text) + + m.state.Pending[len(m.state.Pending)-1].Notification = true + m.state.Notifications = append(m.state.Notifications, NotifyReceipt{ + Source: input.Source, + ID: input.ID, + Digest: digest, + Number: m.state.Next, + }) + + err = m.save() + if err != nil { + return Event{}, fmt.Errorf("accept external event: %w", m.failed(err)) + } + + select { + case root.wake <- struct{}{}: + default: + } + + root.logger.Info("event accepted", "id", m.state.Next) + + return Event{ + Kind: "notify", + ID: m.state.Next, + Status: "accepted", + }, nil +} + +// Notifications delivers at most twenty queued events to a root turn. It stops +// before 64 KiB of envelope text, except that the first event always fits. Call +// only between chains, never from take. Events stay queued until Finish, so +// daemon shutdown can repeat model calls and tool effects, but AppendOnce does +// not create duplicate delivery log entries. +func (m *DaemonMessages) Notifications(loop *TurnLoop) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + if m.failure != nil { + return fmt.Errorf("external event queue stopped: %w", m.failure) + } + + count := 0 + size := 0 + for i := range m.state.Pending { + message := &m.state.Pending[i] + if !message.Notification || message.Target != loop.agent.config.Name { + continue + } + + if count == 20 || (count > 0 && size+len(message.Text) > 64<<10) { + break + } + + logged, err := loop.memory.AppendOnce(message.Kind, message.Text, message.LogID, func(id int) error { + message.LogID = &id + return m.save() + }) + if err != nil { + return fmt.Errorf("deliver external event: %w", m.failed(err)) + } + + loop.notify(Event{ + Kind: "work", + ID: logged.ID, + Text: logged.Text, + Source: notifySource, + }) + + loop.Wake(logged) + + count++ + size += len(message.Text) + } + + return nil +}