feat(tasks): phase 1 — elevate chat session to a task (schema + task entity)

Migration 018 adds goal/status/outcome/summary/entity_id to agent_sessions
and creates session_plan_steps + session_questions. Registers a 'task'
entity type and an 'involves' (task→entity) relationship in the ontology
so each session anchors its knowledge and involved-entity edges on the
existing relationships graph.

nomos createSession now mints a task:<session-id> entity (type task) and
links it via agent_sessions.entity_id — best-effort so chat never blocks on
it. listSessions/GET /sessions surface the new task fields.

No behaviour change yet; this is the data foundation for the task board and
live context panel. Verified end-to-end against the local stack: migration
applied, ontology ingested (60 types/47 rels), a new session mints a linked
task entity and the API returns status/goal/entity_id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 12:25:28 +02:00
parent eed6e3b1c5
commit 72e9fe534e
3 changed files with 116 additions and 4 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
@@ -37,10 +38,18 @@ func (s *store) close() {
}
}
// session is a chat session elevated to a task: goal-structured work with a
// lifecycle status and an outcome (see migration 018 / the task-board plan).
// Outcome/Summary/EntityID are empty until set, hence omitempty.
type session struct {
ID string `json:"id"`
Title string `json:"title"`
Actor string `json:"actor"`
Goal string `json:"goal"`
Status string `json:"status"`
Outcome string `json:"outcome,omitempty"`
Summary string `json:"summary,omitempty"`
EntityID string `json:"entity_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastActiveAt time.Time `json:"last_active_at"`
}
@@ -55,7 +64,7 @@ type message struct {
func (s *store) createSession(ctx context.Context, title string) (*session, error) {
if s == nil {
return &session{ID: "ephemeral", Title: title, Actor: "agent:nomos"}, nil
return &session{ID: "ephemeral", Title: title, Actor: "agent:nomos", Status: "active"}, nil
}
var id string
err := s.pool.QueryRow(ctx,
@@ -64,7 +73,38 @@ func (s *store) createSession(ctx context.Context, title string) (*session, erro
if err != nil {
return nil, err
}
return &session{ID: id, Title: title, Actor: "agent:nomos", CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil
// Give the task its own entity so knowledge and involved-entity edges hang
// off the existing relationships graph. Best-effort: a failure here must not
// block the chat — the session is usable without a graph anchor.
entityID := s.createTaskEntity(ctx, id, title)
return &session{ID: id, Title: title, Actor: "agent:nomos", Status: "active",
EntityID: entityID, CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil
}
// createTaskEntity creates (or reuses) the task:<session-id> entity that
// anchors this task's knowledge and involved-entity relationships, and records
// it on the session. Returns the entity id, or "" on failure — non-fatal, see
// caller. Requires the 'task' entity type (seeds/ontology.yaml).
func (s *store) createTaskEntity(ctx context.Context, sessionID, title string) string {
entityID, _ := uuid.NewV7()
slug := "task:" + sessionID
// name is UNIQUE(type,name) and chat titles collide ("hi" ×6), so key the
// name on the session id and keep the human title in attributes for display.
name := "task " + sessionID
attrs, _ := json.Marshal(map[string]any{"title": title})
if err := s.pool.QueryRow(ctx, `
INSERT INTO entities (id, slug, type, name, attributes)
VALUES ($1, $2, 'task', $3, $4)
ON CONFLICT (slug) DO UPDATE SET updated_at = now()
RETURNING id`, entityID, slug, name, string(attrs)).Scan(&entityID); err != nil {
slog.Warn("nomos: could not create task entity", "session", sessionID, "error", err)
return ""
}
if _, err := s.pool.Exec(ctx,
`UPDATE agent_sessions SET entity_id = $1 WHERE id = $2`, entityID, sessionID); err != nil {
slog.Warn("nomos: could not link task entity", "session", sessionID, "error", err)
}
return entityID.String()
}
func (s *store) saveMessage(ctx context.Context, sessionID, role string, content json.RawMessage) error {
@@ -151,7 +191,9 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
return nil, nil
}
rows, err := s.pool.Query(ctx,
`SELECT id, title, actor, created_at, last_active_at FROM agent_sessions ORDER BY last_active_at DESC LIMIT 50`)
`SELECT id, title, actor, goal, status, COALESCE(outcome, ''), summary,
COALESCE(entity_id::text, ''), created_at, last_active_at
FROM agent_sessions ORDER BY last_active_at DESC LIMIT 50`)
if err != nil {
return nil, err
}
@@ -160,7 +202,8 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
var out []session
for rows.Next() {
var sess session
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.CreatedAt, &sess.LastActiveAt); err != nil {
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.CreatedAt, &sess.LastActiveAt); err != nil {
return nil, err
}
out = append(out, sess)

View File

@@ -0,0 +1,53 @@
-- 018_tasks.up.sql
-- Elevate a chat session into a "task": a goal-structured unit of work with a
-- lifecycle status, an outcome, and a one-line summary — the first-class object
-- the task board and the live context panel render. See
-- plans/2026-07-11-goal-oriented-chat-control-panel.md.
--
-- entity_id links the session to its OWN entity (type 'task', registered in
-- seeds/ontology.yaml) so knowledge notes and involved-entity edges hang off
-- the existing relationships graph unchanged — get_relations and
-- get_entity_knowledge just work. Intentionally no hard FK (mirrors 017's
-- decoupling): a race between task-entity creation and the session insert must
-- not be able to break the session.
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS goal TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active';
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS outcome TEXT;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS summary TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS entity_id UUID;
-- Ordered plan steps. A step is a described unit of work that maps to a
-- run/request_execution call (no fixed step enum, per general-gated-execution).
-- execution_id is the gated action a step runs, if any; its terminal status
-- auto-closes the step server-side.
CREATE TABLE IF NOT EXISTS session_plan_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
seq INT NOT NULL,
title TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
-- pending | running | done | failed | skipped | blocked
execution_id UUID,
target_slug TEXT,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_plan_steps_session ON session_plan_steps(session_id, seq);
-- Structured decisions the agent surfaces to the operator mid-task. context
-- carries { entities:[], options:[], why:"" } for the pinned question card.
CREATE TABLE IF NOT EXISTS session_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
prompt TEXT NOT NULL,
context JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'open', -- open | answered | dismissed
answer TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
answered_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_questions_session_open
ON session_questions(session_id) WHERE status = 'open';

View File

@@ -641,6 +641,14 @@ entity_types:
domain: cognition
layer: cognition
description: Recorded investigation/postmortem.
task:
parent: entity
domain: cognition
layer: cognition
description: A goal-structured unit of agent work — one chat/session elevated
to a task with a plan, lifecycle status, and outcome. Anchors the knowledge
and involved-entity relationships for the task so future tasks can learn
from it. Typed rows in agent_sessions.
# ─── Relationship types ────────────────────────────────────────────────
# cardinality is source→target: e.g. `hosts` one-to-many = one machine
@@ -928,6 +936,14 @@ relationship_types:
target: entity
cardinality: many-to-one
description: Document describes an entity.
involves:
inverse: involved-in
source: task
target: entity
cardinality: many-to-many
description: Task explored or acted on an entity (captured from its tool
calls). A task's involved-entity set is its graph neighborhood, so future
tasks on the same entities can surface this task's knowledge and outcome.
procedure-for:
inverse: has-procedure
source: runbook