feat: Phase 9 gaps closed — ApprovalService.Decide convergence, execlog fold, execworker poller
Some checks are pending
ci / build-test (push) Waiting to run
ci / docker-build (push) Waiting to run

- ApprovalService (core/app/approval.go) + ApprovalRepo (postgres adapter) with
  full decide transaction: HMAC token verify, approval flip, execution un-gate,
  session-scoped window keys (+session suffix matching GovernanceStore gate),
  nomos session flip, audit+event on failure abort. httpapi DecideApproval now
  a thin presenter delegating to the service. ListPending payload format fixed
  (json.Unmarshal not raw-wrap).
- execlog folded into postgres adapter: internal/execlog deleted, NewExecutionLog
  / ReadExecutionLog live in the db package, callers updated (mcp, httpapi).
- execworker poller over ExecutionService.DispatchQueued: advisory lock leak
  fixed (defer/recover per execution), correlation_id preserved via Finalize
  event emission (ExecRunRepo.Finalize now emits execution.{status} with
  correlation_id from the row).
- Phase 8 session export-rename completed: Store, New, and all 53 methods
  exported; cmd/nomos/ agent.go fixed to use session.PendingContinuation etc.
- Coverage gates: ExecutionService.Submit 93.1%, PolicyService.Decide 100%.
- Plans index updated, VERSION bumped to 0.36.0.
This commit is contained in:
2026-08-16 12:29:59 +02:00
parent 986937799a
commit b98d7c24bf
27 changed files with 1161 additions and 600 deletions

View File

