Fixes B1 and B2 of plans/2026-07-11-nomos-agent-code-review.md together, since the right granularity for B1 in the auto-continuation worker turned out to require B2's restructuring anyway (see below). B1: grep -rn "recover()" cmd/nomos/ internal/mcp/ internal/httpapi/ returned nothing before this — every explicitly-spawned goroutine (continuation worker, resumed chat turns, async execution dispatch, the SSE listener, two duplicate sshExec implementations' output-collector goroutines) crashed the whole process on an unhandled panic, not just that one goroutine. More consequential post-concurrency: more simultaneous unattended background work means more surface area for one bad input to end every running task. New internal/safego package: Go(label, fn) launches fn in a goroutine with a recover-and-log wrapper. Applied at every bare `go` spawn site across the three packages. Two sites needed bespoke handling instead of the generic helper because their callers block on a channel and a silent recover would just make them hang until timeout: sshExec's output-collector goroutine (two near-identical copies, internal/mcp/server.go and internal/httpapi/phase3.go) and httpapi's ListenAndServe goroutine — both now recover AND send a synthetic error result so the waiting select unblocks immediately instead of waiting out the full timeout. httpapi's sseListener got extra treatment: its per-notification handling was extracted into handleNotification with its own recover, so a panic decoding ONE malformed pg_notify payload can't kill the listener goroutine for every connected SSE client — the outer goroutine spawn only needs to guard the connection setup/reconnect code around it. B2: cmd/nomos/continue.go's processContinuations used to run every pending continuation SEQUENTIALLY in a plain for loop, in the SAME goroutine as the ticker — meaning (a) task B's continuation waited for task A's full (up to 10-minute) resumed turn to finish first, undercutting this session's earlier concurrency work on exactly the path autonomous tasks depend on most, and (b) an unrecovered panic anywhere in that call chain didn't just crash the process (B1) — even WITH B1's recovery wrapped only at the top-level worker spawn, the panic would still unwind the ENTIRE ticker-loop goroutine, silently ending auto-continuation for every task until nomos restarted. Fixed by spawning each pending item via safego.Go individually: real parallelism, and a bad item can now only ever take down its own goroutine. Added internal/safego/safego_test.go: TestGo_RecoversPanic is the concrete proof — a deliberate panic inside Go() that would otherwise crash the whole test binary; reaching the assertion after it IS the evidence recovery works. Verified live against the rebuilt containers: full chat turn round-tripped correctly (hostname lookup, 2 iterations, normal completion) — no regression from threading safego.Go through the tool-dispatch/continuation paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
367 lines
11 KiB
Go
367 lines
11 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"container/list"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// SSE broker is a keep-last-event-id in-memory buffer used for subscriber
|
|
// fan-out. The database NOTIFY is the primary delivery mechanism; this buffer
|
|
// just supports the Last-Event-ID replay on connect.
|
|
type sseBroker struct {
|
|
mu sync.Mutex
|
|
buf *list.List // list of sqlcgen.Event
|
|
cache map[int64]*list.Element // id → list element for O(1) lookup
|
|
cap int
|
|
lastID int64
|
|
}
|
|
|
|
func newSSEBroker(capacity int) *sseBroker {
|
|
return &sseBroker{
|
|
buf: list.New(),
|
|
cache: make(map[int64]*list.Element),
|
|
cap: capacity,
|
|
}
|
|
}
|
|
|
|
func (b *sseBroker) push(ev sqlcgen.Event) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
// Evict oldest if at capacity
|
|
for b.buf.Len() >= b.cap && b.buf.Len() > 0 {
|
|
front := b.buf.Front()
|
|
b.cache[front.Value.(sqlcgen.Event).ID] = nil // don't delete, just nil
|
|
b.buf.Remove(front)
|
|
}
|
|
|
|
elem := b.buf.PushBack(ev)
|
|
b.cache[ev.ID] = elem
|
|
if ev.ID > b.lastID {
|
|
b.lastID = ev.ID
|
|
}
|
|
}
|
|
|
|
func (b *sseBroker) after(id int64) []sqlcgen.Event {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if id >= b.lastID {
|
|
return nil
|
|
}
|
|
|
|
// Walk from the front to find the first element after id
|
|
var events []sqlcgen.Event
|
|
for e := b.buf.Front(); e != nil; e = e.Next() {
|
|
ev := e.Value.(sqlcgen.Event)
|
|
if ev.ID > id {
|
|
events = append(events, ev)
|
|
}
|
|
}
|
|
return events
|
|
}
|
|
|
|
func (b *sseBroker) latestID() int64 {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.lastID
|
|
}
|
|
|
|
// notifyPayload is the JSON payload from the pg_notify trigger (migration 008).
|
|
type notifyPayload struct {
|
|
ID int64 `json:"id"`
|
|
Ts string `json:"ts"`
|
|
Type string `json:"type"`
|
|
EntityID *string `json:"entity_id"`
|
|
Severity string `json:"severity"`
|
|
Source string `json:"source"`
|
|
CorrelationID *string `json:"correlation_id"`
|
|
}
|
|
|
|
// sseSubscriber holds the channels and cancel func for one SSE client.
|
|
type sseSubscriber struct {
|
|
ch chan sqlcgen.Event
|
|
done chan struct{}
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// sseListener runs in a background goroutine: it opens a dedicated pgx
|
|
// connection, LISTENs on oikos_events, and fans out each notification to
|
|
// all live subscribers. Runs until ctx is cancelled.
|
|
func (s *Server) sseListener(ctx context.Context) {
|
|
poolConn, err := s.pool.Acquire(ctx)
|
|
if err != nil {
|
|
slog.Error("sse listener acquire failed", "error", err)
|
|
return
|
|
}
|
|
defer poolConn.Release()
|
|
|
|
conn := poolConn.Conn()
|
|
if _, err := conn.Exec(ctx, "LISTEN oikos_events"); err != nil {
|
|
slog.Error("sse listener listen failed", "error", err)
|
|
return
|
|
}
|
|
|
|
slog.Info("sse listener started on oikos_events")
|
|
defer slog.Info("sse listener stopped")
|
|
|
|
for {
|
|
nt, err := conn.WaitForNotification(ctx)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return // normal shutdown
|
|
}
|
|
slog.Error("sse listener notification error", "error", err)
|
|
// Reconnect on error after a brief delay
|
|
select {
|
|
case <-time.After(5 * time.Second):
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
// Re-acquire connection
|
|
poolConn.Release()
|
|
var reconnErr error
|
|
poolConn, reconnErr = s.pool.Acquire(ctx)
|
|
if reconnErr != nil {
|
|
slog.Error("sse listener reconnect failed", "error", reconnErr)
|
|
return
|
|
}
|
|
conn = poolConn.Conn()
|
|
if _, err := conn.Exec(ctx, "LISTEN oikos_events"); err != nil {
|
|
slog.Error("sse listener re-listen failed", "error", err)
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
|
|
s.handleNotification(ctx, nt.Payload)
|
|
}
|
|
}
|
|
|
|
// handleNotification processes one pg_notify payload: decode, fetch the full
|
|
// event, push to the broker, fan out to live subscribers. Split out of
|
|
// sseListener's loop specifically so it can be wrapped in its own recover —
|
|
// a panic while handling ONE notification (a malformed payload, an
|
|
// unexpected nil somewhere in the fan-out) must not kill the whole listener
|
|
// goroutine, which would silently stop the live event stream for every
|
|
// connected client until the api process is restarted.
|
|
func (s *Server) handleNotification(ctx context.Context, payload string) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
slog.Error("sse listener: panic recovered handling notification", "panic", r)
|
|
}
|
|
}()
|
|
|
|
var p notifyPayload
|
|
if err := json.Unmarshal([]byte(payload), &p); err != nil {
|
|
slog.Error("sse listener unmarshal failed", "error", err)
|
|
return
|
|
}
|
|
|
|
// Fetch full event from DB
|
|
q := sqlcgen.New(s.pool)
|
|
events, err := q.ListEventsAfter(ctx, sqlcgen.ListEventsAfterParams{
|
|
ID: p.ID - 1,
|
|
Limit: 1,
|
|
})
|
|
if err != nil || len(events) == 0 {
|
|
slog.Warn("sse listener event fetch failed", "id", p.ID, "error", err)
|
|
return
|
|
}
|
|
ev := events[0]
|
|
|
|
// Push to broker
|
|
s.sseBroker.push(ev)
|
|
|
|
// Fan out to subscribers (non-blocking send)
|
|
s.sseMu.Lock()
|
|
for sub := range s.sseSubs {
|
|
select {
|
|
case sub.ch <- ev:
|
|
default:
|
|
// Subscriber too slow — drop event for them
|
|
// (they'll reconnect via Last-Event-ID)
|
|
}
|
|
}
|
|
s.sseMu.Unlock()
|
|
}
|
|
|
|
// sqlcEventToGen converts a DB event row to the canonical wire shape so the
|
|
// SSE `data:` payload matches GET /events (snake_case keys, decoded data
|
|
// object) rather than leaking Go field names and base64-encoded JSONB.
|
|
func sqlcEventToGen(ev sqlcgen.Event) gen.Event {
|
|
out := gen.Event{
|
|
Id: int(ev.ID),
|
|
Ts: ev.Ts,
|
|
Type: ev.Type,
|
|
Severity: gen.EventSeverity(ev.Severity),
|
|
Source: ev.Source,
|
|
CorrelationId: ev.CorrelationID,
|
|
}
|
|
if ev.EntityID != nil {
|
|
s := ev.EntityID.String()
|
|
out.EntityId = &s
|
|
}
|
|
if len(ev.Data) > 0 {
|
|
var data map[string]any
|
|
if json.Unmarshal(ev.Data, &data) == nil && len(data) > 0 {
|
|
out.Data = &data
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// writeSSE writes a single Event as an SSE message. Returns false if the
|
|
// write failed (client disconnected). flusher may be nil (io.Pipe path,
|
|
// which has no separate flush step).
|
|
//
|
|
// We deliberately DO NOT set the SSE `event:` name field, even though every
|
|
// event has a type. A named SSE event is only delivered to a matching
|
|
// addEventListener(type) handler, NOT to EventSource.onmessage — and the whole
|
|
// frontend (stores/events.ts and every page that reads liveEvents) consumes the
|
|
// stream via onmessage, reading the type from the JSON payload's `type` field.
|
|
// Emitting `event: <type>` silently routed every event away from onmessage, so
|
|
// the live stream delivered nothing to the UI. Leaving the name off sends all
|
|
// events to onmessage; the type is already in `data`, and new event types need
|
|
// zero client changes. `id:` is kept for Last-Event-ID reconnection.
|
|
func writeSSE(w ioWriter, flusher http.Flusher, ev sqlcgen.Event) bool {
|
|
data, err := json.Marshal(sqlcEventToGen(ev))
|
|
if err != nil {
|
|
return true // skip un-serializable events
|
|
}
|
|
_, err = fmt.Fprintf(w, "id: %d\ndata: %s\n\n", ev.ID, data)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ioWriter is an interface satisfied by both http.ResponseWriter and
|
|
// io.StringWriter, letting writeSSE work with the writer directly.
|
|
type ioWriter interface {
|
|
Write([]byte) (int, error)
|
|
}
|
|
|
|
// serveSSE is the streaming handler for GET /api/v1/events/stream, registered
|
|
// directly on the chi router in NewHandler so it supersedes the generated
|
|
// route. Using the raw http.ResponseWriter lets us Flush() after every event
|
|
// (real-time delivery); the generated strict-server path can only hand back
|
|
// an io.Reader that io.Copy drains without flushing (chunk-buffered).
|
|
//
|
|
// Protocol: https://html.spec.whatwg.org/multipage/server-sent-events.html
|
|
// 1. If Last-Event-ID is present, replay buffered events (broker, then DB).
|
|
// 2. Subscribe and forward events fanned out from pg_notify.
|
|
// 3. Colon-comment heartbeat every 15s.
|
|
// 4. Unsubscribe on client disconnect (request context cancels).
|
|
func (s *Server) serveSSE(w http.ResponseWriter, r *http.Request) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
writeProblem(w, r, http.StatusInternalServerError, "internal error", "streaming not supported")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering
|
|
w.WriteHeader(http.StatusOK)
|
|
flusher.Flush()
|
|
|
|
ctx, cancel := context.WithCancel(r.Context())
|
|
defer cancel()
|
|
|
|
sub := &sseSubscriber{
|
|
ch: make(chan sqlcgen.Event, 64),
|
|
done: make(chan struct{}),
|
|
cancel: cancel,
|
|
}
|
|
s.sseMu.Lock()
|
|
s.sseSubs[sub] = struct{}{}
|
|
s.sseMu.Unlock()
|
|
defer func() {
|
|
s.sseMu.Lock()
|
|
delete(s.sseSubs, sub)
|
|
s.sseMu.Unlock()
|
|
close(sub.done)
|
|
}()
|
|
|
|
// ── 1. Replay on Last-Event-ID ────────────────────────────────
|
|
if lastID := r.Header.Get("Last-Event-ID"); lastID != "" {
|
|
if id, err := strconv.ParseInt(lastID, 10, 64); err == nil {
|
|
// The in-memory broker only holds recent events; if it doesn't
|
|
// cover the whole gap, fall back to the DB for a complete replay.
|
|
events := s.sseBroker.after(id)
|
|
complete := len(events) > 0 && events[len(events)-1].ID == s.sseBroker.latestID()
|
|
if complete {
|
|
for _, ev := range events {
|
|
if !writeSSE(w, flusher, ev) {
|
|
return
|
|
}
|
|
}
|
|
} else {
|
|
q := sqlcgen.New(s.pool)
|
|
dbEvents, err := q.ListEventsAfter(ctx, sqlcgen.ListEventsAfterParams{ID: id, Limit: 5000})
|
|
if err != nil {
|
|
slog.Error("sse db replay failed", "error", err)
|
|
} else {
|
|
for _, ev := range dbEvents {
|
|
if !writeSSE(w, flusher, ev) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 2. Subscribe and forward ──────────────────────────────────
|
|
heartbeat := time.NewTicker(15 * time.Second)
|
|
defer heartbeat.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case ev, ok := <-sub.ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
if !writeSSE(w, flusher, ev) {
|
|
return
|
|
}
|
|
case <-heartbeat.C:
|
|
if _, err := fmt.Fprintf(w, ": heartbeat\n\n"); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// StreamEvents satisfies the generated ServerInterface, but the SSE route is
|
|
// served by the raw serveSSE handler registered in NewHandler (which wins
|
|
// over this generated route). If this method is ever reached, routing has
|
|
// regressed — fail loudly rather than silently chunk-buffering.
|
|
func (s *Server) StreamEvents(ctx context.Context, req gen.StreamEventsRequestObject) (gen.StreamEventsResponseObject, error) {
|
|
return nil, fmt.Errorf("%w: SSE must be served by the raw handler", errNotImplemented)
|
|
}
|
|
|
|
// Ensure pgxpool is imported — used via Acquire.
|
|
var _ = &pgxpool.Pool{}
|
|
var _ = pgx.ErrNoRows
|