Files
oikos/cmd/nomos/store.go
dtoro 7ef8446825
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
fix(agent+ui): whatsapp session audit — approvals, stuck indicator, stale execs
P1: add docker compose (logs|ps|top|config|images|port|cp) to read-only
allowlist. docker compose logs was classified as config_mutation, causing
individual approval cards for read-only inspection commands.

P2: remove approval entries from activityLog. They were always status=running
and never transitioned to done (the derived store builds from tool-call
text, not execution status), causing AgentIndicator to latch onto a stale
'Approval: ...' entry and never clear — even after the session completed.

P3: remove InlineApproval from Chat.svelte. The green 'Completed in 1s on
lxc:...' boxes were noise in the chat stream. Approval UX belongs in the
Operations page (already has it via Ops.svelte), not inline in the chat.

P4: stale execution cleanup. Startup sweep (mark >1hr non-terminal as
cancelled) + 5-min periodic sweep (mark >10min non-terminal as cancelled).
98 orphaned executions accumulated from eval testing (39 running from
apt_upgrade:audit timeouts, 19 pending_approval, 3 approved).

P5: refuse second config_mutation run when an approval is already pending
for the session. Without this, the agent queues N individual approvals
before the operator can respond — confirmed in session 20757eb9 (two
approval cards for what should have been one plan-level approval).

VERSION 0.7.0 → 0.7.1
2026-07-15 22:19:30 +02:00

1456 lines
57 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/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")
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.
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"`
CreatedAt time.Time `json:"created_at"`
LastActiveAt time.Time `json:"last_active_at"`
}
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)
}
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) {
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),
s.created_at, s.last_active_at
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
ORDER BY s.last_active_at DESC LIMIT 50`)
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.CreatedAt, &sess.LastActiveAt); 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 id, title, actor, goal, status, COALESCE(outcome, ''), summary,
COALESCE(entity_id::text, ''), 0, created_at, last_active_at
FROM agent_sessions WHERE id = $1`, id).
Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
&sess.CreatedAt, &sess.LastActiveAt)
if err != nil {
return nil, err
}
return &sess, nil
}
// 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()
}
// 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.
func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil
}
// 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.
s.pool.Exec(ctx,
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
sessionID)
if _, err := s.pool.Exec(ctx,
`UPDATE agent_sessions SET goal = $2, status = 'executing', 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)
_ = 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 startSeq int
var anyStarted bool
// `replaced` steps (from a prior plan generation superseded by a
// follow-up sub-task — see reopenSession) are excluded: they prove a
// prior plan was completed and superseded, not that a plan is in flight.
// Without this exclusion, reopenSession's `replaced` marking would be
// useless — propose_plan would still refuse on the follow-up.
if err := tx.QueryRow(ctx, `
SELECT COALESCE(max(seq), 0), COALESCE(bool_or(status NOT IN ('pending', 'replaced')), false)
FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&startSeq, &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).
// This preserves the rows for the generation counter (MAX(generation)+1
// below) and the plan_generations eval assertion. Without this, a first
// plan that was proposed but never executed (all pending) would be
// wiped, resetting the counter to 1 — making a follow-up's plan look
// like generation 1 instead of 2. `replaced` steps are excluded from
// the anyStarted check above, so they don't block the fresh proposal.
if _, err := tx.Exec(ctx,
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status = 'pending'`,
sessionID); err != nil {
return nil, err
}
// startSeq keeps the max(seq) from the query above: if prior steps
// exist (replaced or done), the new generation's steps start after them
// (no seq collisions across generations). If no rows exist (first plan),
// startSeq is 0 and the first step is seq 1.
// Resolve the generation number for this plan. Generation 1 is the
// initial plan; a genuine revise (which currently goes through the same
// fresh-start path above because all steps were pending) resets to 1
// since the DELETE wiped the prior rows. The column is wired here so a
// future explicit mid-flight revise path can increment it.
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
}
// After the DELETE above, no rows remain, so MAX(generation) is NULL →
// nextGen = 1. (Keep the query for the future revise path; it's cheap.)
out := make([]map[string]any, 0, len(steps))
for i, st := range steps {
var targetSlug *string
if st.TargetSlug != "" {
targetSlug = &st.TargetSlug
}
seq := startSeq + 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 string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil
}
stamp := ""
switch status {
case "running":
stamp = ", started_at = COALESCE(started_at, now())"
case "done", "failed", "skipped", "blocked", "replaced":
stamp = ", finished_at = now()"
}
// Completion ordering: for terminal states, check that no earlier step
// is still pending. Running steps can start out of order (the agent
// may dispatch parallel work), but completion must be sequential.
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 seq < $2 AND status = 'pending'`,
sessionID, 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.
if err := s.pool.QueryRow(ctx, `
UPDATE session_plan_steps
SET status = $3, execution_id = COALESCE($4, execution_id)`+stamp+`
WHERE session_id = $1 AND seq = $2
RETURNING id, target_slug`, sessionID, seq, status, execPtr).Scan(&stepID, &targetSlug); err != nil {
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)
// 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"
}
if _, err := s.pool.Exec(ctx, `
UPDATE agent_sessions SET status = $2, outcome = $3, summary = $4, last_active_at = now()
WHERE id = $1`, sessionID, status, outcome, summary); 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})
return nil
}
// 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
}
// 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
}
// 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).
func (s *store) getPlanSteps(ctx context.Context, sessionID string) ([]planStep, error) {
if s == nil {
return nil, nil
}
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 ORDER BY 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) {
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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
agentID, sessionID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary,
durationMs, success, correlationID)
}