session reliability: reconnect, knowledge loop, retire request_execution
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Phase 1 — crash recovery: SSE auto-reconnect + backoff, polling gate
during disconnect, connection banner with retry button, empty-response
retry 3x, non-terminal resume on empty response, persistent error cards.

Phase 2/4 — visibility + continuation: custom ExecutionStatus renderer,
approvals extracted on every tool_result (not just done), activity bar
with status/goal, SessionDigest live polling, Continue button.

Phase 3 — cleanup: complete_task auto-cancels orphaned approvals,
deletes assent/destructive window keys, propose_plan marks pending
steps as replaced, plan step seq-order enforcement.

Phase 5 — knowledge loop: list_lxcs state filter (active/destroyed),
SOUL.md unmissable writeback section, propose_plan validation nudge,
complete_task writeback check, upsert_knowledge about array support,
plan generation grouping in frontend, session approval count badge.

Retire request_execution — all mutations now route through run.
Updated SOUL.md, AGENTS.md, CLIENTS.md, skills, and agent system notes.

Migration 020: plan step generation column, audit_log session_id index,
nomos_plan_executions pending-approval index.
This commit is contained in:
2026-07-14 11:03:23 +02:00
parent b446909ea5
commit 60effcb2fe
25 changed files with 1894 additions and 323 deletions

View File