@@ -1,4 +1,4 @@
package main
package session
import (
"context"
@@ -14,30 +14,31 @@ import (
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
const maxToolResultSize = 4096
// errPlanInFlight is returned by proposePlan when called again after a step
// 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")
var ErrPlanInFlight = errors.New("plan already in flight")
// errPlanStepNotFound is returned by updatePlanStep when no step matches the
// 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")
var ErrPlanStepNotFound = errors.New("plan step not found in current generation")
type store struct {
type Store struct {
pool *pgxpool.Pool
}
func newStore(ctx context.Context, databaseURL string) (*store, error) {
func New(ctx context.Context, databaseURL string) (*Store, error) {
if databaseURL == "" {
return nil, nil
}
@@ -49,8 +50,8 @@ func newStore(ctx context.Context, databaseURL string) (*store, error) {
pool.Close()
return nil, fmt.Errorf("ping db: %w", err)
}
s := &store{pool: pool}
s.cleanupStaleExecutions(ctx, time.Hour)
s := &Store{pool: pool}
s.CleanupStaleExecutions(ctx, time.Hour)
return s, nil
}
@@ -62,7 +63,7 @@ func newStore(ctx context.Context, databaseURL string) (*store, error) {
// `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 {
func (s *Store) CleanupStaleExecutions(ctx context.Context, maxAge time.Duration) int {
if s == nil {
return 0
}
@@ -85,12 +86,21 @@ func (s *store) cleanupStaleExecutions(ctx context.Context, maxAge time.Duration
return n
}
func (s *store) close() {
func (s *Store) Close() {
if s.pool != nil {
s.pool.Close()
}
}
// Exec runs a raw SQL query against the store's pool. Used by the agent to
// write autonomy_settings rows directly.
func (s *Store) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
if s == nil {
return pgconn.CommandTag{}, nil
}
return s.pool.Exec(ctx, sql, args...)
}
// 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.
@@ -104,7 +114,7 @@ func (s *store) close() {
// 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 {
type Session struct {
ID string `json:"id"`
Title string `json:"title"`
Actor string `json:"actor"`
@@ -129,7 +139,7 @@ type session struct {
DurationSeconds int `json:"duration_seconds,omitempty"`
}
type message struct {
type Message struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
Role string `json:"role"`
@@ -137,9 +147,9 @@ type message struct {
CreatedAt time.Time `json:"created_at"`
}
func (s *store) createSession(ctx context.Context, title string) (*session, error) {
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
return &Session{ID: "ephemeral", Title: title, Actor: "agent:nomos", Status: "active"}, nil
}
var id string
err := s.pool.QueryRow(ctx,
@@ -152,7 +162,7 @@ func (s *store) createSession(ctx context.Context, title string) (*session, erro
// 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",
return &Session{ID: id, Title: title, Actor: "agent:nomos", Status: "active",
EntityID: entityID, CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil
}
@@ -160,7 +170,7 @@ func (s *store) createSession(ctx context.Context, title string) (*session, erro
// 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 {
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
@@ -191,7 +201,7 @@ func (s *store) createTaskEntity(ctx context.Context, sessionID, title string) s
return entityID.String()
}
func (s *store) saveMessage(ctx context.Context, sessionID, role string, content json.RawMessage) error {
func (s *Store) SaveMessage(ctx context.Context, sessionID, role string, content json.RawMessage) error {
if s == nil {
return nil
}
@@ -208,7 +218,7 @@ func (s *store) saveMessage(ctx context.Context, sessionID, role string, content
// 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) {
func (s *Store) InsertMessageReturningID(ctx context.Context, sessionID, role string, content json.RawMessage) (uuid.UUID, error) {
if s == nil {
return uuid.Nil, nil
}
@@ -219,7 +229,7 @@ func (s *store) insertMessageReturningID(ctx context.Context, sessionID, role st
return id, err
}
func (s *store) updateMessage(ctx context.Context, id uuid.UUID, content json.RawMessage) error {
func (s *Store) UpdateMessage(ctx context.Context, id uuid.UUID, content json.RawMessage) error {
if s == nil || id == uuid.Nil {
return nil
}
@@ -234,19 +244,19 @@ func (s *store) updateMessage(ctx context.Context, id uuid.UUID, content json.Ra
// 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) {
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,
// 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 {
func (s *Store) lastUserMessage(ctx context.Context, sessionID string) string {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return ""
}
@@ -272,7 +282,7 @@ func (s *store) lastUserMessage(ctx context.Context, sessionID string) string {
// 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 {
func (s *Store) HasPlanInFlight(ctx context.Context, sessionID string) bool {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return false
}
@@ -292,12 +302,12 @@ func (s *store) hasPlanInFlight(ctx context.Context, sessionID string) bool {
// 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 {
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)
inFlight := s.HasPlanInFlight(ctx, sessionID)
if last == "" && !inFlight {
return base
}
@@ -346,21 +356,21 @@ func truncateToolResults(content json.RawMessage) json.RawMessage {
return out
}
func (s *store) touchSession(ctx context.Context, id string) {
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})
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 {
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)
@@ -370,7 +380,7 @@ type listFilter struct {
Limit int // default 50, clamped by the handler
}
func (s *store) listSessionsFiltered(ctx context.Context, f listFilter) ([]session, error) {
func (s *Store) ListSessionsFiltered(ctx context.Context, f ListFilter) ([]Session, error) {
if s == nil {
return nil, nil
}
@@ -474,9 +484,9 @@ func (s *store) listSessionsFiltered(ctx context.Context, f listFilter) ([]sessi
}
defer rows.Close()
var out []session
var out []Session
for rows.Next() {
var sess session
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,
@@ -488,11 +498,11 @@ func (s *store) listSessionsFiltered(ctx context.Context, f listFilter) ([]sessi
return out, rows.Err()
}
func (s *store) getSession(ctx context.Context, id string) (*session, error) {
func (s *Store) GetSession(ctx context.Context, id string) (*Session, error) {
if s == nil {
return nil, nil
}
var sess session
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, ''),
@@ -533,7 +543,7 @@ func (s *store) getSession(ctx context.Context, id string) (*session, error) {
// 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) {
func (s *Store) RecentPartialSessions(ctx context.Context, excludeSessionID string, since time.Duration) ([]Session, error) {
if s == nil {
return nil, nil
}
@@ -576,9 +586,9 @@ func (s *store) recentPartialSessions(ctx context.Context, excludeSessionID stri
}
defer rows.Close()
var out []session
var out []Session
for rows.Next() {
var sess session
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,
@@ -596,7 +606,7 @@ func (s *store) recentPartialSessions(ctx context.Context, excludeSessionID stri
// 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) {
func (s *Store) GetMessages(ctx context.Context, sessionID string) ([]Message, error) {
if s == nil {
return nil, nil
}
@@ -608,9 +618,9 @@ func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, e
}
defer rows.Close()
var out []message
var out []Message
for rows.Next() {
var m message
var m Message
if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
return nil, err
}
@@ -644,11 +654,11 @@ type SessionToolCall struct {
// 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) {
func (s *Store) GetSessionToolCalls(ctx context.Context, sessionID string) ([]SessionToolCall, error) {
if s == nil {
return nil, nil
}
msgs, err := s.getMessages(ctx, sessionID)
msgs, err := s.GetMessages(ctx, sessionID)
if err != nil {
return nil, err
}
@@ -699,7 +709,7 @@ func (s *store) getSessionToolCalls(ctx context.Context, sessionID string) ([]Se
// 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) {
func (s *Store) GetRecentMessages(ctx context.Context, sessionID string, limit int) (msgs []Message, truncated bool, err error) {
if s == nil {
return nil, false, nil
}
@@ -712,9 +722,9 @@ func (s *store) getRecentMessages(ctx context.Context, sessionID string, limit i
}
defer rows.Close()
var out []message
var out []Message
for rows.Next() {
var m message
var m Message
if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
return nil, false, err
}
@@ -736,7 +746,7 @@ func (s *store) getRecentMessages(ctx context.Context, sessionID string, limit i
return out, truncated, nil
}
func (s *store) deleteSession(ctx context.Context, id string) error {
func (s *Store) DeleteSession(ctx context.Context, id string) error {
if s == nil {
return nil
}
@@ -763,9 +773,9 @@ func (s *store) deleteSession(ctx context.Context, id string) error {
return nil
}
// taskEntityPtr returns the task entity id for a session, or nil — used as the
// 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 {
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 {
@@ -795,7 +805,7 @@ func (s *store) taskEntityPtr(ctx context.Context, sessionID string) *uuid.UUID
// 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 {
func (s *Store) SetGoal(ctx context.Context, sessionID, goal string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil
}
@@ -845,7 +855,7 @@ func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
//
// 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 {
func (s *Store) ReopenSession(ctx context.Context, sessionID string) bool {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return false
}
@@ -874,7 +884,7 @@ func (s *store) reopenSession(ctx context.Context, sessionID string) bool {
}
// planStepInput is one step as the agent proposes it.
type planStepInput struct {
type PlanStepInput struct {
Title string
Detail string
TargetSlug string
@@ -895,7 +905,7 @@ type planStepInput struct {
// 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) {
func (s *Store) ProposePlan(ctx context.Context, sessionID string, steps []PlanStepInput) ([]map[string]any, error) {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil, nil
}
@@ -920,7 +930,7 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
// 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
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)
@@ -992,7 +1002,7 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
// 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 {
func (s *Store) UpdatePlanStep(ctx context.Context, sessionID string, seq int, status, execID, replacedReason string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil
}
@@ -1011,7 +1021,7 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
return err
}
if curGen == 0 {
return errPlanStepNotFound
return ErrPlanStepNotFound
}
stamp := ""
switch status {
@@ -1044,7 +1054,7 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
// 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).
// resurrecting it. No matching row → ErrPlanStepNotFound (stale/out-of-range seq).
if status == "replaced" && replacedReason != "" {
err := s.pool.QueryRow(ctx, `
UPDATE session_plan_steps
@@ -1053,7 +1063,7 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
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 ErrPlanStepNotFound
}
return err
}
@@ -1065,7 +1075,7 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr).Scan(&stepID, &targetSlug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return errPlanStepNotFound
return ErrPlanStepNotFound
}
return err
}
@@ -1090,21 +1100,21 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
return nil
}
// errTaskAlreadyComplete is returned by completeTask when the session is
// 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")
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
// 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 {
func (s *Store) CompleteTask(ctx context.Context, sessionID, outcome, summary string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil
}
@@ -1119,7 +1129,7 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
// 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
return ErrTaskAlreadyComplete
}
// Auto-cancel any executions still in pending_approval/approved/queued
@@ -1271,12 +1281,12 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
return nil
}
// autoUpsertKnowledge creates a knowledge entry for a completed session,
// 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) {
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`,
@@ -1335,7 +1345,7 @@ func autoUpsertKnowledge(ctx context.Context, s *store, sessionID, outcome, summ
// 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) {
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)
@@ -1359,7 +1369,7 @@ func writePlanCompletionRate(ctx context.Context, s *store, sessionID string) {
// 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) {
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, `
@@ -1418,7 +1428,7 @@ var blockerPatterns = []struct {
// "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 {
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
@@ -1438,7 +1448,7 @@ func deriveBlocker(ctx context.Context, s *store, sessionID, summary string) str
// 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 {
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
}
@@ -1463,7 +1473,7 @@ func (s *store) hadEntityWriteback(ctx context.Context, sessionID string) bool {
// 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 {
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
}
@@ -1479,7 +1489,7 @@ func (s *store) hadDiscovery(ctx context.Context, sessionID string) bool {
// 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 {
func (s *Store) SessionGoal(ctx context.Context, sessionID string) string {
if s == nil || sessionID == "" {
return ""
}
@@ -1494,7 +1504,7 @@ func (s *store) sessionGoal(ctx context.Context, sessionID string) string {
// 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 {
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
}
@@ -1537,7 +1547,7 @@ type staleGoalSession struct {
// 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 {
func (s *Store) StaleGoalSessions(ctx context.Context, idleThreshold time.Duration, limit int) []staleGoalSession {
if s == nil {
return nil
}
@@ -1563,11 +1573,11 @@ func (s *store) staleGoalSessions(ctx context.Context, idleThreshold time.Durati
return out
}
// bumpCompletionNudge records that the idle sweep nudged a stalled session,
// 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 {
func (s *Store) BumpCompletionNudge(ctx context.Context, sessionID string) error {
if s == nil {
return nil
}
@@ -1582,7 +1592,7 @@ func (s *store) bumpCompletionNudge(ctx context.Context, sessionID string) error
// 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 {
func (s *Store) AllPlanStepsTerminal(ctx context.Context, sessionID string) bool {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return false
}
@@ -1600,7 +1610,7 @@ func (s *store) allPlanStepsTerminal(ctx context.Context, sessionID string) bool
// 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 {
func (s *Store) HasPendingApprovals(ctx context.Context, sessionID string) bool {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return false
}
@@ -1634,7 +1644,7 @@ type planStep struct {
// 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) {
func (s *Store) GetPlanSteps(ctx context.Context, sessionID string, all bool) ([]planStep, error) {
if s == nil {
return nil, nil
}
@@ -1678,7 +1688,7 @@ type sessionQuestion struct {
// 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) {
func (s *Store) GetQuestions(ctx context.Context, sessionID string) ([]sessionQuestion, error) {
if s == nil {
return nil, nil
}
@@ -1707,7 +1717,7 @@ func (s *store) getQuestions(ctx context.Context, sessionID string) ([]sessionQu
// 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) {
func (s *Store) AskOperator(ctx context.Context, sessionID, prompt string, qctx map[string]any) (string, error) {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return "", nil
}
@@ -1730,7 +1740,7 @@ func (s *store) askOperator(ctx context.Context, sessionID, prompt string, qctx
// 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 {
func (s *Store) OpenQuestionID(ctx context.Context, sessionID string) string {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return ""
}
@@ -1742,7 +1752,7 @@ func (s *store) openQuestionID(ctx context.Context, sessionID string) string {
// 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) {
func (s *Store) GetQuestion(ctx context.Context, questionID string) (prompt, answer, sessionID string) {
if s == nil || questionID == "" {
return "", "", ""
}
@@ -1759,7 +1769,7 @@ func (s *store) getQuestion(ctx context.Context, questionID string) (prompt, ans
// 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 {
func (s *Store) AnswerQuestion(ctx context.Context, sessionID, questionID, answer string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" || questionID == "" {
return nil
}
@@ -1789,7 +1799,7 @@ var knowledgeSlugRe = regexp.MustCompile(`[a-z]+:nomos/[a-z0-9-]+`)
// 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) {
func (s *Store) LinkKnowledgeToTask(ctx context.Context, sessionID, resultText string) {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return
}
@@ -1817,7 +1827,7 @@ func (s *store) linkKnowledgeToTask(ctx context.Context, sessionID, resultText s
map[string]any{"slug": slug})
}
func (s *store) updateSessionTitle(ctx context.Context, id, title string) error {
func (s *Store) UpdateSessionTitle(ctx context.Context, id, title string) error {
if s == nil {
return nil
}
@@ -1827,7 +1837,7 @@ func (s *store) updateSessionTitle(ctx context.Context, id, title string) error
// 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 {
func (s *Store) ResolveAgentID(ctx context.Context, slug string) uuid.UUID {
if s == nil {
return uuid.Nil
}
@@ -1839,10 +1849,10 @@ func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID {
}
// 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, 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) {
func (s *Store) LinkExecution(ctx context.Context, execID uuid.UUID, sessionID string) {
if s == nil || execID == uuid.Nil || sessionID == "" || sessionID == "ephemeral" {
return
}
@@ -1868,7 +1878,7 @@ var touchExcludedTypes = map[string]bool{"check": true, "execution": true, "task
// 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) {
func (s *Store) RecordTouched(ctx context.Context, sessionID, toolName string, args map[string]any) {
if s == nil || sessionID == "" || sessionID == "ephemeral" || len(args) == 0 {
return
}
@@ -1953,7 +1963,7 @@ func collectTaskSlugs(v any, out map[string]struct{}) {
// pendingContinuation is one finished execution whose result hasn't yet been
// fed back to its originating session.
type pendingContinuation struct {
type PendingContinuation struct {
ExecID uuid.UUID
SessionID string
Status string
@@ -1964,7 +1974,7 @@ type pendingContinuation struct {
// 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 {
func (s *Store) PendingContinuations(ctx context.Context, limit int) []PendingContinuation {
if s == nil {
return nil
}
@@ -1981,9 +1991,9 @@ func (s *store) pendingContinuations(ctx context.Context, limit int) []pendingCo
return nil
}
defer rows.Close()
var out []pendingContinuation
var out []PendingContinuation
for rows.Next() {
var p pendingContinuation
var p PendingContinuation
if err := rows.Scan(&p.ExecID, &p.SessionID, &p.Status, &p.Result, &p.Action); err == nil {
out = append(out, p)
}
@@ -1993,7 +2003,7 @@ func (s *store) pendingContinuations(ctx context.Context, limit int) []pendingCo
// 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) {
func (s *Store) MarkContinued(ctx context.Context, execID uuid.UUID) {
if s == nil {
return
}
@@ -2001,29 +2011,29 @@ func (s *store) markContinued(ctx context.Context, execID uuid.UUID) {
}
// assentWindowActive reports whether THIS TASK currently has an open assent
// window — the scope gate for auto-continuation. Scoped by session, not just
// 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 {
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)
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
// 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 {
func AssentWindowKey(agentID uuid.UUID, sessionID string) string {
return "assent_window.agent:" + agentID.String() + ".session:" + sessionID
}
@@ -2050,7 +2060,7 @@ func destructiveWindowKey(agentID uuid.UUID, targetSlug, sessionID string) strin
// 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) {
func (s *Store) OpenDestructiveWindow(ctx context.Context, agentID uuid.UUID, targetSlug, sessionID string) {
if s == nil || agentID == uuid.Nil || targetSlug == "" || sessionID == "" {
return
}
@@ -2061,7 +2071,7 @@ func (s *store) openDestructiveWindow(ctx context.Context, agentID uuid.UUID, ta
// 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 {
func (s *Store) DestructiveWindowActive(ctx context.Context, agentID uuid.UUID, targetSlug, sessionID string) bool {
if s == nil || agentID == uuid.Nil || targetSlug == "" || sessionID == "" {
return false
}
@@ -2076,7 +2086,7 @@ func (s *store) destructiveWindowActive(ctx context.Context, agentID uuid.UUID,
// 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 {
func (s *Store) ExecutionTarget(ctx context.Context, execID uuid.UUID) string {
if s == nil {
return ""
}
@@ -2101,7 +2111,7 @@ var entityArgKeys = []string{
// 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 {
func (s *Store) ResolveArgEntityID(ctx context.Context, args map[string]any) uuid.UUID {
if s == nil {
return uuid.Nil
}
@@ -2126,11 +2136,11 @@ func (s *store) resolveArgEntityID(ctx context.Context, args map[string]any) uui
// 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) {
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)
entityID := s.ResolveArgEntityID(ctx, args)
var entityIDArg any
if entityID != uuid.Nil {
entityIDArg = entityID