package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"regexp"
	"strings"
	"time"

	"github.com/dtoro/oikos/internal/adapters/postgres/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:<session-id> 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)
	}
	// Graph edge: task —involves→ agent:nomos (gives every task at least one
	// edge from creation, even if no run calls are ever made).
	s.pool.Exec(ctx, `INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
		SELECT $1, id, 'involves', '{"by":"nomos"}'::jsonb, now()
		FROM entities WHERE slug = 'agent:nomos'
		AND NOT EXISTS (
			SELECT 1 FROM relationships r
			WHERE r.source_id = $1 AND r.target_id = entities.id AND r.type = 'involves' AND r.valid_to IS NULL)`,
		entityID)
	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:<id> 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(&currentStatus); 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(&currentStatus); 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 (<kind>:nomos/<title>) 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)
}
