package main import ( "context" "encoding/json" "errors" "fmt" "log/slog" "regexp" "strings" "time" "github.com/dtoro/oikos/internal/db/sqlcgen" "github.com/dtoro/oikos/internal/observability" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) const maxToolResultSize = 4096 // errPlanInFlight is returned by proposePlan when called again after a step // has already started. The agent must advance the existing plan with // update_plan_step + run instead of re-proposing — re-proposing was the // source of duplicate plans in the sidebar (operator-reported 2026-07-14). // The caller translates this into a directive tool result. var errPlanInFlight = errors.New("plan already in flight") // errPlanStepNotFound is returned by updatePlanStep when no step matches the // given seq in the CURRENT (MAX) generation — either the seq is out of range, // or (after a re-plan) the model addressed a stale 1-based number. seq is // generation-relative, so this never resurrects a superseded generation's row. // The caller translates it into a directive tool result (P0.1). var errPlanStepNotFound = errors.New("plan step not found in current generation") type store struct { pool *pgxpool.Pool } func newStore(ctx context.Context, databaseURL string) (*store, error) { if databaseURL == "" { return nil, nil } pool, err := pgxpool.New(ctx, databaseURL) if err != nil { return nil, fmt.Errorf("connect db: %w", err) } if err := pool.Ping(ctx); err != nil { pool.Close() return nil, fmt.Errorf("ping db: %w", err) } s := &store{pool: pool} s.cleanupStaleExecutions(ctx, time.Hour) return s, nil } // cleanupStaleExecutions marks non-terminal executions older than maxAge as // cancelled. Orphaned executions accumulate when the MCP client times out // (30s) before the run handler's error path can mark them failed — the // execution entity is created before the SSH call, and a timeout kills the // connection before the handler runs its UPDATE. Without this, stale // `running` and `pending_approval` executions pile up in the DB and pollute // the Operations page + session rail badges. Called at startup (maxAge=1h) // and periodically (maxAge=10m) by the sweep worker. func (s *store) cleanupStaleExecutions(ctx context.Context, maxAge time.Duration) int { if s == nil { return 0 } tag, err := s.pool.Exec(ctx, ` UPDATE executions SET status = 'cancelled', result = jsonb_build_object('message', 'cleaned up — stale non-terminal execution (older than ' || $1 || ')') WHERE status IN ('running', 'pending_approval', 'approved', 'queued') AND entity_id IN ( SELECT entity_id FROM entities WHERE created_at < now() - ($2 * interval '1 second') )`, maxAge.String(), maxAge.Seconds()) if err != nil { slog.Warn("nomos: stale execution cleanup failed", "error", err) return 0 } n := int(tag.RowsAffected()) if n > 0 { slog.Info("nomos: cleaned up stale executions", "count", n, "max_age", maxAge.String()) } return n } func (s *store) close() { if s.pool != nil { s.pool.Close() } } // session is a chat session elevated to a task: goal-structured work with a // lifecycle status and an outcome (see migration 018 / the task-board plan). // Outcome/Summary/EntityID are empty until set, hence omitempty. // // P1.5 (2026-07-20): Blocker and ClosedAt track WHY a session ended // partial/failed and WHEN it actually closed. ClosedAt is distinct from // LastActiveAt — the latter is touched on any access (including a UI // transcript view), the former is set ONCE at completion. Without it, // "duration" computed as last_active - created lies for reopened sessions // (a51e2086 reported 4-day duration because the operator reopened it). // Blocker is a short structured reason: approval_timeout, // classifier_overreach, user_abandoned, tool_error, etc. See // plans/2026-07-20-session-review-ten-sessions.md P1.5. type session struct { ID string `json:"id"` Title string `json:"title"` Actor string `json:"actor"` Goal string `json:"goal"` Status string `json:"status"` Outcome string `json:"outcome,omitempty"` Summary string `json:"summary,omitempty"` EntityID string `json:"entity_id,omitempty"` PendingApprovals int `json:"pending_approvals"` Blocker string `json:"blocker,omitempty"` CreatedAt time.Time `json:"created_at"` LastActiveAt time.Time `json:"last_active_at"` ClosedAt *time.Time `json:"closed_at,omitempty"` // P2.6 (2026-07-20): server-side aggregates so /sessions can answer // "how big was this task?" without N+1 transcript fetches. The audit // had to pull every session's full message tree to count tool calls — // ~600 KB of JSON for 10 sessions. With these, the list view is a // single round trip. omitempty so getSession for a brand-new session // with zero activity doesn't emit zeros. MessageCount int `json:"message_count,omitempty"` ToolCallCount int `json:"tool_call_count,omitempty"` DurationSeconds int `json:"duration_seconds,omitempty"` } type message struct { ID string `json:"id"` SessionID string `json:"session_id"` Role string `json:"role"` Content json.RawMessage `json:"content"` CreatedAt time.Time `json:"created_at"` } func (s *store) createSession(ctx context.Context, title string) (*session, error) { if s == nil { return &session{ID: "ephemeral", Title: title, Actor: "agent:nomos", Status: "active"}, nil } var id string err := s.pool.QueryRow(ctx, `INSERT INTO agent_sessions (title, actor) VALUES ($1, 'agent:nomos') RETURNING id`, title).Scan(&id) if err != nil { return nil, err } // Give the task its own entity so knowledge and involved-entity edges hang // off the existing relationships graph. Best-effort: a failure here must not // block the chat — the session is usable without a graph anchor. entityID := s.createTaskEntity(ctx, id, title) return &session{ID: id, Title: title, Actor: "agent:nomos", Status: "active", EntityID: entityID, CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil } // createTaskEntity creates (or reuses) the task: entity that // anchors this task's knowledge and involved-entity relationships, and records // it on the session. Returns the entity id, or "" on failure — non-fatal, see // caller. Requires the 'task' entity type (seeds/ontology.yaml). func (s *store) createTaskEntity(ctx context.Context, sessionID, title string) string { entityID, _ := uuid.NewV7() slug := "task:" + sessionID // name is UNIQUE(type,name) and chat titles collide ("hi" ×6), so key the // name on the session id and keep the human title in attributes for display. name := "task " + sessionID attrs, _ := json.Marshal(map[string]any{"title": title}) if err := s.pool.QueryRow(ctx, ` INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'task', $3, $4) ON CONFLICT (slug) DO UPDATE SET updated_at = now() RETURNING id`, entityID, slug, name, string(attrs)).Scan(&entityID); err != nil { slog.Warn("nomos: could not create task entity", "session", sessionID, "error", err) return "" } if _, err := s.pool.Exec(ctx, `UPDATE agent_sessions SET entity_id = $1 WHERE id = $2`, entityID, sessionID); err != nil { slog.Warn("nomos: could not link task entity", "session", sessionID, "error", err) } return entityID.String() } func (s *store) saveMessage(ctx context.Context, sessionID, role string, content json.RawMessage) error { if s == nil { return nil } _, err := s.pool.Exec(ctx, `INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3)`, sessionID, role, truncateToolResults(content)) return err } // insertMessageReturningID and updateMessage exist for the auto-continuation // worker's live-progress persistence (see continue.go): rather than saving // one message only once the whole continuation finishes — which could be // several minutes of silence in the UI even though frontend polling exists — // the worker inserts a placeholder immediately and updates the SAME row as // each tool call completes, so a poller sees individual steps land, not just // a final rolled-up summary. func (s *store) insertMessageReturningID(ctx context.Context, sessionID, role string, content json.RawMessage) (uuid.UUID, error) { if s == nil { return uuid.Nil, nil } var id uuid.UUID err := s.pool.QueryRow(ctx, `INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3) RETURNING id`, sessionID, role, truncateToolResults(content)).Scan(&id) return id, err } func (s *store) updateMessage(ctx context.Context, id uuid.UUID, content json.RawMessage) error { if s == nil || id == uuid.Nil { return nil } _, err := s.pool.Exec(ctx, `UPDATE agent_messages SET content = $2 WHERE id = $1`, id, truncateToolResults(content)) return err } // deleteMessage removes a message row. Used by B.6: when a chat turn ends // with no text and no tool calls (the model empty-response'd and all // retries failed), the placeholder row is deleted instead of persisting an // empty assistant bubble — the error was already streamed to the frontend // via the 'done with error=true' event, so the operator sees it inline. func (s *store) deleteMessage(ctx context.Context, id uuid.UUID) { if s == nil || id == uuid.Nil { return } s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE id = $1`, id) } // lastUserMessage returns the most recent user message text for a session, // or "" if none. Used to build a context-rich reconnect/resume note: instead // of a generic "report your state," the note can say "the operator's last // message was X — advance the plan" so the agent doesn't re-propose or // re-execute on a reconnect (the operator-reported 2026-07-14 divergence). func (s *store) lastUserMessage(ctx context.Context, sessionID string) string { if s == nil || sessionID == "" || sessionID == "ephemeral" { return "" } var content json.RawMessage if err := s.pool.QueryRow(ctx, `SELECT content FROM agent_messages WHERE session_id = $1 AND role = 'user' ORDER BY created_at DESC LIMIT 1`, sessionID).Scan(&content); err != nil { return "" } var m struct { Text string `json:"text"` } if err := json.Unmarshal(content, &m); err != nil { return "" } return m.Text } // hasPlanInFlight reports whether a session has a plan with at least one // step in a non-terminal state (pending/running). Used to direct the // reconnect/resume note: if a plan is in flight, the note says "advance // the plan with update_plan_step + run" instead of the generic "report // your state" (which caused the agent to re-propose and duplicate the plan // in the sidebar — operator-reported 2026-07-14). func (s *store) hasPlanInFlight(ctx context.Context, sessionID string) bool { if s == nil || sessionID == "" || sessionID == "ephemeral" { return false } var exists bool if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM session_plan_steps WHERE session_id = $1 AND status IN ('pending', 'running'))`, sessionID).Scan(&exists); err != nil { return false } return exists } // enrichResumeNote appends session context to a base resume/reconnect note: // the operator's last user message and, if a plan is in flight, an explicit // directive to advance it with update_plan_step + run (not re-propose). The // generic "report your state" note caused the agent to re-propose and // duplicate the plan on a reconnect (operator-reported 2026-07-14); this // enrichment gives the agent enough context to do the right thing even // through the reconnect path. func (s *store) enrichResumeNote(ctx context.Context, sessionID, base string) string { if s == nil || sessionID == "" || sessionID == "ephemeral" { return base } last := s.lastUserMessage(ctx, sessionID) inFlight := s.hasPlanInFlight(ctx, sessionID) if last == "" && !inFlight { return base } note := base if last != "" { note += fmt.Sprintf(" The operator's last message was: %q.", last) } if inFlight { note += " A plan is in flight — advance it with update_plan_step (status=running) + run for the next step's target. Do NOT call propose_plan again." } return note } func truncateToolResults(content json.RawMessage) json.RawMessage { var m map[string]any if err := json.Unmarshal(content, &m); err != nil { return content } toolCalls, ok := m["tool_calls"].([]any) if !ok || len(toolCalls) == 0 { return content } changed := false for i, raw := range toolCalls { tc, ok := raw.(map[string]any) if !ok { continue } if result, ok := tc["result"]; ok { resultJSON, _ := json.Marshal(result) if len(resultJSON) > maxToolResultSize { tc["result"] = string(resultJSON[:maxToolResultSize]) + fmt.Sprintf("...truncated (%d bytes total)", len(resultJSON)) toolCalls[i] = tc changed = true } } } if !changed { return content } m["tool_calls"] = toolCalls out, err := json.Marshal(m) if err != nil { return content } return out } func (s *store) touchSession(ctx context.Context, id string) { if s != nil { s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id) } } func (s *store) listSessions(ctx context.Context) ([]session, error) { return s.listSessionsFiltered(ctx, listFilter{Limit: 50}) } // listFilter carries the optional WHERE/ORDER clauses added by P2.8 // (filtering & pagination). All fields optional; empty values are no-ops. // The handler in main.go parses query params into this struct so the SQL // builder here is the single source of truth for what filters exist. type listFilter struct { Outcome string // exact match on outcome (success/partial/failure) Status string // exact match on status (active/done/failed/executing) EntityID string // exact match on entity_id (UUID) Blocker string // exact match on blocker reason Since string // last_active_at >= this; RFC3339 timestamp OR Go duration (e.g. "24h") Cursor string // last_active_at < cursor (RFC3339) — page back in time Limit int // default 50, clamped by the handler } func (s *store) listSessionsFiltered(ctx context.Context, f listFilter) ([]session, error) { if s == nil { return nil, nil } if f.Limit <= 0 { f.Limit = 50 } // Build the WHERE clause dynamically. We use a single args slice with // $N placeholders to keep pgx happy; the index increments per clause. var ( where []string args []any n = 1 ) if f.Outcome != "" { where = append(where, fmt.Sprintf("COALESCE(s.outcome, '') = $%d", n)) args = append(args, f.Outcome) n++ } if f.Status != "" { where = append(where, fmt.Sprintf("s.status = $%d", n)) args = append(args, f.Status) n++ } if f.EntityID != "" { // Accept UUID or string; cast gracefully if invalid. if _, err := uuid.Parse(f.EntityID); err == nil { where = append(where, fmt.Sprintf("s.entity_id = $%d::uuid", n)) args = append(args, f.EntityID) n++ } } if f.Blocker != "" { where = append(where, fmt.Sprintf("COALESCE(s.blocker, '') = $%d", n)) args = append(args, f.Blocker) n++ } if f.Since != "" { // Accept RFC3339 timestamp OR a Go-style duration like "24h", "7d". // Try timestamp first, fall back to duration relative to now. if t, err := time.Parse(time.RFC3339, f.Since); err == nil { where = append(where, fmt.Sprintf("s.last_active_at >= $%d", n)) args = append(args, t) n++ } else if d, err := time.ParseDuration(f.Since); err == nil { where = append(where, fmt.Sprintf("s.last_active_at >= now() - ($%d * interval '1 second')", n)) args = append(args, d.Seconds()) n++ } // Unknown format: silently drop the filter — better than erroring // out and breaking the whole list. Caller can validate if needed. } if f.Cursor != "" { if t, err := time.Parse(time.RFC3339, f.Cursor); err == nil { where = append(where, fmt.Sprintf("s.last_active_at < $%d", n)) args = append(args, t) n++ } } whereClause := "" if len(where) > 0 { whereClause = "WHERE " + strings.Join(where, " AND ") } args = append(args, f.Limit) limitArg := fmt.Sprintf("$%d", n) query := fmt.Sprintf(` SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary, COALESCE(s.entity_id::text, ''), COALESCE(pa.cnt, 0), COALESCE(s.blocker, ''), s.created_at, s.last_active_at, s.closed_at, COALESCE(msg.cnt, 0), COALESCE(act.cnt, 0), COALESCE(EXTRACT(EPOCH FROM (COALESCE(s.closed_at, s.last_active_at) - s.created_at))::bigint, 0) FROM agent_sessions s LEFT JOIN ( SELECT l.session_id, COUNT(*) AS cnt FROM nomos_plan_executions l JOIN executions e ON e.entity_id = l.execution_id WHERE e.status = 'pending_approval' GROUP BY l.session_id ) pa ON pa.session_id = s.id LEFT JOIN ( SELECT session_id, COUNT(*) AS cnt FROM agent_messages GROUP BY session_id ) msg ON msg.session_id = s.id LEFT JOIN ( SELECT session_id::uuid AS sid, COUNT(*) AS cnt FROM agent_activity WHERE session_id IS NOT NULL AND session_id <> '' GROUP BY session_id ) act ON act.sid = s.id %s ORDER BY s.last_active_at DESC LIMIT %s`, whereClause, limitArg) rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var out []session for rows.Next() { var sess session if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status, &sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals, &sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt, &sess.MessageCount, &sess.ToolCallCount, &sess.DurationSeconds); err != nil { return nil, err } out = append(out, sess) } return out, rows.Err() } func (s *store) getSession(ctx context.Context, id string) (*session, error) { if s == nil { return nil, nil } var sess session err := s.pool.QueryRow(ctx, `SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary, COALESCE(s.entity_id::text, ''), 0, COALESCE(s.blocker, ''), s.created_at, s.last_active_at, s.closed_at, COALESCE(msg.cnt, 0), COALESCE(act.cnt, 0), COALESCE(EXTRACT(EPOCH FROM (COALESCE(s.closed_at, s.last_active_at) - s.created_at))::bigint, 0) FROM agent_sessions s LEFT JOIN ( SELECT session_id, COUNT(*) AS cnt FROM agent_messages WHERE session_id = $1::uuid GROUP BY session_id ) msg ON msg.session_id = s.id LEFT JOIN ( SELECT session_id::uuid AS sid, COUNT(*) AS cnt FROM agent_activity WHERE session_id IS NOT NULL AND session_id <> '' AND session_id::uuid = $1::uuid GROUP BY session_id ) act ON act.sid = s.id WHERE s.id = $1`, id). Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status, &sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals, &sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt, &sess.MessageCount, &sess.ToolCallCount, &sess.DurationSeconds) if err != nil { return nil, err } return &sess, nil } // recentPartialSessions returns recent sessions (within `since`) whose outcome // is partial or failed, excluding the current session. Used by the set_goal // handler to surface prior unfinished work on the same problem — three // duplicate rclone sessions (a51e2086, 8acea2e3, cb8c8a4a) all bounced off // the classifier because each new session started from scratch. Surfacing the // prior session's goal + summary at set_goal time lets the agent pick up the // thread instead of rediscovering it. See // plans/2026-07-20-session-review-ten-sessions.md P1.3. func (s *store) recentPartialSessions(ctx context.Context, excludeSessionID string, since time.Duration) ([]session, error) { if s == nil { return nil, nil } rows, err := s.pool.Query(ctx, `SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary, COALESCE(s.entity_id::text, ''), COALESCE(pa.cnt, 0), COALESCE(s.blocker, ''), s.created_at, s.last_active_at, s.closed_at, COALESCE(msg.cnt, 0), COALESCE(act.cnt, 0), COALESCE(EXTRACT(EPOCH FROM (COALESCE(s.closed_at, s.last_active_at) - s.created_at))::bigint, 0) FROM agent_sessions s LEFT JOIN ( SELECT l.session_id, COUNT(*) AS cnt FROM nomos_plan_executions l JOIN executions e ON e.entity_id = l.execution_id WHERE e.status = 'pending_approval' GROUP BY l.session_id ) pa ON pa.session_id = s.id LEFT JOIN ( SELECT session_id, COUNT(*) AS cnt FROM agent_messages GROUP BY session_id ) msg ON msg.session_id = s.id LEFT JOIN ( SELECT session_id::uuid AS sid, COUNT(*) AS cnt FROM agent_activity WHERE session_id IS NOT NULL AND session_id <> '' GROUP BY session_id ) act ON act.sid = s.id WHERE s.id <> $1 AND s.last_active_at >= now() - ($2 * interval '1 second') AND COALESCE(s.outcome, '') IN ('partial', 'failed') ORDER BY s.last_active_at DESC LIMIT 10`, excludeSessionID, since.Seconds()) if err != nil { return nil, err } defer rows.Close() var out []session for rows.Next() { var sess session if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status, &sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals, &sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt, &sess.MessageCount, &sess.ToolCallCount, &sess.DurationSeconds); err != nil { return nil, err } out = append(out, sess) } return out, rows.Err() } // getMessages returns a session's ENTIRE message history, unbounded — used // for the UI's own transcript view (GET /sessions/{id}), where the operator // should be able to see everything a task has done regardless of how long // it's run. For LLM replay, see getRecentMessages: sending the operator's // full transcript is fine; sending the model's full transcript on every // single turn is not (see getRecentMessages's doc comment). func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, error) { if s == nil { return nil, nil } rows, err := s.pool.Query(ctx, `SELECT id, session_id, role, content, created_at FROM agent_messages WHERE session_id=$1 ORDER BY created_at ASC`, sessionID) if err != nil { return nil, err } defer rows.Close() var out []message for rows.Next() { var m message if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil { return nil, err } out = append(out, m) } return out, rows.Err() } // SessionToolCall is the flat view of one tool call as exposed by // GET /sessions/{id}/tool_calls. Mirrors the persisted tool_call shape but // drops the message-shell wrapping. Args/Result are kept as RawMessage so // the caller can decide how to render them (the audit case wanted raw // text sizes, but other callers may want full JSON). type SessionToolCall struct { ID string `json:"id"` Name string `json:"name"` Args json.RawMessage `json:"args,omitempty"` Result json.RawMessage `json:"result,omitempty"` Error string `json:"error,omitempty"` Type string `json:"type,omitempty"` // "tool_use" or "tool_result" MessageID string `json:"message_id"` Role string `json:"role"` Seq int `json:"seq"` // 1-indexed position within the session (across all messages) CreatedAt time.Time `json:"created_at"` } // getSessionToolCalls walks a session's messages and returns a flat list of // tool calls in chronological order, without the two-level message nesting. // The audit at plans/2026-07-20-session-review-ten-sessions.md P2.10 had to // write Python to walk messages[].content.tool_calls[]; this method makes // it a single SQL + Go walk on the server. Each tool_use/tool_result pair // is emitted as two rows (same id, different Type), preserving the // persisted shape — clients that want the merged shape can group by ID. func (s *store) getSessionToolCalls(ctx context.Context, sessionID string) ([]SessionToolCall, error) { if s == nil { return nil, nil } msgs, err := s.getMessages(ctx, sessionID) if err != nil { return nil, err } var out []SessionToolCall seq := 0 for _, m := range msgs { var payload struct { ToolCalls []struct { ID string `json:"id"` Type string `json:"type"` Name string `json:"name"` Args json.RawMessage `json:"args"` Result json.RawMessage `json:"result"` Error string `json:"error"` } `json:"tool_calls"` } if err := json.Unmarshal(m.Content, &payload); err != nil { continue } for _, tc := range payload.ToolCalls { if tc.ID == "" { continue } seq++ out = append(out, SessionToolCall{ ID: tc.ID, Name: tc.Name, Args: tc.Args, Result: tc.Result, Error: tc.Error, Type: tc.Type, MessageID: m.ID, Role: m.Role, Seq: seq, CreatedAt: m.CreatedAt, }) } } return out, nil } // getRecentMessages returns the most recent `limit` messages for sessionID, // in chronological order, plus whether older messages exist beyond that // window. Used specifically for LLM replay (chatWith): without a bound, // every turn re-sent the ENTIRE session history into the model's context, // unconditionally growing with every turn — a real, observed-in-production // cost/latency/eventual-context-limit risk for exactly the long-running, // heavily-autonomous tasks (many auto-continuation cycles) this system is // built to run longest. Fetches limit+1 rows to detect "there's more" // without a separate COUNT query. func (s *store) getRecentMessages(ctx context.Context, sessionID string, limit int) (msgs []message, truncated bool, err error) { if s == nil { return nil, false, nil } rows, qerr := s.pool.Query(ctx, `SELECT id, session_id, role, content, created_at FROM agent_messages WHERE session_id=$1 ORDER BY created_at DESC LIMIT $2`, sessionID, limit+1) if qerr != nil { return nil, false, qerr } defer rows.Close() var out []message for rows.Next() { var m message if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil { return nil, false, err } out = append(out, m) } if err := rows.Err(); err != nil { return nil, false, err } truncated = len(out) > limit if truncated { out = out[:limit] } // Rows came back newest-first (for the LIMIT to bound the right end); // reverse to chronological order for replay. for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { out[i], out[j] = out[j], out[i] } return out, truncated, nil } func (s *store) deleteSession(ctx context.Context, id string) error { if s == nil { return nil } // Resolve the task entity so we can clean up its graph edges and events too // — otherwise deleting a session orphans its task: entity, its involves/ // documents relationships, and its task-scoped events. var entID uuid.UUID s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, id).Scan(&entID) if _, err := s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE session_id = $1`, id); err != nil { return err } // task.status / entity.touched / knowledge.recorded are all correlated by // session id. s.pool.Exec(ctx, `DELETE FROM events WHERE correlation_id = $1`, id) if _, err := s.pool.Exec(ctx, `DELETE FROM agent_sessions WHERE id = $1`, id); err != nil { return err } if entID != uuid.Nil { // relationships FK is ON DELETE RESTRICT, so drop the task's edges first. s.pool.Exec(ctx, `DELETE FROM relationships WHERE source_id = $1 OR target_id = $1`, entID) s.pool.Exec(ctx, `DELETE FROM entities WHERE id = $1`, entID) } return nil } // taskEntityPtr returns the task entity id for a session, or nil — used as the // entity_id on task-scoped events so they anchor to the task in the graph. func (s *store) taskEntityPtr(ctx context.Context, sessionID string) *uuid.UUID { var id uuid.UUID if err := s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&id); err != nil || id == uuid.Nil { return nil } return &id } // setGoal records the task's goal and moves it into executing. The `planning` // intermediate state was removed (2026-07-14) — it was indistinguishable from // `active` to the operator and caused sessions to appear stuck when the agent // called set_goal but never propose_plan (observed in production). // // P2 (2026-07-15): setGoal also replaces any prior plan steps (from a // previous sub-task or an incomplete first turn) as `replaced`, clearing the // way for a fresh propose_plan. This is the ONLY place step replacement // happens — not in reopenSession — because set_goal is the explicit signal // for "new sub-task." An approval ("go ahead") does NOT call set_goal, so it // won't destroy the plan the operator just approved. // // P1.4 (2026-07-18): when a non-empty prior goal is being overwritten by a // different goal, emit a `task.superseded` event carrying the prior goal. // This gives the UI/audit trail a clear signal that the operator pivoted — // without it, the prior goal just silently disappears from // agent_sessions.goal and there's no record the session ever had a // different starting intent. See plans/2026-07-18-session-review-three- // sessions.md P1.4 (session 55927f0a had two set_goal calls with the first // implicitly abandoned when the operator said "lets just keep ludo-library // then"). func (s *store) setGoal(ctx context.Context, sessionID, goal string) error { if s == nil || sessionID == "" || sessionID == "ephemeral" { return nil } // Capture the prior goal BEFORE the UPDATE overwrites it. If non-empty // and different from the new goal, emit task.superseded so the audit // trail records the pivot — the row's goal column won't. var priorGoal string s.pool.QueryRow(ctx, `SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`, sessionID).Scan(&priorGoal) if priorGoal != "" && priorGoal != goal { _ = observability.Event(ctx, sqlcgen.New(s.pool), "task.superseded", s.taskEntityPtr(ctx, sessionID), "info", "nomos", sessionID, map[string]any{"prior_goal": priorGoal, "new_goal": goal}) slog.Info("nomos: task goal superseded by a new set_goal", "session", sessionID, "prior_goal", priorGoal, "new_goal", goal) } // Replace any prior plan steps (done/running/pending/...) as `replaced`. // The rows are kept for the generation counter + audit trail; proposePlan // excludes `replaced` from its in-flight check, so the next propose_plan // takes the fresh-generation path. replaced_reason records the cause // (2026-08-04 plan-step integrity audit). s.pool.Exec(ctx, `UPDATE session_plan_steps SET status = 'replaced', replaced_reason = $2, finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`, sessionID, "goal superseded") if _, err := s.pool.Exec(ctx, `UPDATE agent_sessions SET goal = $2, status = 'executing', title = $2, last_active_at = now() WHERE id = $1`, sessionID, goal); err != nil { return err } _ = observability.Event(ctx, sqlcgen.New(s.pool), "goal.set", s.taskEntityPtr(ctx, sessionID), "info", "nomos", sessionID, map[string]any{"goal": goal}) return nil } // reopenSession flips a terminal (done/failed) session back to `executing` // so a follow-up message can start a new sub-task — the iteration path // (P2, 2026-07-15). Without this, a completed session stays `done` forever // and the panel shows a stale result. // // reopenSession ONLY flips the status + clears outcome/summary. It does NOT // touch plan steps — that's `setGoal`'s job (see below). The reason: not // every follow-up is a new sub-task. An approval ("go ahead") is a // continuation of the current plan, and replacing its steps would destroy // the plan the operator just approved. `set_goal` is the explicit signal for // "new sub-task," so step replacement happens there, not here. // // Returns true if the session was actually reopened (was terminal), false if // it was already active (no-op). func (s *store) reopenSession(ctx context.Context, sessionID string) bool { if s == nil || sessionID == "" || sessionID == "ephemeral" { return false } var currentStatus string if err := s.pool.QueryRow(ctx, `SELECT status FROM agent_sessions WHERE id = $1`, sessionID).Scan(¤tStatus); err != nil { return false } if currentStatus != "done" && currentStatus != "failed" { return false } s.pool.Exec(ctx, `UPDATE agent_sessions SET status = 'executing', outcome = NULL, summary = NULL, last_active_at = now() WHERE id = $1`, sessionID) // Mark the prior plan's steps as replaced so the P1 plan-first gate in // classifyAndGate forces a fresh propose_plan before any run. Without // this, the agent could resume a session and call run against the old // (completed) plan — exactly what caused the ZimaOS continuation to // have 81 ad-hoc tool calls with zero plan structure (2026-08-04). s.pool.Exec(ctx, `UPDATE session_plan_steps SET status = 'replaced', replaced_reason = $2, finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`, sessionID, "session reopened — awaiting new plan") _ = observability.Event(ctx, sqlcgen.New(s.pool), "task.reopened", s.taskEntityPtr(ctx, sessionID), "info", "nomos", sessionID, map[string]any{"prior_status": currentStatus}) return true } // planStepInput is one step as the agent proposes it. type planStepInput struct { Title string Detail string TargetSlug string } // proposePlan sets the task's plan and moves it into executing. Emits // plan.proposed with the persisted steps (seq + id) so the panel can render // and later address them by id. // // Two modes, chosen by whether any existing step has left 'pending': // - Fresh/revise (no step started yet): full replace (delete + insert). This // covers the first call, and a genuine re-plan before any work began. // - Mid-flight (some step is running/done/failed/…): REFUSE the call. // The agent must advance the existing plan with update_plan_step + run // instead of re-proposing. The previous append-mode safety net (commit // 5384499) preserved history but produced a confusing duplicate sidebar // when the agent re-proposed on "proceed" (operator-reported 2026-07-14). // Refusing is the correct default — the tool result tells the agent how // to advance, and the generation column tracks revisions if a genuine // re-plan is ever allowed. func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planStepInput) ([]map[string]any, error) { if s == nil || sessionID == "" || sessionID == "ephemeral" { return nil, nil } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) var anyStarted bool // `replaced` steps (from a prior plan generation superseded by a // follow-up sub-task — see setGoal/reopenSession) are excluded: they // prove a prior plan was completed and superseded, not that a plan is in // flight. Without this exclusion, setGoal's `replaced` marking would be // useless — propose_plan would still refuse on the follow-up. if err := tx.QueryRow(ctx, ` SELECT COALESCE(bool_or(status NOT IN ('pending', 'replaced')), false) FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&anyStarted); err != nil { return nil, err } if anyStarted { // A plan is already in flight (a step is running/done/failed/...). // Refuse the re-proposal — the agent must advance with // update_plan_step + run. The caller surfaces a directive. return nil, errPlanInFlight } // Fresh/revise: mark any prior PENDING steps as `replaced` (not DELETE). // The rows are kept for the generation counter (MAX(generation)+1 below) // and the plan_generations eval assertion. `replaced` steps are excluded // from the anyStarted check above, so they don't block this proposal. // replaced_reason records the cause — required by the plan-step integrity // gate (2026-08-04 session audit). if _, err := tx.Exec(ctx, `UPDATE session_plan_steps SET status = 'replaced', replaced_reason = $2, finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status = 'pending'`, sessionID, "superseded by new plan generation"); err != nil { return nil, err } // nextGen: generation 1 for the first plan, MAX(generation)+1 for every // revise/follow-up (prior rows were marked `replaced` above, not deleted, // so the counter survives). seq is generation-relative — it resets to // 1..N for this generation, so (session_id, generation, seq) is the // addressing key and the model's 1-based update_plan_step always maps to // the CURRENT plan after a re-plan (P0.1). var nextGen int if err := tx.QueryRow(ctx, ` SELECT COALESCE(MAX(generation), 0) + 1 FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&nextGen); err != nil { return nil, err } out := make([]map[string]any, 0, len(steps)) for i, st := range steps { var targetSlug *string if st.TargetSlug != "" { targetSlug = &st.TargetSlug } seq := i + 1 var id uuid.UUID if err := tx.QueryRow(ctx, ` INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug, generation) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, sessionID, seq, st.Title, st.Detail, targetSlug, nextGen).Scan(&id); err != nil { return nil, err } out = append(out, map[string]any{ "id": id.String(), "seq": seq, "title": st.Title, "detail": st.Detail, "target_slug": st.TargetSlug, "generation": nextGen, }) } if _, err := tx.Exec(ctx, `UPDATE agent_sessions SET status = 'executing', last_active_at = now() WHERE id = $1`, sessionID); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } // Event after commit so subscribers only ever see a persisted plan. // appended=false (always now — we refuse mid-flight re-proposals) tells // the panel to replace its list with these steps. _ = observability.Event(ctx, sqlcgen.New(s.pool), "plan.proposed", s.taskEntityPtr(ctx, sessionID), "info", "nomos", sessionID, map[string]any{"steps": out, "appended": false, "generation": nextGen}) return out, nil } // updatePlanStep sets a step's status by seq, stamping started_at/finished_at // and linking an execution if given. Emits plan.step.started (running) or // plan.step.finished (terminal) so the panel advances live. The execution link // is also what lets the api auto-close the step when the execution finishes // (see closePlanStepForExecution). // // Completion ordering (done/failed/skipped/blocked) is enforced: a step cannot // be marked complete while an earlier step is still pending, preventing the // agent from marking step 5 done before step 4 (observed in production: the // agent rushed to close all steps in a final turn, in reverse order). func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, status, execID, replacedReason string) error { if s == nil || sessionID == "" || sessionID == "ephemeral" { return nil } // Resolve the CURRENT generation: seq is generation-relative (1-based // within the plan the model is working), so (session_id, MAX(generation), // seq) is the addressing key. A re-plan's superseded generations have // their own seq space and must never be touched by a follow-up's // update_plan_step — that was the root cause of "the plan was off" // (gen-1 `replaced` rows resurrected as `done` while gen-2 work went // unrecorded). The MAX(generation) step is by construction the active // plan, never `replaced`, so this can't resurrect a superseded row (P0.1). var curGen int if err := s.pool.QueryRow(ctx, `SELECT COALESCE(MAX(generation), 0) FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&curGen); err != nil { return err } if curGen == 0 { return errPlanStepNotFound } stamp := "" switch status { case "running": stamp = ", started_at = COALESCE(started_at, now())" case "done", "failed", "skipped", "blocked", "replaced": stamp = ", finished_at = now()" } // Completion ordering, scoped to the CURRENT generation: for terminal // states, no earlier step in THIS plan may still be pending. Running // steps can start out of order (the agent may dispatch parallel work), // but completion must be sequential. Earlier generations are superseded // and irrelevant. if status == "done" || status == "failed" || status == "skipped" || status == "blocked" { var blockedBy int if err := s.pool.QueryRow(ctx, ` SELECT COALESCE(MIN(seq), 0) FROM session_plan_steps WHERE session_id = $1 AND generation = $2 AND seq < $3 AND status = 'pending'`, sessionID, curGen, seq).Scan(&blockedBy); err == nil && blockedBy > 0 { return fmt.Errorf("cannot complete step %d — step %d is still pending", seq, blockedBy) } } var execPtr *uuid.UUID if id, err := uuid.Parse(execID); err == nil { execPtr = &id } var stepID uuid.UUID var targetSlug *string // stamp is a fixed literal from the switch above — never user input. // status <> 'replaced' is defense-in-depth: MAX(generation) can't hold a // replaced row, but if it ever could, this refuses the write instead of // resurrecting it. No matching row → errPlanStepNotFound (stale/out-of-range seq). if status == "replaced" && replacedReason != "" { err := s.pool.QueryRow(ctx, ` UPDATE session_plan_steps SET status = $4, execution_id = COALESCE($5, execution_id), replaced_reason = $6`+stamp+` WHERE session_id = $1 AND generation = $2 AND seq = $3 AND status <> 'replaced' RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr, replacedReason).Scan(&stepID, &targetSlug) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return errPlanStepNotFound } return err } } else { err := s.pool.QueryRow(ctx, ` UPDATE session_plan_steps SET status = $4, execution_id = COALESCE($5, execution_id)`+stamp+` WHERE session_id = $1 AND generation = $2 AND seq = $3 AND status <> 'replaced' RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr).Scan(&stepID, &targetSlug) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return errPlanStepNotFound } return err } } // Anchor the event to the step's target entity when it has one, else the task. entPtr := s.taskEntityPtr(ctx, sessionID) if targetSlug != nil && *targetSlug != "" { var tid uuid.UUID if s.pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, *targetSlug).Scan(&tid) == nil { entPtr = &tid } } evType := "plan.step.finished" if status == "running" { evType = "plan.step.started" } data := map[string]any{"step_id": stepID.String(), "seq": seq, "status": status} if execID != "" { data["execution_id"] = execID } _ = observability.Event(ctx, sqlcgen.New(s.pool), evType, entPtr, "info", "nomos", sessionID, data) return nil } // errTaskAlreadyComplete is returned by completeTask when the session is // already in a terminal state (done/failed/partial). The agent sometimes // re-calls complete_task after a UI clarification (operator-reported // 2026-07-14) — without this guard, the re-completion produces duplicate // knowledge entries and erodes audit-log clarity. The caller translates this // into a directive tool result. var errTaskAlreadyComplete = errors.New("task already complete") // completeTask sets a task's terminal state, outcome, and one-line summary, // mirrors the outcome onto the task entity's attributes (so the board/graph // show it), and publishes task.status for the live context panel. outcome is // success|failure|partial; status is derived (failure → failed, else done). // Returns errTaskAlreadyComplete if the session is already terminal — the // agent must not re-complete a finished task. func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary string) error { if s == nil || sessionID == "" || sessionID == "ephemeral" { return nil } // C.1: reject re-completion of an already-terminal session. The agent // sometimes re-calls complete_task after a UI clarification ("the sidebar // differs") — without this guard, the re-completion duplicates knowledge // entries and produces a confusing audit trail. var currentStatus string if err := s.pool.QueryRow(ctx, `SELECT status FROM agent_sessions WHERE id = $1`, sessionID).Scan(¤tStatus); err != nil { // Session doesn't exist or query failed — let the rest of the // function proceed; it'll fail safely on the UPDATE below. } else if currentStatus == "done" || currentStatus == "failed" { return errTaskAlreadyComplete } // Auto-cancel any executions still in pending_approval/approved/queued // state for this session — preventing orphaned approvals (observed in // production: 4 approvals left open after session completed). var cancelledCount int if err := s.pool.QueryRow(ctx, ` WITH cancelled AS ( UPDATE executions SET status = 'cancelled', result = '{"message": "task completed — auto-cancelled"}'::jsonb WHERE entity_id IN ( SELECT execution_id FROM nomos_plan_executions WHERE session_id = $1 ) AND status IN ('pending_approval', 'approved', 'queued') RETURNING entity_id ) SELECT COUNT(*) FROM cancelled `, sessionID).Scan(&cancelledCount); err != nil { slog.Warn("nomos: completeTask failed to cancel orphaned executions", "session", sessionID, "error", err) } // Mark all continuations done so the worker won't try to feed them back. s.pool.Exec(ctx, `UPDATE nomos_plan_executions SET continued_at = now() WHERE session_id = $1 AND continued_at IS NULL`, sessionID) // P1.4 (2026-07-20): auto-close any in-flight plan steps so the agent // doesn't need an update_plan_step(running)→update_plan_step(done) // dance for each step right before completion. Session 8c76bb3a // (greeting + title-sync test) burned 4 update_plan_step calls for a // one-step plan. completeTask is the authoritative terminal — any // step still in pending/running when the task ends is closed (as // "done" for success, "skipped" for partial/failure) so the UI's plan // view doesn't show orphaned running steps on a completed task. // Replaced/cancelled/blocked steps are left alone. closeStatus := "done" if outcome != "success" { closeStatus = "skipped" } // Auto-close only the CURRENT generation's in-flight steps — superseded // generations were already resolved when their plan was replaced. Stamp // started_at so no `done` step is left with a NULL start time (P0.1 fix // 5), and emit a plan.step.finished event per closed step so the panel // converges instead of freezing on "running" after the task completes // (P1.1: no bulk plan-step status write without a corresponding event). type closingStep struct { id uuid.UUID seq int targetSlug *string } var toClose []closingStep if rows, qerr := s.pool.Query(ctx, ` SELECT id, seq, target_slug FROM session_plan_steps WHERE session_id = $1 AND generation = (SELECT MAX(generation) FROM session_plan_steps WHERE session_id = $1) AND status IN ('pending', 'running')`, sessionID); qerr == nil { for rows.Next() { var cs closingStep if err := rows.Scan(&cs.id, &cs.seq, &cs.targetSlug); err == nil { toClose = append(toClose, cs) } } rows.Close() } if _, err := s.pool.Exec(ctx, ` UPDATE session_plan_steps SET status = $2, started_at = COALESCE(started_at, now()), finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND generation = (SELECT MAX(generation) FROM session_plan_steps WHERE session_id = $1) AND status IN ('pending', 'running')`, sessionID, closeStatus); err != nil { slog.Warn("nomos: completeTask failed to auto-close in-flight steps", "session", sessionID, "error", err) } // Emit one plan.step.finished per closed step so the live panel advances // (mirrors updatePlanStep's event). A bulk UPDATE that skips the event // bus guarantees a stale panel — the rule is: no plan-step status change // without a corresponding event. taskEnt := s.taskEntityPtr(ctx, sessionID) for _, cs := range toClose { evEnt := taskEnt if cs.targetSlug != nil && *cs.targetSlug != "" { var tid uuid.UUID if s.pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, *cs.targetSlug).Scan(&tid) == nil { evEnt = &tid } } _ = observability.Event(ctx, sqlcgen.New(s.pool), "plan.step.finished", evEnt, "info", "nomos", sessionID, map[string]any{"step_id": cs.id.String(), "seq": cs.seq, "status": closeStatus}) } // Clean up assent and destructive window keys from autonomy_settings. s.pool.Exec(ctx, `DELETE FROM autonomy_settings WHERE key LIKE '%:' || $1`, sessionID) status := "done" if outcome == "failure" { status = "failed" } // P1.5 (2026-07-20): derive a structured blocker reason when the // outcome is partial/failed, so trend analysis can answer "why are // sessions failing?" without parsing free-text summaries. Three // duplicate rclone sessions (a51e2086, 8acea2e3, cb8c8a4a) all // bounced off the classifier; without a blocker field, the *why* was // buried in the last assistant message. The signatures matched here // are the recurring ones from the 2026-07-20 session audit. Empty for // success — that's not a blocker. blocker := "" if outcome != "success" { blocker = deriveBlocker(ctx, s, sessionID, summary) } if _, err := s.pool.Exec(ctx, ` UPDATE agent_sessions SET status = $2, outcome = $3, summary = $4, blocker = $5, closed_at = now(), last_active_at = now() WHERE id = $1`, sessionID, status, outcome, summary, blocker); err != nil { return err } var entID uuid.UUID s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&entID) var entPtr *uuid.UUID if entID != uuid.Nil { attrs, _ := json.Marshal(map[string]any{"outcome": outcome, "status": status, "summary": summary}) s.pool.Exec(ctx, `UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now() WHERE id = $1`, entID, string(attrs)) entPtr = &entID } severity := "info" if outcome == "failure" { severity = "warning" } _ = observability.Event(ctx, sqlcgen.New(s.pool), "task.status", entPtr, severity, "nomos", sessionID, map[string]any{"status": status, "outcome": outcome, "summary": summary, "cancelled_executions": cancelledCount, "blocker": blocker}) // Auto-persist knowledge so the graph learns from this session regardless // of whether the agent remembered to call upsert_knowledge (2026-08-04 // session audit: only 2.4% of sessions called upsert_knowledge manually). if outcome == "success" || outcome == "partial" { autoUpsertKnowledge(ctx, s, sessionID, outcome, summary) } // Plan quality metric: compute step completion rate for the session's // current plan generation. Tracked as a task attribute so the trend // can be monitored over time (2026-08-04 session audit: 38% baseline). writePlanCompletionRate(ctx, s, sessionID) // Auto-feedback: create a feedback entry linking the session's outcome // to its last execution, feeding the pattern-extraction pipeline that // has been empty since launch (2026-08-04 session audit: 0 feedback rows). if outcome == "success" || outcome == "partial" { autoFeedback(ctx, s, sessionID, outcome, summary) } return nil } // autoUpsertKnowledge creates a knowledge entry for a completed session, // capturing what was done and linking it to the entities involved. Called // automatically from completeTask so every session leaves a trace, even if // the agent forgot to call upsert_knowledge. Only fired for success/partial // outcomes (failures don't have actionable discoveries). func autoUpsertKnowledge(ctx context.Context, s *store, sessionID, outcome, summary string) { var goal string if err := s.pool.QueryRow(ctx, `SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`, sessionID).Scan(&goal); err != nil || goal == "" { return } title := "Session " + sessionID[:8] + ": " + goal if len(title) > 200 { title = title[:200] } content := "## Outcome\n" + outcome + "\n\n## Summary\n" + summary kind := "investigation" slug := "investigation:nomos/" + sessionID tags := []string{"nomos-session", "auto-generated"} // Upsert the knowledge entity. docID, _ := uuid.NewV7() if err := s.pool.QueryRow(ctx, ` INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, $3, $4, '{}') ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now() RETURNING id`, docID, slug, kind, title).Scan(&docID); err != nil { slog.Warn("nomos: autoUpsertKnowledge entity insert", "session", sessionID, "error", err) return } // Upsert the knowledge content. if _, err := s.pool.Exec(ctx, ` INSERT INTO knowledge_entities (entity_id, title, content, source, tags, updated_at) VALUES ($1, $2, $3, 'nomos-agent', $4, now()) ON CONFLICT (entity_id) DO UPDATE SET title = EXCLUDED.title, content = EXCLUDED.content, tags = EXCLUDED.tags, updated_at = now()`, docID, title, content, tags); err != nil { slog.Warn("nomos: autoUpsertKnowledge content insert", "session", sessionID, "error", err) return } // Link to the task entity. var taskEntID uuid.UUID if s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&taskEntID) == nil && taskEntID != uuid.Nil { s.pool.Exec(ctx, ` INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) SELECT $1, $2, 'involves', '{"by":"nomos","auto":true}'::jsonb, now() WHERE NOT EXISTS ( SELECT 1 FROM relationships WHERE source_id = $1 AND target_id = $2 AND type = 'involves' AND valid_to IS NULL)`, taskEntID, docID) } slog.Info("nomos: auto-upserted knowledge for session", "session", sessionID, "outcome", outcome, "slug", slug) } // writePlanCompletionRate computes the step completion rate for the current // plan generation and writes it as a task entity attribute so the trend can // be tracked. Baseline from 2026-08-04 audit: 38% (15/39 steps reached done). func writePlanCompletionRate(ctx context.Context, s *store, sessionID string) { var total, completed int s.pool.QueryRow(ctx, ` SELECT COUNT(*), COALESCE(SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END), 0) FROM session_plan_steps WHERE session_id = $1 AND generation = (SELECT MAX(generation) FROM session_plan_steps WHERE session_id = $1) AND status <> 'replaced'`, sessionID).Scan(&total, &completed) if total > 0 { rate := float64(completed) / float64(total) attrs, _ := json.Marshal(map[string]any{"plan_completion_rate": rate, "plan_steps_total": total, "plan_steps_completed": completed}) s.pool.Exec(ctx, ` UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now() WHERE id = (SELECT entity_id FROM agent_sessions WHERE id = $1)`, sessionID, string(attrs)) slog.Info("nomos: plan completion rate", "session", sessionID, "rate", fmt.Sprintf("%.0f%%", rate*100), "completed", completed, "total", total) } } // autoFeedback creates a feedback entry linking the session's outcome to its // last execution, feeding the pattern-extraction pipeline that has been empty // since launch. Only created for success/partial outcomes (failures don't // have a specific execution to tie to). func autoFeedback(ctx context.Context, s *store, sessionID, outcome, summary string) { // Find the last execution linked to this session. var execID uuid.UUID if err := s.pool.QueryRow(ctx, ` SELECT pe.execution_id FROM nomos_plan_executions pe WHERE pe.session_id = $1::uuid ORDER BY pe.created_at DESC LIMIT 1`, sessionID).Scan(&execID); err != nil || execID == uuid.Nil { return } fbID, _ := uuid.NewV7() slug := "feedback:" + fbID.String() if _, err := s.pool.Exec(ctx, ` INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'feedback', $3, '{}')`, fbID, slug, "feedback for "+sessionID[:8]); err != nil { slog.Warn("nomos: autoFeedback entity insert", "session", sessionID, "error", err) return } _, err := s.pool.Exec(ctx, ` INSERT INTO feedback (entity_id, execution_id, outcome, observation, lesson, tags, created_at) VALUES ($1, $2, $3, $4, $5, $6, now())`, fbID, execID, outcome, summary, summary, []string{"nomos-session", "auto-generated", "session:" + sessionID[:8]}) if err != nil { slog.Warn("nomos: autoFeedback insert", "session", sessionID, "error", err) return } slog.Info("nomos: auto-feedback created for session", "session", sessionID, "outcome", outcome) } // blockerPatterns maps a substring (case-insensitive) to a structured blocker // reason. Order matters — earlier patterns take precedence. These are the // recurring failure signatures from the 2026-07-20 session audit. A // real-world blocker that doesn't match any of these falls through to // "uncategorized" — better than empty, because empty means "we don't know // it's a blocker at all." See plans/2026-07-20-session-review-ten-sessions.md. var blockerPatterns = []struct { pattern string reason string }{ {"queued for approval", "approval_timeout"}, {"assent window", "approval_timeout"}, {"cancel", "user_abandoned"}, {"close this session", "user_abandoned"}, {"lets just close", "user_abandoned"}, {"classifier flagged", "classifier_overreach"}, {"config_mutation", "classifier_overreach"}, {"refus", "model_refusal"}, // refuses/refused/refusal {"empty response", "model_empty_response"}, {"no local knowledge", "missing_knowledge"}, {"can't run", "missing_capability"}, {"cannot run", "missing_capability"}, {"timeout", "tool_error"}, {"error", "tool_error"}, } // deriveBlocker scans the last assistant message + the summary for known // failure signatures and returns the matching structured reason. Returns // "uncategorized" when outcome is partial/failed but no signature matched — // better than "" because the audit needs to know this WAS blocked, just for // an unknown reason. Returns "" for success outcomes (caller checks first). func deriveBlocker(ctx context.Context, s *store, sessionID, summary string) string { // Pull the last assistant text — that's where the agent's parting // words explain why it didn't finish. var lastText string _ = s.pool.QueryRow(ctx, ` SELECT content::text FROM agent_messages WHERE session_id = $1 AND role = 'assistant' ORDER BY created_at DESC LIMIT 1`, sessionID).Scan(&lastText) haystack := strings.ToLower(lastText + " " + summary) for _, p := range blockerPatterns { if strings.Contains(haystack, p.pattern) { return p.reason } } return "uncategorized" } // hadEntityWriteback checks whether this session called update_entity_attributes // or create_relationship — used by complete_task to warn the agent when it // forgot to persist entity facts (the #1 cause of knowledge graph drift). func (s *store) hadEntityWriteback(ctx context.Context, sessionID string) bool { if s == nil || sessionID == "" { return true // fail safe: don't warn when we can't check } var count int s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM agent_activity WHERE session_id = $1 AND tool_name IN ('update_entity_attributes', 'create_relationship') AND success = true`, sessionID).Scan(&count) return count > 0 } // hadDiscovery checks whether this session ran `run` successfully against a // real target — i.e. discovered live state (versions, package counts, host // facts, service status) that the DB didn't have. Used by complete_task to // refuse success when discovery happened but no writeback followed (the // knowledge-loop drift the prior warnings failed to close — the agent // ignored advisory text, so D.1 makes it structural). // // Only `run` counts as discovery here, NOT get_entity/list_lxcs/etc. — those // are DB lookups, not new facts. A trivial Q&A ("status of lxc:dns?") that // only calls get_entity is a degenerate case (SOUL.md: "Don't invent // attributes that don't exist") and must NOT be blocked. Only sessions that // actually executed against a live target get the writeback gate. func (s *store) hadDiscovery(ctx context.Context, sessionID string) bool { if s == nil || sessionID == "" { return false // fail safe: don't block when we can't check } var count int s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM agent_activity WHERE session_id = $1 AND tool_name = 'run' AND success = true`, sessionID).Scan(&count) return count > 0 } // sessionGoal returns the session's goal text, empty string if not found. // Used by complete_task to check whether the goal involved a reachability // verification before marking success. func (s *store) sessionGoal(ctx context.Context, sessionID string) string { if s == nil || sessionID == "" { return "" } var goal string s.pool.QueryRow(ctx, `SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`, sessionID).Scan(&goal) return goal } // hadRecentVerification checks whether the session successfully verified // reachability in recent turns — ping_service, or a run with curl/wget that // returned successfully. Used by complete_task as a soft warning when the // goal involved a reachability check but no recent verification occurred. func (s *store) hadRecentVerification(ctx context.Context, sessionID string) bool { if s == nil || sessionID == "" { return true // fail safe: don't warn when we can't check } // Check for ping_service calls in the last 5 activity entries for this session. var pingCount int s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM ( SELECT 1 FROM agent_activity WHERE session_id = $1 AND tool_name = 'ping_service' AND success = true ORDER BY ts DESC LIMIT 5 ) sub`, sessionID).Scan(&pingCount) if pingCount > 0 { return true } // Check for run calls with curl/wget that returned successfully. var curlCount int s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM ( SELECT 1 FROM agent_activity WHERE session_id = $1 AND tool_name = 'run' AND success = true AND (input_summary LIKE '%curl%' OR input_summary LIKE '%wget%') ORDER BY ts DESC LIMIT 10 ) sub`, sessionID).Scan(&curlCount) return curlCount > 0 } // staleGoalSession is a goal-bearing task that's gone idle without reaching // a terminal state — the idle-sweep worker's work list (fix 2+3 of // plans/2026-07-11-task-completion-safety-net.md). type staleGoalSession struct { ID string Goal string CompletionNudges int } // staleGoalSessions finds sessions that framed themselves as a real task // (goal != ”, so the inline safety net in agent.go intentionally left them // alone) but have sat non-terminal past idleThreshold. completion_nudges // tells the caller whether to nudge (0) or give up and auto-close (>=1) — // see processIdleSweep in continue.go. func (s *store) staleGoalSessions(ctx context.Context, idleThreshold time.Duration, limit int) []staleGoalSession { if s == nil { return nil } rows, err := s.pool.Query(ctx, ` SELECT id, goal, completion_nudges FROM agent_sessions WHERE goal <> '' AND status IN ('active', 'planning', 'executing') AND last_active_at < now() - ($1 * interval '1 second') ORDER BY last_active_at LIMIT $2`, idleThreshold.Seconds(), limit) if err != nil { return nil } defer rows.Close() var out []staleGoalSession for rows.Next() { var s staleGoalSession if err := rows.Scan(&s.ID, &s.Goal, &s.CompletionNudges); err == nil { out = append(out, s) } } return out } // bumpCompletionNudge records that the idle sweep nudged a stalled session, // stamping last_active_at so it isn't picked up again until it's genuinely // idle again (a fresh nudge shouldn't fire every tick while the model is // mid-response to the previous one). func (s *store) bumpCompletionNudge(ctx context.Context, sessionID string) error { if s == nil { return nil } _, err := s.pool.Exec(ctx, ` UPDATE agent_sessions SET completion_nudges = completion_nudges + 1, last_active_at = now() WHERE id = $1`, sessionID) return err } // allPlanStepsTerminal reports whether every plan step for this session is in // a terminal state (done/failed/replaced/skipped/blocked) — i.e. no step is // still pending or running. Used by autoCompleteIfPlanDone to auto-close a // task when the agent did all the work but forgot to call complete_task. // Returns false if there are no plan steps at all (no plan was proposed). func (s *store) allPlanStepsTerminal(ctx context.Context, sessionID string) bool { if s == nil || sessionID == "" || sessionID == "ephemeral" { return false } var total, terminal int if err := s.pool.QueryRow(ctx, `SELECT COUNT(*), COUNT(*) FILTER (WHERE status IN ('done', 'failed', 'replaced', 'skipped', 'blocked')) FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&total, &terminal); err != nil { return false } return total > 0 && total == terminal } // hasPendingApprovals reports whether this session has any executions in // pending_approval state. Used by autoCompleteIfPlanDone to avoid closing a // session that's blocked waiting for operator approval — the agent hit the // P5 gate and can't continue until the operator responds. func (s *store) hasPendingApprovals(ctx context.Context, sessionID string) bool { if s == nil || sessionID == "" || sessionID == "ephemeral" { return false } var count int s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM nomos_plan_executions pe JOIN executions ex ON ex.entity_id = pe.execution_id WHERE pe.session_id = $1 AND ex.status = 'pending_approval'`, sessionID).Scan(&count) return count > 0 } // planStep is a persisted plan step, as returned to the frontend for hydration // (the panel otherwise only sees steps live via plan.proposed/plan.step.*). type planStep struct { ID string `json:"id"` Seq int `json:"seq"` Title string `json:"title"` Detail string `json:"detail"` Status string `json:"status"` ExecutionID *string `json:"execution_id,omitempty"` TargetSlug *string `json:"target_slug,omitempty"` StartedAt *string `json:"started_at,omitempty"` FinishedAt *string `json:"finished_at,omitempty"` Generation int `json:"generation"` } // getPlanSteps returns a task's plan in order — REST hydration for the context // panel when it first opens a task (live events only carry deltas from then on). // By default only the CURRENT (MAX) generation is returned — the panel shows the // live plan, not an archaeological record of every superseded generation. Pass // all=true for the audit/eval view that needs every generation (the // plan_generations assertion counts distinct generations across the full set). func (s *store) getPlanSteps(ctx context.Context, sessionID string, all bool) ([]planStep, error) { if s == nil { return nil, nil } genFilter := "" if !all { genFilter = "AND generation = (SELECT MAX(generation) FROM session_plan_steps WHERE session_id = $1)" } rows, err := s.pool.Query(ctx, ` SELECT id::text, seq, title, detail, status, execution_id::text, target_slug, started_at::text, finished_at::text, generation FROM session_plan_steps WHERE session_id = $1 `+genFilter+` ORDER BY generation, seq`, sessionID) if err != nil { return nil, err } defer rows.Close() var out []planStep for rows.Next() { var st planStep var execID, target, started, finished *string if err := rows.Scan(&st.ID, &st.Seq, &st.Title, &st.Detail, &st.Status, &execID, &target, &started, &finished, &st.Generation); err != nil { return nil, err } st.ExecutionID, st.TargetSlug, st.StartedAt, st.FinishedAt = execID, target, started, finished out = append(out, st) } return out, rows.Err() } // sessionQuestion is a persisted question, as returned to the frontend. type sessionQuestion struct { ID string `json:"id"` Prompt string `json:"prompt"` Context map[string]any `json:"context"` Status string `json:"status"` Answer *string `json:"answer,omitempty"` CreatedAt string `json:"created_at"` AnsweredAt *string `json:"answered_at,omitempty"` } // getQuestions returns a task's questions (open and answered) newest-first — // REST hydration for the context panel's pinned question card and history. func (s *store) getQuestions(ctx context.Context, sessionID string) ([]sessionQuestion, error) { if s == nil { return nil, nil } rows, err := s.pool.Query(ctx, ` SELECT id::text, prompt, context, status, answer, created_at::text, answered_at::text FROM session_questions WHERE session_id = $1 ORDER BY created_at DESC`, sessionID) if err != nil { return nil, err } defer rows.Close() var out []sessionQuestion for rows.Next() { var q sessionQuestion var ctxJSON []byte var answer, answeredAt *string if err := rows.Scan(&q.ID, &q.Prompt, &ctxJSON, &q.Status, &answer, &q.CreatedAt, &answeredAt); err != nil { return nil, err } json.Unmarshal(ctxJSON, &q.Context) q.Answer, q.AnsweredAt = answer, answeredAt out = append(out, q) } return out, rows.Err() } // askOperator records a structured decision the agent needs from the operator, // moves the task to awaiting_input, and emits question.raised so the context // panel pins it. qctx carries {why, options, entities}. Returns the question id. func (s *store) askOperator(ctx context.Context, sessionID, prompt string, qctx map[string]any) (string, error) { if s == nil || sessionID == "" || sessionID == "ephemeral" { return "", nil } ctxJSON, _ := json.Marshal(qctx) var qid uuid.UUID if err := s.pool.QueryRow(ctx, ` INSERT INTO session_questions (session_id, prompt, context) VALUES ($1, $2, $3) RETURNING id`, sessionID, prompt, string(ctxJSON)).Scan(&qid); err != nil { return "", err } s.pool.Exec(ctx, `UPDATE agent_sessions SET status = 'awaiting_input', last_active_at = now() WHERE id = $1`, sessionID) data := map[string]any{"question_id": qid.String(), "prompt": prompt} for k, v := range qctx { data[k] = v } _ = observability.Event(ctx, sqlcgen.New(s.pool), "question.raised", s.taskEntityPtr(ctx, sessionID), "warning", "nomos", sessionID, data) return qid.String(), nil } // openQuestionID returns the id of the session's open question, or "". Used to // auto-close a pending question when the operator answers via a plain chat reply. func (s *store) openQuestionID(ctx context.Context, sessionID string) string { if s == nil || sessionID == "" || sessionID == "ephemeral" { return "" } var qid string s.pool.QueryRow(ctx, `SELECT id::text FROM session_questions WHERE session_id = $1 AND status = 'open' ORDER BY created_at DESC LIMIT 1`, sessionID).Scan(&qid) return qid } // getQuestion returns a question's prompt, answer, and session — used to build // the resume note when the operator answers via the panel. func (s *store) getQuestion(ctx context.Context, questionID string) (prompt, answer, sessionID string) { if s == nil || questionID == "" { return "", "", "" } qid, err := uuid.Parse(questionID) if err != nil { return "", "", "" } s.pool.QueryRow(ctx, `SELECT prompt, COALESCE(answer, ''), session_id::text FROM session_questions WHERE id = $1`, qid).Scan(&prompt, &answer, &sessionID) return } // answerQuestion records the operator's answer, returns the task to executing, // and emits question.answered. It does NOT itself resume the agent — the caller // decides: a chat reply IS the resuming turn, while a panel answer triggers a // continuation. func (s *store) answerQuestion(ctx context.Context, sessionID, questionID, answer string) error { if s == nil || sessionID == "" || sessionID == "ephemeral" || questionID == "" { return nil } qid, err := uuid.Parse(questionID) if err != nil { return err } if _, err := s.pool.Exec(ctx, ` UPDATE session_questions SET status = 'answered', answer = $2, answered_at = now() WHERE id = $1 AND status = 'open'`, qid, answer); err != nil { return err } s.pool.Exec(ctx, `UPDATE agent_sessions SET status = 'executing', last_active_at = now() WHERE id = $1`, sessionID) _ = observability.Event(ctx, sqlcgen.New(s.pool), "question.answered", s.taskEntityPtr(ctx, sessionID), "info", "nomos", sessionID, map[string]any{"question_id": questionID, "answer": answer}) return nil } // knowledgeSlugRe matches a nomos knowledge doc slug (:nomos/) as // printed in upsert_knowledge's result text. var knowledgeSlugRe = regexp.MustCompile(`[a-z]+:nomos/[a-z0-9-]+`) // linkKnowledgeToTask runs after a successful upsert_knowledge call within a // task: it links the created knowledge doc to the task entity (documents) so // get_relations(task) surfaces what the task learned, and publishes // knowledge.recorded for the live panel. Best-effort. The doc is ALSO linked to // the entity it's "about" by upsert_knowledge itself — that about-link is the // retrieval path future tasks use (get_entity_knowledge); this task-link is for // the task's own outcome/knowledge view. func (s *store) linkKnowledgeToTask(ctx context.Context, sessionID, resultText string) { if s == nil || sessionID == "" || sessionID == "ephemeral" { return } slug := knowledgeSlugRe.FindString(resultText) if slug == "" { return } var taskID, docID uuid.UUID if err := s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&taskID); err != nil || taskID == uuid.Nil { return } if err := s.pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&docID); err != nil { return } s.pool.Exec(ctx, ` INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) SELECT $1, $2, 'documents', '{"by":"nomos"}'::jsonb, now() WHERE NOT EXISTS ( SELECT 1 FROM relationships WHERE source_id = $1 AND target_id = $2 AND type = 'documents' AND valid_to IS NULL)`, docID, taskID) _ = observability.Event(ctx, sqlcgen.New(s.pool), "knowledge.recorded", &docID, "info", "nomos", sessionID, map[string]any{"slug": slug}) } func (s *store) updateSessionTitle(ctx context.Context, id, title string) error { if s == nil { return nil } _, err := s.pool.Exec(ctx, `UPDATE agent_sessions SET title = $1 WHERE id = $2`, title, id) return err } // resolveAgentID looks up the UUID of the agent entity (e.g. "agent:nomos"). // Returns uuid.Nil if the store is absent or the slug is unknown. func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID { if s == nil { return uuid.Nil } var id uuid.UUID if err := s.pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&id); err != nil { return uuid.Nil } return id } // linkExecution records that a gated execution was initiated by a chat // session, so the auto-continuation worker can feed its result back to that // session when it finishes. Idempotent — the same execution may appear in // several tool results across a turn. func (s *store) linkExecution(ctx context.Context, execID uuid.UUID, sessionID string) { if s == nil || execID == uuid.Nil || sessionID == "" || sessionID == "ephemeral" { return } s.pool.Exec(ctx, ` INSERT INTO nomos_plan_executions (execution_id, session_id) VALUES ($1, $2) ON CONFLICT (execution_id) DO NOTHING`, execID, sessionID) } // taskSlugRe matches an entity slug: a lowercase type prefix then colon- // separated segments (host:hubris, lxc:caddy, check:ping:8cf). Mirrors the // frontend SessionGraph regex so the panel and the involves-graph agree on // what counts as an entity reference. var taskSlugRe = regexp.MustCompile(`[a-z][a-z-]*:[a-z0-9][a-z0-9._/-]*(?::[a-z0-9._/-]+)*`) // touchExcludedTypes are entity types too noisy to record as task involvement: // a health question names dozens of check:… slugs, executions/tasks are // bookkeeping, not things the task "worked on". var touchExcludedTypes = map[string]bool{"check": true, "execution": true, "task": true} // recordTouched links the task to every entity referenced in a tool call's // args (task —involves→ entity) and publishes one entity.touched event per // entity so the live context panel can pulse it. Best-effort: it never blocks // or fails the tool call. Only args are inspected — what the agent chose to act // on — never results, since a single bulk query result would otherwise pull the // whole fleet into the task's graph. func (s *store) recordTouched(ctx context.Context, sessionID, toolName string, args map[string]any) { if s == nil || sessionID == "" || sessionID == "ephemeral" || len(args) == 0 { return } slugs := map[string]struct{}{} collectTaskSlugs(args, slugs) if len(slugs) == 0 { return } var taskEntityID uuid.UUID if err := s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&taskEntityID); err != nil || taskEntityID == uuid.Nil { return // no task entity to anchor edges on } // One batched lookup instead of a SELECT per slug — a tool call naming // several entities (e.g. a multi-target comparison) used to issue N // round-trips here for N slugs found in its args. slugList := make([]string, 0, len(slugs)) for slug := range slugs { slugList = append(slugList, slug) } rows, err := s.pool.Query(ctx, `SELECT id, type, slug FROM entities WHERE slug = ANY($1)`, slugList) if err != nil { return } type found struct { id uuid.UUID etype string } matched := make(map[string]found, len(slugList)) for rows.Next() { var f found var slug string if rows.Scan(&f.id, &f.etype, &slug) == nil { matched[slug] = f } } rows.Close() if err := rows.Err(); err != nil { return } q := sqlcgen.New(s.pool) for slug, f := range matched { if touchExcludedTypes[f.etype] || f.id == taskEntityID { continue } // Idempotent involves edge (task → entity), same guard as upsert_knowledge. s.pool.Exec(ctx, ` INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) SELECT $1, $2, 'involves', '{"by":"nomos"}'::jsonb, now() WHERE NOT EXISTS ( SELECT 1 FROM relationships WHERE source_id = $1 AND target_id = $2 AND type = 'involves' AND valid_to IS NULL)`, taskEntityID, f.id) // Live pulse for the panel. correlation_id = sessionID lets the frontend // filter to the active task. _ = observability.Event(ctx, q, "entity.touched", &f.id, "info", "nomos", sessionID, map[string]any{"slug": slug, "tool": toolName}) } } // collectTaskSlugs recursively pulls entity slugs out of tool-call args, // mirroring the frontend's collectSlugs so both sides see the same references. func collectTaskSlugs(v any, out map[string]struct{}) { switch t := v.(type) { case string: for _, m := range taskSlugRe.FindAllString(t, -1) { out[strings.TrimRight(m, ".,;)]")] = struct{}{} } case []any: for _, e := range t { collectTaskSlugs(e, out) } case map[string]any: for _, e := range t { collectTaskSlugs(e, out) } } } // pendingContinuation is one finished execution whose result hasn't yet been // fed back to its originating session. type pendingContinuation struct { ExecID uuid.UUID SessionID string Status string Result string Action string } // pendingContinuations returns executions that have reached a terminal state // but haven't been continued yet — the worker's work list. Bounded so one // tick can't fan out unboundedly. func (s *store) pendingContinuations(ctx context.Context, limit int) []pendingContinuation { if s == nil { return nil } rows, err := s.pool.Query(ctx, ` SELECT l.execution_id, l.session_id, e.status, COALESCE(e.result::text, ''), COALESCE(e.action, '') FROM nomos_plan_executions l JOIN executions e ON e.entity_id = l.execution_id WHERE l.continued_at IS NULL AND e.status IN ('completed', 'failed', 'cancelled', 'denied', 'revoked') ORDER BY l.created_at LIMIT $1`, limit) if err != nil { return nil } defer rows.Close() var out []pendingContinuation for rows.Next() { var p pendingContinuation if err := rows.Scan(&p.ExecID, &p.SessionID, &p.Status, &p.Result, &p.Action); err == nil { out = append(out, p) } } return out } // markContinued stamps an execution as fed-back so the worker won't process it // again (prevents an auto-continuation loop). func (s *store) markContinued(ctx context.Context, execID uuid.UUID) { if s == nil { return } s.pool.Exec(ctx, `UPDATE nomos_plan_executions SET continued_at = now() WHERE execution_id = $1`, execID) } // assentWindowActive reports whether THIS TASK currently has an open assent // window — the scope gate for auto-continuation. Scoped by session, not just // agent: with a single agent:nomos entity serving every concurrent task, an // agent-only key would let approving Task A's plan silently auto-run // unapproved config-mutation actions in a concurrently-running Task B. We // only auto-continue executions that are part of THIS session's approved // plan, never a stray action from another task riding the same window. func (s *store) assentWindowActive(ctx context.Context, agentID uuid.UUID, sessionID string) bool { if s == nil || agentID == uuid.Nil || sessionID == "" { return false // fail closed: no session to scope to means no window } var expires time.Time key := assentWindowKey(agentID, sessionID) if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`, key).Scan(&expires); err != nil { return false } return time.Now().Before(expires) } // assentWindowKey scopes the grant to one agent AND one session/task — see // assentWindowActive. Must match internal/mcp/server.go's copy (mirrored // there, not shared, since the two are separate Go packages/binaries reading // the same autonomy_settings row). func assentWindowKey(agentID uuid.UUID, sessionID string) string { return "assent_window.agent:" + agentID.String() + ".session:" + sessionID } // destructiveWindowDuration is intentionally shorter than the general assent // window (30 min): it's a narrow, scoped grant for a multi-step DESTRUCTIVE // recovery (e.g. "stop then destroy this specific half-provisioned // container"), not a standing license to destroy things. const destructiveWindowDuration = 15 * time.Minute // destructiveWindowKey scopes the grant to one agent, one target entity, AND // one session/task — an explicit typed confirmation ("I confirm") for a // destructive action on target X in task A must never be read as authorizing // a destructive action on target X from a DIFFERENT concurrently-running // task B, even though both share the same agent identity. func destructiveWindowKey(agentID uuid.UUID, targetSlug, sessionID string) string { return "destructive_window.agent:" + agentID.String() + ".target:" + targetSlug + ".session:" + sessionID } // openDestructiveWindow records a short, target-and-session-scoped grant // after an operator's EXPLICIT typed confirmation (never loose assent) // authorized a destructive action. Real case this exists for: recovering a // failed destroy took "stop" (destructive) then "destroy" (destructive) — // same container, two separate typed-confirmation round trips, because each // was gated independently. One explicit confirmation on a target should // cover the short follow-up sequence needed to finish what was just // confirmed — but only within the task that got the confirmation. func (s *store) openDestructiveWindow(ctx context.Context, agentID uuid.UUID, targetSlug, sessionID string) { if s == nil || agentID == uuid.Nil || targetSlug == "" || sessionID == "" { return } expires := time.Now().Add(destructiveWindowDuration).UTC().Format(time.RFC3339) s.pool.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2`, destructiveWindowKey(agentID, targetSlug, sessionID), expires) } // destructiveWindowActive reports whether target has a live, explicitly- // confirmed destructive grant for this agent within this session/task. func (s *store) destructiveWindowActive(ctx context.Context, agentID uuid.UUID, targetSlug, sessionID string) bool { if s == nil || agentID == uuid.Nil || targetSlug == "" || sessionID == "" { return false } var expires time.Time if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`, destructiveWindowKey(agentID, targetSlug, sessionID)).Scan(&expires); err != nil { return false } return time.Now().Before(expires) } // executionTarget resolves the target entity slug for an execution — used to // scope the destructive window to the right entity when a chat-assent typed // confirmation grants a destructive execution. func (s *store) executionTarget(ctx context.Context, execID uuid.UUID) string { if s == nil { return "" } var slug string s.pool.QueryRow(ctx, ` SELECT e.slug FROM executions ex JOIN entities e ON e.id = ex.target_entity_id WHERE ex.entity_id = $1`, execID).Scan(&slug) return slug } // entityArgKeys lists tool-argument keys, in priority order, that commonly // carry the target entity's slug or UUID. Tool input schemas aren't // consistent about naming this (target, entity_slug, slug, service_slug, // lxc_slug, entity_id all appear across the MCP tool registrations in // internal/mcp/server.go), so this is a best-effort lookup used to tag // agent_activity rows with the entity a tool call acted on. var entityArgKeys = []string{ "target", "entity_slug", "slug", "slug_or_id", "service_slug", "lxc_slug", "entity_id", "about", } // resolveArgEntityID best-effort resolves the entity a tool call acted on // from its arguments, trying entityArgKeys in order. Returns uuid.Nil if no // key is present or none resolves to a known entity. func (s *store) resolveArgEntityID(ctx context.Context, args map[string]any) uuid.UUID { if s == nil { return uuid.Nil } for _, key := range entityArgKeys { v, _ := args[key].(string) if v == "" { continue } if u, err := uuid.Parse(v); err == nil { return u } var id uuid.UUID if err := s.pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", v).Scan(&id); err == nil { return id } } return uuid.Nil } // logActivity records a tool call. agent_id is the agent entity UUID and is // NOT NULL in the schema, so we skip logging when it can't be resolved. // The (nullable) session_id column carries the conversation id. args is the // tool call's own arguments, used to best-effort tag the row with the // entity it acted on (see resolveArgEntityID). func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName string, args map[string]any, inputSummary, outputSummary string, durationMs int, success bool, correlationID string, tokenCount int) { if s == nil || agentID == uuid.Nil { return } entityID := s.resolveArgEntityID(ctx, args) var entityIDArg any if entityID != uuid.Nil { entityIDArg = entityID } s.pool.Exec(ctx, ` INSERT INTO agent_activity (agent_id, session_id, activity_type, tool_name, entity_id, input_summary, output_summary, duration_ms, success, correlation_id, token_count) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, agentID, sessionID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary, durationMs, success, correlationID, tokenCount) }