@@ -46,16 +46,17 @@ func (s *store) close() {
// 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"`
ID string `json:"id"`
Title string `json:"title"`
Actor string `json:"actor"`
Goal string `json:"goal"`
Status string `json:"status"`
Outcome string `json:"outcome,omitempty"`
Summary string `json:"summary,omitempty"`
EntityID string `json:"entity_id,omitempty"`
PendingApprovals int `json:"pending_approvals"`
CreatedAt time.Time `json:"created_at"`
LastActiveAt time.Time `json:"last_active_at"`
}
type message struct {
@@ -195,9 +196,19 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
return nil, nil
}
rows, err := s.pool.Query(ctx,
`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`)
`SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary,
COALESCE(s.entity_id::text, ''),
COALESCE(pa.cnt, 0),
s.created_at, s.last_active_at
FROM agent_sessions s
LEFT JOIN (
SELECT l.session_id, COUNT(*) AS cnt
FROM nomos_plan_executions l
JOIN executions e ON e.entity_id = l.execution_id
WHERE e.status = 'pending_approval'
GROUP BY l.session_id
) pa ON pa.session_id = s.id
ORDER BY s.last_active_at DESC LIMIT 50`)
if err != nil {
return nil, err
}
@@ -207,7 +218,8 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
for rows.Next() {
var sess session
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.CreatedAt, &sess.LastActiveAt); err != nil {
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
&sess.CreatedAt, &sess.LastActiveAt); err != nil {
return nil, err
}
out = append(out, sess)
@@ -215,6 +227,24 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
return out, rows.Err()
}
func (s *store) getSession(ctx context.Context, id string) (*session, error) {
if s == nil {
return nil, nil
}
var sess session
err := s.pool.QueryRow(ctx,
`SELECT id, title, actor, goal, status, COALESCE(outcome, ''), summary,
COALESCE(entity_id::text, ''), 0, created_at, last_active_at
FROM agent_sessions WHERE id = $1`, id).
Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
&sess.CreatedAt, &sess.LastActiveAt)
if err != nil {
return nil, err
}
return &sess, nil
}
// getMessages returns a session's ENTIRE message history, unbounded — used
// for the UI's own transcript view (GET /sessions/{id}), where the operator
// should be able to see everything a task has done regardless of how long
@@ -387,6 +417,17 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
return nil, err
}
startSeq = 0
} else {
// Mid-flight plan revision: mark any still-pending steps from the
// previous generation as 'replaced' so the panel doesn't show them
// as incomplete forever. Only pending steps — already-running or
// done steps from the prior plan are preserved as history.
if _, err := tx.Exec(ctx, `
UPDATE session_plan_steps
SET status = 'replaced', finished_at = now()
WHERE session_id = $1 AND status = 'pending'`, sessionID); err != nil {
return nil, err
}
}
out := make([]map[string]any, 0, len(steps))
@@ -428,6 +469,11 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
// plan.step.finished (terminal) so the panel advances live. The execution link
// is also what lets the api auto-close the step when the execution finishes
// (see closePlanStepForExecution).
//
// Completion ordering (done/failed/skipped/blocked) is enforced: a step cannot
// be marked complete while an earlier step is still pending, preventing the
// agent from marking step 5 done before step 4 (observed in production: the
// agent rushed to close all steps in a final turn, in reverse order).
func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, status, execID string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil
@@ -436,9 +482,22 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
switch status {
case "running":
stamp = ", started_at = COALESCE(started_at, now())"
case "done", "failed", "skipped", "blocked":
case "done", "failed", "skipped", "blocked", "replaced":
stamp = ", finished_at = now()"
}
// Completion ordering: for terminal states, check that no earlier step
// is still pending. Running steps can start out of order (the agent
// may dispatch parallel work), but completion must be sequential.
if status == "done" || status == "failed" || status == "skipped" || status == "blocked" {
var blockedBy int
if err := s.pool.QueryRow(ctx, `
SELECT COALESCE(MIN(seq), 0)
FROM session_plan_steps
WHERE session_id = $1 AND seq < $2 AND status = 'pending'`,
sessionID, seq).Scan(&blockedBy); err == nil && blockedBy > 0 {
return fmt.Errorf("cannot complete step %d — step %d is still pending", seq, blockedBy)
}
}
var execPtr *uuid.UUID
if id, err := uuid.Parse(execID); err == nil {
execPtr = &id
@@ -481,6 +540,33 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil
}
// Auto-cancel any executions still in pending_approval/approved/queued
// state for this session — preventing orphaned approvals (observed in
// production: 4 approvals left open after session completed).
var cancelledCount int
if err := s.pool.QueryRow(ctx, `
WITH cancelled AS (
UPDATE executions SET status = 'cancelled',
result = '{"message": "task completed — auto-cancelled"}'::jsonb
WHERE entity_id IN (
SELECT execution_id FROM nomos_plan_executions WHERE session_id = $1
) AND status IN ('pending_approval', 'approved', 'queued')
RETURNING entity_id
)
SELECT COUNT(*) FROM cancelled
`, sessionID).Scan(&cancelledCount); err != nil {
slog.Warn("nomos: completeTask failed to cancel orphaned executions", "session", sessionID, "error", err)
}
// Mark all continuations done so the worker won't try to feed them back.
s.pool.Exec(ctx, `UPDATE nomos_plan_executions SET continued_at = now()
WHERE session_id = $1 AND continued_at IS NULL`, sessionID)
// Clean up assent and destructive window keys from autonomy_settings.
s.pool.Exec(ctx, `DELETE FROM autonomy_settings
WHERE key LIKE '%:' || $1`, sessionID)
status := "done"
if outcome == "failure" {
status = "failed"
@@ -504,10 +590,27 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
severity = "warning"
}
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.status", entPtr, severity, "nomos", sessionID,
map[string]any{"status": status, "outcome": outcome, "summary": summary})
map[string]any{"status": status, "outcome": outcome, "summary": summary,
"cancelled_executions": cancelledCount})
return nil
}
// hadEntityWriteback checks whether this session called update_entity_attributes
// or create_relationship — used by complete_task to warn the agent when it
// forgot to persist entity facts (the #1 cause of knowledge graph drift).
func (s *store) hadEntityWriteback(ctx context.Context, sessionID string) bool {
if s == nil || sessionID == "" {
return true // fail safe: don't warn when we can't check
}
var count int
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM agent_activity
WHERE session_id = $1
AND tool_name IN ('update_entity_attributes', 'create_relationship')
AND success = true`, sessionID).Scan(&count)
return count > 0
}
// staleGoalSession is a goal-bearing task that's gone idle without reaching
// a terminal state — the idle-sweep worker's work list (fix 2+3 of
// plans/2026-07-11-task-completion-safety-net.md).