feat: Phase 9 gaps closed — ApprovalService.Decide convergence, execlog fold, execworker poller
- 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:
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package session
|
||||
|
||||
// Integration tests against a real Postgres, mirroring
|
||||
// internal/db/integration_test.go's pattern: guarded by
|
||||
@@ -10,6 +10,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
@@ -98,7 +99,7 @@ func TestGetRecentMessages_Truncation(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sess, err := s.createSession(ctx, "history window test")
|
||||
sess, err := s.CreateSession(ctx, "history window test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
@@ -111,12 +112,12 @@ func TestGetRecentMessages_Truncation(t *testing.T) {
|
||||
role = "assistant"
|
||||
}
|
||||
body := fmt.Appendf(nil, `{"role":%q,"text":"msg-%d"}`, role, i)
|
||||
if err := s.saveMessage(ctx, sess.ID, role, body); err != nil {
|
||||
if err := s.SaveMessage(ctx, sess.ID, role, body); err != nil {
|
||||
t.Fatalf("saveMessage %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
msgs, truncated, err := s.getRecentMessages(ctx, sess.ID, limit)
|
||||
msgs, truncated, err := s.GetRecentMessages(ctx, sess.ID, limit)
|
||||
if err != nil {
|
||||
t.Fatalf("getRecentMessages: %v", err)
|
||||
}
|
||||
@@ -131,25 +132,25 @@ func TestGetRecentMessages_Truncation(t *testing.T) {
|
||||
// the last should be the most recently saved (msg-34).
|
||||
wantFirst := fmt.Sprintf("msg-%d", total-limit)
|
||||
wantLast := fmt.Sprintf("msg-%d", total-1)
|
||||
if got := extractText(msgs[0].Content); got != wantFirst {
|
||||
if got := sessionText(msgs[0].Content); got != wantFirst {
|
||||
t.Errorf("first retained message = %q, want %q", got, wantFirst)
|
||||
}
|
||||
if got := extractText(msgs[len(msgs)-1].Content); got != wantLast {
|
||||
if got := sessionText(msgs[len(msgs)-1].Content); got != wantLast {
|
||||
t.Errorf("last retained message = %q, want %q", got, wantLast)
|
||||
}
|
||||
|
||||
// Under the limit: nothing dropped.
|
||||
sess2, err := s.createSession(ctx, "small session")
|
||||
sess2, err := s.CreateSession(ctx, "small session")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
body := fmt.Appendf(nil, `{"role":"user","text":"msg-%d"}`, i)
|
||||
if err := s.saveMessage(ctx, sess2.ID, "user", body); err != nil {
|
||||
if err := s.SaveMessage(ctx, sess2.ID, "user", body); err != nil {
|
||||
t.Fatalf("saveMessage: %v", err)
|
||||
}
|
||||
}
|
||||
msgs2, truncated2, err := s.getRecentMessages(ctx, sess2.ID, limit)
|
||||
msgs2, truncated2, err := s.GetRecentMessages(ctx, sess2.ID, limit)
|
||||
if err != nil {
|
||||
t.Fatalf("getRecentMessages (small): %v", err)
|
||||
}
|
||||
@@ -173,14 +174,14 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sess, err := s.createSession(ctx, "plan refuse test")
|
||||
sess, err := s.CreateSession(ctx, "plan refuse test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
|
||||
// First call: no steps exist yet — must persist as-is (replace mode,
|
||||
// trivially: nothing to replace).
|
||||
out1, err := s.proposePlan(ctx, sess.ID, []PlanStepInput{{Title: "Step A"}})
|
||||
out1, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "Step A"}})
|
||||
if err != nil {
|
||||
t.Fatalf("proposePlan #1: %v", err)
|
||||
}
|
||||
@@ -192,20 +193,20 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
|
||||
}
|
||||
|
||||
// Mark step 1 as started.
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
|
||||
if err := s.UpdatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
|
||||
t.Fatalf("updatePlanStep: %v", err)
|
||||
}
|
||||
|
||||
// Second call, simulating a model that re-proposes mid-flight (the
|
||||
// operator-reported "proceed" bug): since step 1 has left 'pending',
|
||||
// this MUST refuse with errPlanInFlight, not append or replace.
|
||||
_, err = s.proposePlan(ctx, sess.ID, []PlanStepInput{{Title: "Step B"}})
|
||||
if !errors.Is(err, errPlanInFlight) {
|
||||
t.Fatalf("proposePlan #2: err = %v, want errPlanInFlight (refuse mid-flight re-proposal)", err)
|
||||
// this MUST refuse with ErrPlanInFlight, not append or replace.
|
||||
_, err = s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "Step B"}})
|
||||
if !errors.Is(err, ErrPlanInFlight) {
|
||||
t.Fatalf("proposePlan #2: err = %v, want ErrPlanInFlight (refuse mid-flight re-proposal)", err)
|
||||
}
|
||||
|
||||
// The original step 1 must be untouched — not erased, not appended to.
|
||||
steps, err := s.getPlanSteps(ctx, sess.ID, false)
|
||||
steps, err := s.GetPlanSteps(ctx, sess.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("getPlanSteps: %v", err)
|
||||
}
|
||||
@@ -219,18 +220,18 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
|
||||
// Third call BEFORE anything runs on a fresh session: every step is
|
||||
// still pending, so this must REPLACE (mark the prior plan `replaced`),
|
||||
// not refuse. The new plan becomes generation 2.
|
||||
sess2, err := s.createSession(ctx, "plan replace test")
|
||||
sess2, err := s.CreateSession(ctx, "plan replace test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
if _, err := s.proposePlan(ctx, sess2.ID, []PlanStepInput{{Title: "Original"}}); err != nil {
|
||||
if _, err := s.ProposePlan(ctx, sess2.ID, []PlanStepInput{{Title: "Original"}}); err != nil {
|
||||
t.Fatalf("proposePlan (initial): %v", err)
|
||||
}
|
||||
if _, err := s.proposePlan(ctx, sess2.ID, []PlanStepInput{{Title: "Revised"}}); err != nil {
|
||||
if _, err := s.ProposePlan(ctx, sess2.ID, []PlanStepInput{{Title: "Revised"}}); err != nil {
|
||||
t.Fatalf("proposePlan (revise before execution): %v", err)
|
||||
}
|
||||
// Default (current generation) view: only the revised step.
|
||||
revisedSteps, err := s.getPlanSteps(ctx, sess2.ID, false)
|
||||
revisedSteps, err := s.GetPlanSteps(ctx, sess2.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("getPlanSteps: %v", err)
|
||||
}
|
||||
@@ -244,7 +245,7 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
|
||||
t.Fatalf("revised step generation = %d, want 2 (prior pending plan is replaced, not deleted, so the counter increments)", revisedSteps[0].Generation)
|
||||
}
|
||||
// all=true audit view: both generations, the original marked `replaced`.
|
||||
allSteps, err := s.getPlanSteps(ctx, sess2.ID, true)
|
||||
allSteps, err := s.GetPlanSteps(ctx, sess2.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("getPlanSteps(all): %v", err)
|
||||
}
|
||||
@@ -270,32 +271,32 @@ func TestUpdatePlanStep_GenerationRelative(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sess, err := s.createSession(ctx, "gen-relative seq test")
|
||||
sess, err := s.CreateSession(ctx, "gen-relative seq test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
// Generation 1: two steps.
|
||||
if _, err := s.proposePlan(ctx, sess.ID, []PlanStepInput{{Title: "A"}, {Title: "B"}}); err != nil {
|
||||
if _, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "A"}, {Title: "B"}}); err != nil {
|
||||
t.Fatalf("proposePlan #1: %v", err)
|
||||
}
|
||||
// Re-plan: setGoal marks the gen-1 plan `replaced`, proposePlan starts gen 2.
|
||||
if err := s.setGoal(ctx, sess.ID, "follow-up sub-task"); err != nil {
|
||||
if err := s.SetGoal(ctx, sess.ID, "follow-up sub-task"); err != nil {
|
||||
t.Fatalf("setGoal: %v", err)
|
||||
}
|
||||
if _, err := s.proposePlan(ctx, sess.ID, []PlanStepInput{{Title: "C"}, {Title: "D"}}); err != nil {
|
||||
if _, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "C"}, {Title: "D"}}); err != nil {
|
||||
t.Fatalf("proposePlan #2: %v", err)
|
||||
}
|
||||
|
||||
// The model addresses the new plan with 1-based seq. seq=1 must hit
|
||||
// gen-2 "C", leaving gen-1 "A" (replaced) untouched.
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
|
||||
if err := s.UpdatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
|
||||
t.Fatalf("updatePlanStep(seq=1, running): %v", err)
|
||||
}
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 1, "done", "", ""); err != nil {
|
||||
if err := s.UpdatePlanStep(ctx, sess.ID, 1, "done", "", ""); err != nil {
|
||||
t.Fatalf("updatePlanStep(seq=1, done): %v", err)
|
||||
}
|
||||
|
||||
all, err := s.getPlanSteps(ctx, sess.ID, true)
|
||||
all, err := s.GetPlanSteps(ctx, sess.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("getPlanSteps(all): %v", err)
|
||||
}
|
||||
@@ -319,8 +320,8 @@ func TestUpdatePlanStep_GenerationRelative(t *testing.T) {
|
||||
}
|
||||
|
||||
// Out-of-range seq must be refused (no current-gen step there).
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 99, "running", "", ""); !errors.Is(err, errPlanStepNotFound) {
|
||||
t.Fatalf("updatePlanStep(seq=99) err = %v, want errPlanStepNotFound", err)
|
||||
if err := s.UpdatePlanStep(ctx, sess.ID, 99, "running", "", ""); !errors.Is(err, ErrPlanStepNotFound) {
|
||||
t.Fatalf("updatePlanStep(seq=99) err = %v, want ErrPlanStepNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,24 +334,24 @@ func TestCompleteTask_AutoCloseEmitsEvents(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sess, err := s.createSession(ctx, "auto-close events test")
|
||||
sess, err := s.CreateSession(ctx, "auto-close events test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
if _, err := s.proposePlan(ctx, sess.ID, []PlanStepInput{{Title: "A"}, {Title: "B"}}); err != nil {
|
||||
if _, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "A"}, {Title: "B"}}); err != nil {
|
||||
t.Fatalf("proposePlan: %v", err)
|
||||
}
|
||||
// A is running, B still pending at completion time.
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
|
||||
if err := s.UpdatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
|
||||
t.Fatalf("updatePlanStep(1, running): %v", err)
|
||||
}
|
||||
if err := s.completeTask(ctx, sess.ID, "success", "done"); err != nil {
|
||||
if err := s.CompleteTask(ctx, sess.ID, "success", "done"); err != nil {
|
||||
t.Fatalf("completeTask: %v", err)
|
||||
}
|
||||
|
||||
// Every auto-closed step should now carry both a started_at and a
|
||||
// finished_at (no NULL-started `done` step).
|
||||
steps, err := s.getPlanSteps(ctx, sess.ID, true)
|
||||
steps, err := s.GetPlanSteps(ctx, sess.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("getPlanSteps: %v", err)
|
||||
}
|
||||
@@ -382,64 +383,64 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sess, err := s.createSession(ctx, "discovery test")
|
||||
sess, err := s.CreateSession(ctx, "discovery test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
|
||||
// Before any tool calls: no discovery, no writeback.
|
||||
if s.hadDiscovery(ctx, sess.ID) {
|
||||
if s.HadDiscovery(ctx, sess.ID) {
|
||||
t.Fatal("hadDiscovery = true before any tool calls, want false")
|
||||
}
|
||||
if s.hadEntityWriteback(ctx, sess.ID) {
|
||||
if s.HadEntityWriteback(ctx, sess.ID) {
|
||||
t.Fatal("hadEntityWriteback = true before any tool calls, want false")
|
||||
}
|
||||
|
||||
// A `run` call (discovery) — should set hadDiscovery, not hadEntityWriteback.
|
||||
agentID := uuid.New()
|
||||
s.logActivity(ctx, agentID, sess.ID, "run", nil, "", "uptime output", 100, true, "corr-1", 0)
|
||||
if !s.hadDiscovery(ctx, sess.ID) {
|
||||
s.LogActivity(ctx, agentID, sess.ID, "run", nil, "", "uptime output", 100, true, "corr-1", 0)
|
||||
if !s.HadDiscovery(ctx, sess.ID) {
|
||||
t.Fatal("hadDiscovery = false after a successful run call, want true")
|
||||
}
|
||||
if s.hadEntityWriteback(ctx, sess.ID) {
|
||||
if s.HadEntityWriteback(ctx, sess.ID) {
|
||||
t.Fatal("hadEntityWriteback = true after only a run call, want false")
|
||||
}
|
||||
|
||||
// A failed run call should NOT count as discovery (no facts learned).
|
||||
sess2, err := s.createSession(ctx, "failed discovery test")
|
||||
sess2, err := s.CreateSession(ctx, "failed discovery test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
s.logActivity(ctx, agentID, sess2.ID, "run", nil, "", "ssh timeout", 100, false, "corr-2", 0)
|
||||
if s.hadDiscovery(ctx, sess2.ID) {
|
||||
s.LogActivity(ctx, agentID, sess2.ID, "run", nil, "", "ssh timeout", 100, false, "corr-2", 0)
|
||||
if s.HadDiscovery(ctx, sess2.ID) {
|
||||
t.Fatal("hadDiscovery = true after a failed run call, want false (no facts learned)")
|
||||
}
|
||||
|
||||
// A get_entity call should NOT count as discovery (DB lookup, not live state).
|
||||
sess3, err := s.createSession(ctx, "lookup test")
|
||||
sess3, err := s.CreateSession(ctx, "lookup test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
s.logActivity(ctx, agentID, sess3.ID, "get_entity", nil, "", "entity row", 10, true, "corr-3", 0)
|
||||
if s.hadDiscovery(ctx, sess3.ID) {
|
||||
s.LogActivity(ctx, agentID, sess3.ID, "get_entity", nil, "", "entity row", 10, true, "corr-3", 0)
|
||||
if s.HadDiscovery(ctx, sess3.ID) {
|
||||
t.Fatal("hadDiscovery = true after get_entity, want false (DB lookups are not discovery)")
|
||||
}
|
||||
|
||||
// update_entity_attributes sets hadEntityWriteback.
|
||||
sess4, err := s.createSession(ctx, "writeback test")
|
||||
sess4, err := s.CreateSession(ctx, "writeback test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
s.logActivity(ctx, agentID, sess4.ID, "update_entity_attributes", nil, "", "ok", 10, true, "corr-4", 0)
|
||||
if !s.hadEntityWriteback(ctx, sess4.ID) {
|
||||
s.LogActivity(ctx, agentID, sess4.ID, "update_entity_attributes", nil, "", "ok", 10, true, "corr-4", 0)
|
||||
if !s.HadEntityWriteback(ctx, sess4.ID) {
|
||||
t.Fatal("hadEntityWriteback = false after update_entity_attributes, want true")
|
||||
}
|
||||
// And the discovery+writeback combination (the conv3 scenario).
|
||||
s.logActivity(ctx, agentID, sess4.ID, "run", nil, "", "apt-get update output", 100, true, "corr-5", 0)
|
||||
if !s.hadDiscovery(ctx, sess4.ID) {
|
||||
s.LogActivity(ctx, agentID, sess4.ID, "run", nil, "", "apt-get update output", 100, true, "corr-5", 0)
|
||||
if !s.HadDiscovery(ctx, sess4.ID) {
|
||||
t.Fatal("hadDiscovery = false after run+writeback, want true")
|
||||
}
|
||||
if !s.hadEntityWriteback(ctx, sess4.ID) {
|
||||
if !s.HadEntityWriteback(ctx, sess4.ID) {
|
||||
t.Fatal("hadEntityWriteback = false after run+writeback, want true")
|
||||
}
|
||||
}
|
||||
@@ -459,13 +460,13 @@ func TestSetGoal_SupersededEvent(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sess, err := s.createSession(ctx, "goal pivot test")
|
||||
sess, err := s.CreateSession(ctx, "goal pivot test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
|
||||
// First set_goal — no prior, no supersession event expected.
|
||||
if err := s.setGoal(ctx, sess.ID, "Fix sabnzbd download folder to use ludo-lvm"); err != nil {
|
||||
if err := s.SetGoal(ctx, sess.ID, "Fix sabnzbd download folder to use ludo-lvm"); err != nil {
|
||||
t.Fatalf("setGoal #1: %v", err)
|
||||
}
|
||||
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 0 {
|
||||
@@ -473,7 +474,7 @@ func TestSetGoal_SupersededEvent(t *testing.T) {
|
||||
}
|
||||
|
||||
// Second set_goal with a DIFFERENT goal — supersession event expected.
|
||||
if err := s.setGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
|
||||
if err := s.SetGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
|
||||
t.Fatalf("setGoal #2: %v", err)
|
||||
}
|
||||
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 {
|
||||
@@ -482,7 +483,7 @@ func TestSetGoal_SupersededEvent(t *testing.T) {
|
||||
|
||||
// Third set_goal with the SAME goal as the second — no new supersession
|
||||
// event (idempotent: same goal is a no-op, not a pivot).
|
||||
if err := s.setGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
|
||||
if err := s.SetGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
|
||||
t.Fatalf("setGoal #3: %v", err)
|
||||
}
|
||||
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 {
|
||||
@@ -490,7 +491,7 @@ func TestSetGoal_SupersededEvent(t *testing.T) {
|
||||
}
|
||||
|
||||
// The session's current goal must be the latest one set.
|
||||
got, err := s.getSession(ctx, sess.ID)
|
||||
got, err := s.GetSession(ctx, sess.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("getSession: %v", err)
|
||||
}
|
||||
@@ -509,3 +510,14 @@ func countEvents(ctx context.Context, s *Store, sessionID, eventType string) int
|
||||
sessionID, eventType).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// sessionText pulls the "text" field from a persisted message's JSONB content.
|
||||
func sessionText(content json.RawMessage) string {
|
||||
var m struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
return m.Text
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user