Files
oikos/cmd/nomos/store.go
dtoro 7d6a3320d4
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
fix: add involves edge from task to agent:nomos at creation
createTaskEntity creates the task entity but never adds any graph
edges. The involves edges are only added by the run handler, so
sessions that call set_goal but never make a run call leave orphan
task entities with zero relationships.

Adds an idempotent involves edge from the new task to agent:nomos
at creation time, matching the same pattern used for run's involves
edges in server.go.
2026-08-11 21:58:18 +02:00

2146 lines
86 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:<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)
}