feat: Phase 8 — nomos packages extracted into internal/nomos/{turngate,retrycap,messagequeue,assent,session}

Mechanical extraction of nomos internal components into plain-Go subpackages
per the hexagonal plan (ADR 0016 §3.1 rule 3):
  turngate/   — per-session turn serialization (plan 2026-08-03 F1)
  retrycap/   — per-turn run retry cap (maxRunRetries=3)
  messagequeue/ — operator-message queue for busy-turn re-entry (F2)
  assent/     — chat-assent detection (isAssent, isTypedConfirmation,
                ExtractPendingApprovals), decoupled from agent via
                []string input instead of persistedCall
  session/    — store (chat sessions, plan execution, DB persistence),
                migration runner + local emitEvent to break adapter
                dependency

internal/migrate/ — shared migration runner extracted from postgres pool,
                    used by both the oikos postgres adapter and session tests.

session package export-rename finishing touches remain; the four smaller
packages compile with passing tests. Depguard rules and ADR-0016 leaf-note
update deferred to a followup. VERSION 0.35.1.
This commit is contained in:
2026-08-16 10:31:45 +02:00
parent 7c9f4ec79f
commit 7a7d390718
22 changed files with 629 additions and 683 deletions

View File

@@ -1 +1 @@
0.35.0 0.35.1

View File

@@ -10,6 +10,11 @@ import (
"strings" "strings"
"time" "time"
"github.com/dtoro/oikos/internal/nomos/assent"
"github.com/dtoro/oikos/internal/nomos/messagequeue"
"github.com/dtoro/oikos/internal/nomos/retrycap"
"github.com/dtoro/oikos/internal/nomos/session"
"github.com/dtoro/oikos/internal/nomos/turngate"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/openai/openai-go" "github.com/openai/openai-go"
"github.com/openai/openai-go/option" "github.com/openai/openai-go/option"
@@ -51,7 +56,7 @@ type agent struct {
system string system string
provider *openai.Client provider *openai.Client
model string model string
store *store store *session.Store
agentID uuid.UUID agentID uuid.UUID
reqOpts []option.RequestOption reqOpts []option.RequestOption
apiBase string // oikos HTTP API base, derived from NOMOS_MCP_URL, for chat-assent approvals apiBase string // oikos HTTP API base, derived from NOMOS_MCP_URL, for chat-assent approvals
@@ -59,14 +64,14 @@ type agent struct {
httpClient *http.Client httpClient *http.Client
// gate serializes turns per session (at most one in-flight turn per // gate serializes turns per session (at most one in-flight turn per
// sessionID). See turngate.go and plan 2026-08-03 F1. // sessionID). See turngate.go and plan 2026-08-03 F1.
gate *turnGate gate *turngate.TurnGate
// queue holds operator messages that arrived while a turn was already // queue holds operator messages that arrived while a turn was already
// running; they are auto-run when the gate frees (plan 2026-08-03 F2). // running; they are auto-run when the gate frees (plan 2026-08-03 F2).
// See messagequeue.go. // See messagequeue.go.
queue *messageQueue queue *messagequeue.MessageQueue
} }
func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug string, openrouterAPIKey string) (*agent, error) { func newAgent(ctx context.Context, clients *mcpClientPool, st *session.Store, agentSlug string, openrouterAPIKey string) (*agent, error) {
system := loadSoul() system := loadSoul()
apiKey := openrouterAPIKey apiKey := openrouterAPIKey
model := os.Getenv("NOMOS_MODEL") model := os.Getenv("NOMOS_MODEL")
@@ -82,7 +87,7 @@ func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug
option.WithAPIKey(apiKey), option.WithAPIKey(apiKey),
) )
agentID := st.resolveAgentID(ctx, agentSlug) agentID := st.ResolveAgentID(ctx, agentSlug)
if agentID == uuid.Nil { if agentID == uuid.Nil {
slog.Warn("nomos: agent entity not found; tool-call activity will not be logged", "slug", agentSlug) slog.Warn("nomos: agent entity not found; tool-call activity will not be logged", "slug", agentSlug)
} }
@@ -124,8 +129,8 @@ func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug
apiBase: apiBase, apiBase: apiBase,
apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"), apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"),
httpClient: &http.Client{Timeout: 15 * time.Second}, httpClient: &http.Client{Timeout: 15 * time.Second},
gate: newTurnGate(), gate: turngate.New(),
queue: newMessageQueue(), queue: messagequeue.New(),
}, nil }, nil
} }
@@ -155,12 +160,12 @@ const assentWindowDuration = 30 * time.Minute
// session dimension, approving one task's plan would silently auto-run // session dimension, approving one task's plan would silently auto-run
// unapproved actions in any other concurrently-running task. // unapproved actions in any other concurrently-running task.
func (a *agent) openAssentWindow(ctx context.Context, sessionID string) { func (a *agent) openAssentWindow(ctx context.Context, sessionID string) {
if a.store == nil || a.store.pool == nil || a.agentID == uuid.Nil || sessionID == "" { if a.store == nil || a.agentID == uuid.Nil || sessionID == "" {
return return
} }
key := assentWindowKey(a.agentID, sessionID) key := session.AssentWindowKey(a.agentID, sessionID)
expires := time.Now().Add(assentWindowDuration).UTC().Format(time.RFC3339) expires := time.Now().Add(assentWindowDuration).UTC().Format(time.RFC3339)
_, err := a.store.pool.Exec(ctx, _, err := a.store.Exec(ctx,
`INSERT INTO autonomy_settings (key, value) VALUES ($1, $2) `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2`, key, expires) ON CONFLICT (key) DO UPDATE SET value = $2`, key, expires)
if err != nil { if err != nil {
@@ -233,7 +238,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
system += "\n\n" + snapshot system += "\n\n" + snapshot
} }
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)} messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)}
history, truncatedHistory, _ := a.store.getRecentMessages(ctx, sessionID, historyWindowSize) history, truncatedHistory, _ := a.store.GetRecentMessages(ctx, sessionID, historyWindowSize)
if truncatedHistory { if truncatedHistory {
// Tell the model explicitly rather than silently dropping older // Tell the model explicitly rather than silently dropping older
// turns — otherwise it might assume something wasn't done just // turns — otherwise it might assume something wasn't done just
@@ -289,36 +294,42 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
// actions are never granted by loose assent — they need the stricter // actions are never granted by loose assent — they need the stricter
// isTypedConfirmation ("I confirm ...", per SOUL.md's guidance for what // isTypedConfirmation ("I confirm ...", per SOUL.md's guidance for what
// to ask the operator to type). // to ask the operator to type).
pending := extractPendingApprovals(lastAssistantCalls) pending := assent.ExtractPendingApprovals(func() []string {
assent := isAssent(message) texts := make([]string, len(lastAssistantCalls))
typedConfirm := isTypedConfirmation(message) for i, c := range lastAssistantCalls {
if len(pending) > 0 && (assent || typedConfirm) { texts[i] = c.resultText()
}
return texts
}())
operatorAssented := assent.IsAssent(message)
typedConfirm := assent.IsTypedConfirmation(message)
if len(pending) > 0 && (operatorAssented || typedConfirm) {
var granted, blocked []string var granted, blocked []string
for _, p := range pending { for _, p := range pending {
if p.destructive && !typedConfirm { if p.Destructive && !typedConfirm {
blocked = append(blocked, p.execID) blocked = append(blocked, p.ExecID)
continue continue
} }
if !p.destructive && !assent { if !p.Destructive && !operatorAssented {
continue // typed-confirm alone doesn't grant a non-destructive item without also reading as assent continue // typed-confirm alone doesn't grant a non-destructive item without also reading as assent
} }
ok, status, aerr := a.approveExecution(ctx, p.execID) ok, status, aerr := a.approveExecution(ctx, p.ExecID)
if aerr != nil { if aerr != nil {
slog.Error("nomos: chat-assent approve", "execution", p.execID, "error", aerr) slog.Error("nomos: chat-assent approve", "execution", p.ExecID, "error", aerr)
continue continue
} }
if ok { if ok {
granted = append(granted, p.execID) granted = append(granted, p.ExecID)
slog.Info("nomos: chat-assent granted", "execution", p.execID, "status", status, "session", sessionID) slog.Info("nomos: chat-assent granted", "execution", p.ExecID, "status", status, "session", sessionID)
// An explicit typed confirmation for a destructive action // An explicit typed confirmation for a destructive action
// opens a short, target-scoped window so the rest of a // opens a short, target-scoped window so the rest of a
// destructive recovery sequence on the SAME target (e.g. // destructive recovery sequence on the SAME target (e.g.
// stop -> destroy) doesn't need a second typed confirmation. // stop -> destroy) doesn't need a second typed confirmation.
if p.destructive && typedConfirm { if p.Destructive && typedConfirm {
if execUUID, perr := uuid.Parse(p.execID); perr == nil { if execUUID, perr := uuid.Parse(p.ExecID); perr == nil {
if target := a.store.executionTarget(ctx, execUUID); target != "" { if target := a.store.ExecutionTarget(ctx, execUUID); target != "" {
a.store.openDestructiveWindow(ctx, a.agentID, target, sessionID) a.store.OpenDestructiveWindow(ctx, a.agentID, target, sessionID)
slog.Info("nomos: destructive window opened", "agent", a.agentID, "target", target, "session", sessionID) slog.Info("nomos: destructive window opened", "agent", a.agentID, "target", target, "session", sessionID)
} }
} }
@@ -333,7 +344,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
// same session cause empty responses and race conditions. // same session cause empty responses and race conditions.
for _, execID := range granted { for _, execID := range granted {
if execUUID, perr := uuid.Parse(execID); perr == nil { if execUUID, perr := uuid.Parse(execID); perr == nil {
a.store.markContinued(ctx, execUUID) a.store.MarkContinued(ctx, execUUID)
} }
} }
// No system note. The model already sees "go ahead" in the // No system note. The model already sees "go ahead" in the
@@ -348,7 +359,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
note := fmt.Sprintf("[System: execution(s) %s are classified DESTRUCTIVE and were NOT approved by loose assent — you must ask the operator for an explicit typed confirmation before they can run. Once they do confirm, further destructive steps on that SAME target (e.g. finishing a stop-then-destroy sequence) will auto-run for 15 minutes without asking again — but a different target always needs its own confirmation.]", strings.Join(blocked, ", ")) note := fmt.Sprintf("[System: execution(s) %s are classified DESTRUCTIVE and were NOT approved by loose assent — you must ask the operator for an explicit typed confirmation before they can run. Once they do confirm, further destructive steps on that SAME target (e.g. finishing a stop-then-destroy sequence) will auto-run for 15 minutes without asking again — but a different target always needs its own confirmation.]", strings.Join(blocked, ", "))
messages = append(messages, openai.SystemMessage(note)) messages = append(messages, openai.SystemMessage(note))
} }
} else if assent && len(pending) == 0 { } else if operatorAssented && len(pending) == 0 {
// The operator said "proceed"/"go ahead"/"yes" but there are no // The operator said "proceed"/"go ahead"/"yes" but there are no
// pending approvals — the agent proposed a plan (via propose_plan) // pending approvals — the agent proposed a plan (via propose_plan)
// and asked "shall I?" Open the assent window silently. No system // and asked "shall I?" Open the assent window silently. No system
@@ -365,12 +376,12 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
// Retry cap (P0.1 from plans/2026-07-18-session-review-three-sessions.md): // Retry cap (P0.1 from plans/2026-07-18-session-review-three-sessions.md):
// track failing `run` calls within this turn so an identical command that // track failing `run` calls within this turn so an identical command that
// keeps failing is refused after maxRunRetries attempts. Without this, // keeps failing is refused after retrycap.MaxRunRetries attempts. Without this,
// session 1e9c7691 retried the same `chown` ~20 times, each retry piling // session 1e9c7691 retried the same `chown` ~20 times, each retry piling
// up a zombie process on the target (knfsd was holding a kernel lock). // up a zombie process on the target (knfsd was holding a kernel lock).
// The tracker is per-turn — a fresh turn after the operator responds can // The tracker is per-turn — a fresh turn after the operator responds can
// retry once more, so this doesn't permanently block recovery. // retry once more, so this doesn't permanently block recovery.
retries := newRunRetryTracker() retries := retrycap.New()
for i := 0; i < maxIterations; i++ { for i := 0; i < maxIterations; i++ {
params := openai.ChatCompletionNewParams{ params := openai.ChatCompletionNewParams{
@@ -501,7 +512,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
} }
// Retry cap: if this `run` call has already failed // Retry cap: if this `run` call has already failed
// maxRunRetries times this turn with the same (target, // retrycap.MaxRunRetries times this turn with the same (target,
// command), refuse to dispatch it again. Return a synthetic // command), refuse to dispatch it again. Return a synthetic
// tool result directing the agent to investigate *why* the // tool result directing the agent to investigate *why* the
// command hangs instead of retrying. See retrycap.go and // command hangs instead of retrying. See retrycap.go and
@@ -509,12 +520,12 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
if tc.Function.Name == "run" { if tc.Function.Name == "run" {
t, _ := args["target"].(string) t, _ := args["target"].(string)
c, _ := args["command"].(string) c, _ := args["command"].(string)
key := runFailureKey(t, c) key := retrycap.RunFailureKey(t, c)
if n := retries.failures(key); n >= maxRunRetries { if n := retries.Failures(key); n >= retrycap.MaxRunRetries {
directive := runRetryDirective(t, c, n) directive := retrycap.RunRetryDirective(t, c, n)
slog.Warn("nomos: run retry cap hit — refusing dispatch", slog.Warn("nomos: run retry cap hit — refusing dispatch",
"target", t, "failures", n, "session", sessionID) "target", t, "failures", n, "session", sessionID)
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, a.store.LogActivity(ctx, a.agentID, sessionID, tc.Function.Name, args,
tc.Function.Arguments, directive, 0, false, correlationID, totalTokens) tc.Function.Arguments, directive, 0, false, correlationID, totalTokens)
emit(agentEvent{ emit(agentEvent{
Type: "tool_result", Type: "tool_result",
@@ -566,7 +577,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
inputStr := string(inputJSON) inputStr := string(inputJSON)
if callErr != nil { if callErr != nil {
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID, totalTokens) a.store.LogActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID, totalTokens)
// Retry cap: dispatch errors (e.g. MCP client timeout) // Retry cap: dispatch errors (e.g. MCP client timeout)
// count toward the cap too. A command that keeps timing // count toward the cap too. A command that keeps timing
@@ -576,9 +587,9 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
if tc.Function.Name == "run" { if tc.Function.Name == "run" {
t, _ := args["target"].(string) t, _ := args["target"].(string)
c, _ := args["command"].(string) c, _ := args["command"].(string)
key := runFailureKey(t, c) key := retrycap.RunFailureKey(t, c)
n := retries.recordFailure(key) n := retries.RecordFailure(key)
if n >= maxRunRetries { if n >= retrycap.MaxRunRetries {
slog.Warn("nomos: run failure cap reached — next identical call will be refused", slog.Warn("nomos: run failure cap reached — next identical call will be refused",
"target", t, "failures", n, "session", sessionID) "target", t, "failures", n, "session", sessionID)
} }
@@ -596,7 +607,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
} }
resultJSON, _ := json.Marshal(result) resultJSON, _ := json.Marshal(result)
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, string(resultJSON), elapsed, true, correlationID, totalTokens) a.store.LogActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, string(resultJSON), elapsed, true, correlationID, totalTokens)
// Link any execution this tool queued/started back to this // Link any execution this tool queued/started back to this
// session, so the auto-continuation worker can feed its result // session, so the auto-continuation worker can feed its result
@@ -604,18 +615,18 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
// executions (pct_create, apt_upgrade) are the ones that matter — // executions (pct_create, apt_upgrade) are the ones that matter —
// their result lands after this turn ends. // their result lands after this turn ends.
for _, execID := range extractExecutionIDs(string(resultJSON)) { for _, execID := range extractExecutionIDs(string(resultJSON)) {
a.store.linkExecution(ctx, execID, sessionID) a.store.LinkExecution(ctx, execID, sessionID)
} }
// Record which entities this task touched (task —involves→ entity) // Record which entities this task touched (task —involves→ entity)
// and pulse them on the live context panel. Args only — never // and pulse them on the live context panel. Args only — never
// results — so a bulk query doesn't drag the whole fleet in. // results — so a bulk query doesn't drag the whole fleet in.
a.store.recordTouched(ctx, sessionID, tc.Function.Name, args) a.store.RecordTouched(ctx, sessionID, tc.Function.Name, args)
// When the agent records knowledge, link that note to this task so // When the agent records knowledge, link that note to this task so
// the task's outcome view shows what it learned (and pulse it live). // the task's outcome view shows what it learned (and pulse it live).
if tc.Function.Name == "upsert_knowledge" { if tc.Function.Name == "upsert_knowledge" {
a.store.linkKnowledgeToTask(ctx, sessionID, string(resultJSON)) a.store.LinkKnowledgeToTask(ctx, sessionID, string(resultJSON))
} }
emit(agentEvent{ emit(agentEvent{
@@ -635,12 +646,12 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
// queued for approval (pending approvals are not failures). // queued for approval (pending approvals are not failures).
// Pass the RAW result text (not JSON-encoded) so the helper's // Pass the RAW result text (not JSON-encoded) so the helper's
// HasPrefix check sees "run on …" not "\"run on …\"". // HasPrefix check sees "run on …" not "\"run on …\"".
if isRunFailure(tc.Function.Name, runResultText(result), callErr) { if retrycap.IsRunFailure(tc.Function.Name, retrycap.RunResultText(result), callErr) {
t, _ := args["target"].(string) t, _ := args["target"].(string)
c, _ := args["command"].(string) c, _ := args["command"].(string)
key := runFailureKey(t, c) key := retrycap.RunFailureKey(t, c)
n := retries.recordFailure(key) n := retries.RecordFailure(key)
if n >= maxRunRetries { if n >= retrycap.MaxRunRetries {
slog.Warn("nomos: run failure cap reached — next identical call will be refused", slog.Warn("nomos: run failure cap reached — next identical call will be refused",
"target", t, "failures", n, "session", sessionID) "target", t, "failures", n, "session", sessionID)
} }

View File

@@ -9,6 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/dtoro/oikos/internal/nomos/session"
"github.com/dtoro/oikos/internal/safego" "github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -71,7 +72,7 @@ func (a *agent) runIdleSweepWorker(ctx context.Context) {
// reasoning resumeSession already applies below for a different failure // reasoning resumeSession already applies below for a different failure
// mode (a resume that produces no response at all). // mode (a resume that produces no response at all).
func (a *agent) processIdleSweep(ctx context.Context) { func (a *agent) processIdleSweep(ctx context.Context) {
stale := a.store.staleGoalSessions(ctx, idleTaskThreshold, 5) stale := a.store.StaleGoalSessions(ctx, idleTaskThreshold, 5)
for _, s := range stale { for _, s := range stale {
s := s s := s
if s.CompletionNudges == 0 { if s.CompletionNudges == 0 {
@@ -80,13 +81,13 @@ func (a *agent) processIdleSweep(ctx context.Context) {
"If the goal is done (or can't be completed), call complete_task now with the outcome and a "+ "If the goal is done (or can't be completed), call complete_task now with the outcome and a "+
"one-line summary. If you're still genuinely working through the plan, ignore this and continue.]", "one-line summary. If you're still genuinely working through the plan, ignore this and continue.]",
s.Goal, idleTaskThreshold) s.Goal, idleTaskThreshold)
note = a.store.enrichResumeNote(ctx, s.ID, note) note = a.store.EnrichResumeNote(ctx, s.ID, note)
// P1: only count the nudge if it actually delivered. resumeSession // P1: only count the nudge if it actually delivered. resumeSession
// skips (returns false) when a turn is already active; bumping the // skips (returns false) when a turn is already active; bumping the
// counter anyway would make the next sweep auto-close a merely-busy // counter anyway would make the next sweep auto-close a merely-busy
// session as "unanswered." // session as "unanswered."
if a.resumeSession(ctx, s.ID, note) { if a.resumeSession(ctx, s.ID, note) {
if err := a.store.bumpCompletionNudge(ctx, s.ID); err != nil { if err := a.store.BumpCompletionNudge(ctx, s.ID); err != nil {
slog.Error("nomos: idle nudge bump failed", "session", s.ID, "error", err) slog.Error("nomos: idle nudge bump failed", "session", s.ID, "error", err)
} }
} }
@@ -95,7 +96,7 @@ func (a *agent) processIdleSweep(ctx context.Context) {
} }
safego.Go("nomos:idle-autoclose:"+s.ID, func() { safego.Go("nomos:idle-autoclose:"+s.ID, func() {
summary := fmt.Sprintf("Auto-closed after %s idle with no response to a completion nudge.", idleTaskThreshold) summary := fmt.Sprintf("Auto-closed after %s idle with no response to a completion nudge.", idleTaskThreshold)
if err := a.store.completeTask(ctx, s.ID, "partial", summary); err != nil { if err := a.store.CompleteTask(ctx, s.ID, "partial", summary); err != nil {
slog.Error("nomos: idle auto-close failed", "session", s.ID, "error", err) slog.Error("nomos: idle auto-close failed", "session", s.ID, "error", err)
} }
}) })
@@ -141,19 +142,19 @@ func (a *agent) runContinuationWorker(ctx context.Context) {
// silently die until nomos restarted. Now a single bad item can only ever // silently die until nomos restarted. Now a single bad item can only ever
// take down its own goroutine. // take down its own goroutine.
func (a *agent) processContinuations(ctx context.Context) { func (a *agent) processContinuations(ctx context.Context) {
pending := a.store.pendingContinuations(ctx, 5) pending := a.store.PendingContinuations(ctx, 5)
for _, p := range pending { for _, p := range pending {
// Scope gate: only auto-continue while an approved plan is active FOR // Scope gate: only auto-continue while an approved plan is active FOR
// THIS SESSION. Checked per-item, not once for the whole batch — with // THIS SESSION. Checked per-item, not once for the whole batch — with
// multiple tasks in flight, one task's open window must never cover a // multiple tasks in flight, one task's open window must never cover a
// pending continuation belonging to a different task. // pending continuation belonging to a different task.
if !a.store.assentWindowActive(ctx, a.agentID, p.SessionID) { if !a.store.AssentWindowActive(ctx, a.agentID, p.SessionID) {
// Re-open the assent window if this session is genuinely // Re-open the assent window if this session is genuinely
// executing (plan was approved, work is in progress) — the // executing (plan was approved, work is in progress) — the
// window may have expired while the execution ran. Don't // window may have expired while the execution ran. Don't
// penalize timing: the plan was approved, the work happened, // penalize timing: the plan was approved, the work happened,
// the result should flow back. // the result should flow back.
sesh, seshErr := a.store.getSession(ctx, p.SessionID) sesh, seshErr := a.store.GetSession(ctx, p.SessionID)
if seshErr == nil && sesh.Goal != "" && (sesh.Status == "executing" || sesh.Status == "planning") { if seshErr == nil && sesh.Goal != "" && (sesh.Status == "executing" || sesh.Status == "planning") {
a.openAssentWindow(ctx, p.SessionID) a.openAssentWindow(ctx, p.SessionID)
slog.Info("nomos: re-opened assent window for continuing session", "session", p.SessionID, "execution", p.ExecID) slog.Info("nomos: re-opened assent window for continuing session", "session", p.SessionID, "execution", p.ExecID)
@@ -162,8 +163,8 @@ func (a *agent) processContinuations(ctx context.Context) {
// operator knows WHY the agent didn't auto-continue. // operator knows WHY the agent didn't auto-continue.
note := fmt.Sprintf("[System: execution %s finished with status=%s, but the assent window for this session is not active. The agent will not auto-continue. Reply 'continue' or re-approve the plan to resume.]", p.ExecID, p.Status) note := fmt.Sprintf("[System: execution %s finished with status=%s, but the assent window for this session is not active. The agent will not auto-continue. Reply 'continue' or re-approve the plan to resume.]", p.ExecID, p.Status)
body, _ := json.Marshal(map[string]any{"role": "assistant", "text": note, "auto": true}) body, _ := json.Marshal(map[string]any{"role": "assistant", "text": note, "auto": true})
a.store.saveMessage(context.Background(), p.SessionID, "assistant", body) a.store.SaveMessage(context.Background(), p.SessionID, "assistant", body)
a.store.markContinued(ctx, p.ExecID) a.store.MarkContinued(ctx, p.ExecID)
continue continue
} }
} }
@@ -183,7 +184,7 @@ func (a *agent) processContinuations(ctx context.Context) {
// that takes, which is exactly the "I just wait while nothing happens" // that takes, which is exactly the "I just wait while nothing happens"
// complaint this exists to fix — polling alone only helps if there's // complaint this exists to fix — polling alone only helps if there's
// something new to poll for. // something new to poll for.
func (a *agent) continueSession(ctx context.Context, p pendingContinuation) { func (a *agent) continueSession(ctx context.Context, p session.PendingContinuation) {
slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status) slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status)
// P0 (plans/2026-08-03-nomos-chat-changes-review.md): mark the execution // P0 (plans/2026-08-03-nomos-chat-changes-review.md): mark the execution
// continued ONLY after the turn actually ran. resumeSession skips (returns // continued ONLY after the turn actually ran. resumeSession skips (returns
@@ -196,7 +197,7 @@ func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
slog.Info("nomos: continuation deferred — a turn is active; will retry next tick", "session", p.SessionID, "execution", p.ExecID) slog.Info("nomos: continuation deferred — a turn is active; will retry next tick", "session", p.SessionID, "execution", p.ExecID)
return return
} }
a.store.markContinued(ctx, p.ExecID) a.store.MarkContinued(ctx, p.ExecID)
} }
// resumeSession re-invokes the agent for a session with a system-injected note — // resumeSession re-invokes the agent for a session with a system-injected note —
@@ -218,7 +219,7 @@ func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
// the state changed but the work undone (lost continuation / false auto-close). // the state changed but the work undone (lost continuation / false auto-close).
// See plans/2026-08-03-nomos-chat-changes-review.md P0/P1. // See plans/2026-08-03-nomos-chat-changes-review.md P0/P1.
func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool { func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool {
if !a.gate.acquire(sessionID, 0) { if !a.gate.Acquire(sessionID, 0) {
slog.Info("nomos: turn already active, skipping background resume", "session", sessionID) slog.Info("nomos: turn already active, skipping background resume", "session", sessionID)
return false return false
} }
@@ -226,7 +227,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
// this background turn ran (plan 2026-08-03 F2). Queued messages are run as // this background turn ran (plan 2026-08-03 F2). Queued messages are run as
// real user turns server-side; resumeSession itself never enqueues. // real user turns server-side; resumeSession itself never enqueues.
defer func() { defer func() {
a.gate.release(sessionID) a.gate.Release(sessionID)
safego.Go("nomos:drain:"+sessionID, func() { a.drainQueued(context.Background(), sessionID) }) safego.Go("nomos:drain:"+sessionID, func() { a.drainQueued(context.Background(), sessionID) })
}() }()
@@ -235,7 +236,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
"text": "", "text": "",
"auto": true, "auto": true,
}) })
msgID, err := a.store.insertMessageReturningID(ctx, sessionID, "assistant", placeholder) msgID, err := a.store.InsertMessageReturningID(ctx, sessionID, "assistant", placeholder)
if err != nil { if err != nil {
slog.Error("nomos: resume placeholder insert failed", "session", sessionID, "error", err) slog.Error("nomos: resume placeholder insert failed", "session", sessionID, "error", err)
} }
@@ -259,7 +260,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
"tool_calls": toolCalls, "tool_calls": toolCalls,
"auto": true, // marks this as an autonomous continuation, not an operator turn "auto": true, // marks this as an autonomous continuation, not an operator turn
}) })
a.store.updateMessage(ctx, msgID, body) a.store.UpdateMessage(ctx, msgID, body)
} }
// One retry if the LLM call itself produced nothing (transient flake / // One retry if the LLM call itself produced nothing (transient flake /
@@ -362,10 +363,10 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
"auto": true, "auto": true,
}) })
if msgID != uuid.Nil { if msgID != uuid.Nil {
a.store.updateMessage(context.Background(), msgID, body) a.store.UpdateMessage(context.Background(), msgID, body)
} else { } else {
// No placeholder was inserted (rare), save directly. // No placeholder was inserted (rare), save directly.
a.store.saveMessage(context.Background(), sessionID, "assistant", body) a.store.SaveMessage(context.Background(), sessionID, "assistant", body)
} }
return true // do not call persist() again — already persisted above return true // do not call persist() again — already persisted above
} }
@@ -376,7 +377,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
// buildContinuationNote frames the finished execution for the model: what // buildContinuationNote frames the finished execution for the model: what
// happened, and what to do about it. The persist-through-errors instruction // happened, and what to do about it. The persist-through-errors instruction
// lives here (and in SOUL) so the agent recovers instead of stopping. // lives here (and in SOUL) so the agent recovers instead of stopping.
func buildContinuationNote(p pendingContinuation) string { func buildContinuationNote(p session.PendingContinuation) string {
action := p.Action action := p.Action
if i := strings.IndexByte(action, ':'); i > 0 && len(action) > 40 { if i := strings.IndexByte(action, ':'); i > 0 && len(action) > 40 {
action = action[:i] // keep just the action verb for brevity; params are in the DB action = action[:i] // keep just the action verb for brevity; params are in the DB

View File

@@ -54,7 +54,7 @@ func TestExtractExecutionIDs(t *testing.T) {
// would dereference the nil provider and panic. Returning false cleanly proves // would dereference the nil provider and panic. Returning false cleanly proves
// the body was skipped. // the body was skipped.
func TestResumeSession_SkipsWhenBusy(t *testing.T) { func TestResumeSession_SkipsWhenBusy(t *testing.T) {
a := &agent{gate: newTurnGate()} a := &agent{gate: turngate.New()}
if !a.gate.acquire("sess", 0) { if !a.gate.acquire("sess", 0) {
t.Fatal("precondition: initial acquire should succeed on a free session") t.Fatal("precondition: initial acquire should succeed on a free session")
} }
@@ -69,7 +69,7 @@ func TestResumeSession_SkipsWhenBusy(t *testing.T) {
// next worker tick) instead of running or marking it. It must return cleanly // next worker tick) instead of running or marking it. It must return cleanly
// without reaching resumeSession's body (nil provider → panic) or markContinued. // without reaching resumeSession's body (nil provider → panic) or markContinued.
func TestContinueSession_DefersWhenBusy(t *testing.T) { func TestContinueSession_DefersWhenBusy(t *testing.T) {
a := &agent{gate: newTurnGate()} a := &agent{gate: turngate.New()}
if !a.gate.acquire("sess", 0) { if !a.gate.acquire("sess", 0) {
t.Fatal("precondition: initial acquire should succeed on a free session") t.Fatal("precondition: initial acquire should succeed on a free session")
} }

View File

@@ -1,142 +0,0 @@
package main
import (
"context"
"sync"
"testing"
"time"
)
func TestMessageQueue_FIFO(t *testing.T) {
q := newMessageQueue()
q.enqueue("s", "first")
q.enqueue("s", "second")
q.enqueue("s", "third")
want := []string{"first", "second", "third"}
for _, w := range want {
got, ok := q.dequeue("s")
if !ok || got != w {
t.Fatalf("dequeue = %q,%v want %q,true", got, ok, w)
}
}
if _, ok := q.dequeue("s"); ok {
t.Fatal("dequeue on drained queue should return ok=false")
}
}
func TestMessageQueue_RequeueFront(t *testing.T) {
q := newMessageQueue()
q.enqueue("s", "a")
q.enqueue("s", "b")
// Pop "a", then push it back to the front; "a" must come out before "b".
a, _ := q.dequeue("s")
q.requeueFront("s", a)
got, _ := q.dequeue("s")
if got != "a" {
t.Fatalf("after requeueFront, dequeue = %q want %q", got, "a")
}
got2, _ := q.dequeue("s")
if got2 != "b" {
t.Fatalf("next dequeue = %q want %q", got2, "b")
}
}
func TestMessageQueue_IsolatedPerSession(t *testing.T) {
q := newMessageQueue()
q.enqueue("s1", "one")
q.enqueue("s2", "two")
if got, _ := q.dequeue("s1"); got != "one" {
t.Fatalf("s1 = %q want one", got)
}
if got, _ := q.dequeue("s2"); got != "two" {
t.Fatalf("s2 = %q want two", got)
}
if q.peek("s1") != 0 || q.peek("s2") != 0 {
t.Fatal("both sessions should be drained")
}
}
func TestMessageQueue_Concurrent(t *testing.T) {
q := newMessageQueue()
const n = maxQueuedPerSession // stay under the cap so every enqueue lands
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
q.enqueue("s", "m")
}(i)
}
wg.Wait()
if q.peek("s") != n {
t.Fatalf("peek = %d want %d (all enqueues must be counted)", q.peek("s"), n)
}
seen := 0
for {
if _, ok := q.dequeue("s"); !ok {
break
}
seen++
}
if seen != n {
t.Fatalf("drained %d want %d", seen, n)
}
}
func TestMessageQueue_CapsOverflow(t *testing.T) {
q := newMessageQueue()
for i := 0; i < maxQueuedPerSession; i++ {
if !q.enqueue("s", "m") {
t.Fatalf("enqueue #%d within cap should succeed", i)
}
}
if q.enqueue("s", "overflow") {
t.Fatal("enqueue past the cap should return false (dropped)")
}
if got := q.peek("s"); got != maxQueuedPerSession {
t.Fatalf("peek = %d want %d (overflow must not append)", got, maxQueuedPerSession)
}
}
// drainQueued on an empty queue must be a no-op: it returns immediately and
// never touches the gate (so the session stays free for the next turn).
func TestDrainQueued_NoOpOnEmpty(t *testing.T) {
a := &agent{gate: newTurnGate(), queue: newMessageQueue()}
a.drainQueued(context.Background(), "s")
if !a.gate.acquire("s", 0) {
t.Fatal("gate should be free after a no-op drain (drain must not hold it)")
}
a.gate.release("s")
}
// With a queued message but the gate held by another turn, drainQueued must
// re-queue the message and return WITHOUT running a turn (no store/provider → a
// real run would panic). This is the "never stack" property: a busy gate
// defers to the holder's own release-drain.
func TestDrainQueued_RequeuesWhenBusy(t *testing.T) {
prev := drainAcquireWait
drainAcquireWait = 10 * time.Millisecond
t.Cleanup(func() { drainAcquireWait = prev })
a := &agent{gate: newTurnGate(), queue: newMessageQueue()}
if !a.gate.acquire("s", 0) {
t.Fatal("precondition: hold the gate")
}
a.queue.enqueue("s", "queued-msg")
done := make(chan struct{})
go func() {
a.drainQueued(context.Background(), "s") // must not panic; must requeue
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("drainQueued did not return promptly while the gate was busy")
}
if got := a.queue.peek("s"); got != 1 {
t.Fatalf("message should be re-queued while busy; peek = %d want 1", got)
}
a.gate.release("s")
}

View File

@@ -14,9 +14,9 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/dtoro/oikos/internal/nomos/session"
"github.com/dtoro/oikos/internal/safego" "github.com/dtoro/oikos/internal/safego"
"github.com/dtoro/oikos/internal/secrets" "github.com/dtoro/oikos/internal/secrets"
"github.com/jackc/pgx/v5"
) )
func main() { func main() {
@@ -90,13 +90,13 @@ func main() {
probe.close() probe.close()
} }
st, err := newStore(ctx, databaseURL) st, err := session.New(ctx, databaseURL)
if err != nil { if err != nil {
slog.Error("nomos: db connect", "error", err) slog.Error("nomos: db connect", "error", err)
os.Exit(1) os.Exit(1)
} }
if st != nil { if st != nil {
defer st.close() defer st.Close()
} }
nAgent, err := newAgent(ctx, clientPool, st, agentSlug, openrouterAPIKey) nAgent, err := newAgent(ctx, clientPool, st, agentSlug, openrouterAPIKey)
@@ -138,7 +138,7 @@ func main() {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
st.cleanupStaleExecutions(ctx, 10*time.Minute) st.CleanupStaleExecutions(ctx, 10*time.Minute)
} }
} }
}) })
@@ -211,7 +211,7 @@ func sseEvent(w http.ResponseWriter, flusher http.Flusher, event agentEvent) {
flusher.Flush() flusher.Flush()
} }
func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) { func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *session.Store) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405) http.Error(w, "method not allowed", 405)
return return
@@ -238,7 +238,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
// abandoned): there's nothing to resume, and running a "report state" // abandoned): there's nothing to resume, and running a "report state"
// turn there is just a spare turn the operator never asked for (P2.1). // turn there is just a spare turn the operator never asked for (P2.1).
if req.Message == "" && req.SessionID != "" { if req.Message == "" && req.SessionID != "" {
if sess, err := st.getSession(context.Background(), req.SessionID); err == nil { if sess, err := st.GetSession(context.Background(), req.SessionID); err == nil {
switch sess.Status { switch sess.Status {
case "done", "failed", "abandoned": case "done", "failed", "abandoned":
slog.Info("nomos: reconnect skipped — session already terminal", "session", req.SessionID, "status", sess.Status) slog.Info("nomos: reconnect skipped — session already terminal", "session", req.SessionID, "status", sess.Status)
@@ -249,7 +249,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
slog.Info("nomos: reconnect", "session", req.SessionID) slog.Info("nomos: reconnect", "session", req.SessionID)
safego.Go("nomos:reconnect:"+req.SessionID, func() { safego.Go("nomos:reconnect:"+req.SessionID, func() {
base := "[System: the operator's connection was re-established. The task may have progressed in the background.]" base := "[System: the operator's connection was re-established. The task may have progressed in the background.]"
note := st.enrichResumeNote(context.Background(), req.SessionID, base) note := st.EnrichResumeNote(context.Background(), req.SessionID, base)
a.resumeSession(context.Background(), req.SessionID, note) a.resumeSession(context.Background(), req.SessionID, note)
}) })
// Return 202 so the frontend doesn't try to consume an SSE stream // Return 202 so the frontend doesn't try to consume an SSE stream
@@ -299,7 +299,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
if sessionID == "" { if sessionID == "" {
title := truncate(req.Message, 80) title := truncate(req.Message, 80)
sess, err := st.createSession(pctx, title) sess, err := st.CreateSession(pctx, title)
if err != nil { if err != nil {
slog.Error("nomos: create session", "error", err) slog.Error("nomos: create session", "error", err)
sessionID = "ephemeral" sessionID = "ephemeral"
@@ -313,24 +313,24 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
// execute). reopenSession marks the prior plan's steps as // execute). reopenSession marks the prior plan's steps as
// `replaced` (proposePlan ignores those) and clears outcome/ // `replaced` (proposePlan ignores those) and clears outcome/
// summary. Without this, propose_plan refuses the follow-up with // summary. Without this, propose_plan refuses the follow-up with
// errPlanInFlight because the prior steps are all `done`. If the // ErrPlanInFlight because the prior steps are all `done`. If the
// session is still active, reopen is a no-op — the follow-up is // session is still active, reopen is a no-op — the follow-up is
// just a continuation of in-flight work. // just a continuation of in-flight work.
st.reopenSession(pctx, sessionID) st.ReopenSession(pctx, sessionID)
st.touchSession(pctx, sessionID) st.TouchSession(pctx, sessionID)
} }
slog.Info("nomos: chat", "session", sessionID, "message", truncate(req.Message, 100)) slog.Info("nomos: chat", "session", sessionID, "message", truncate(req.Message, 100))
userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message}) userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message})
st.saveMessage(pctx, sessionID, "user", userMsg) st.SaveMessage(pctx, sessionID, "user", userMsg)
// If this task has a pending operator question, the incoming message IS the // If this task has a pending operator question, the incoming message IS the
// answer — close it so the panel clears. No separate resume needed: this // answer — close it so the panel clears. No separate resume needed: this
// chat turn is the resume, and the agent sees the question + answer in its // chat turn is the resume, and the agent sees the question + answer in its
// replayed history. // replayed history.
if qid := st.openQuestionID(pctx, sessionID); qid != "" { if qid := st.OpenQuestionID(pctx, sessionID); qid != "" {
st.answerQuestion(pctx, sessionID, qid, req.Message) st.AnswerQuestion(pctx, sessionID, qid, req.Message)
} }
writeEvent(agentEvent{Type: "session", Data: sessionID, SessionID: sessionID}) writeEvent(agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
@@ -343,8 +343,8 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
// real turn server-side. This never stacks concurrent turns — the gate still // real turn server-side. This never stacks concurrent turns — the gate still
// guarantees one in-flight turn per session. // guarantees one in-flight turn per session.
const turnWait = 5 * time.Second const turnWait = 5 * time.Second
if !a.gate.acquire(sessionID, turnWait) { if !a.gate.Acquire(sessionID, turnWait) {
a.queue.enqueue(sessionID, req.Message) a.queue.Enqueue(sessionID, req.Message)
slog.Info("nomos: turn already active, queued operator message", "session", sessionID) slog.Info("nomos: turn already active, queued operator message", "session", sessionID)
writeEvent(agentEvent{Type: "queued", Data: sessionID, SessionID: sessionID}) writeEvent(agentEvent{Type: "queued", Data: sessionID, SessionID: sessionID})
writeEvent(agentEvent{Type: "done", Data: map[string]any{ writeEvent(agentEvent{Type: "done", Data: map[string]any{
@@ -354,7 +354,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
return return
} }
defer func() { defer func() {
a.gate.release(sessionID) a.gate.Release(sessionID)
// Run any message that was queued while this turn held the gate. In a // Run any message that was queued while this turn held the gate. In a
// goroutine so the HTTP response finishes without waiting on the next // goroutine so the HTTP response finishes without waiting on the next
// turn; the queued turn has no SSE client of its own. // turn; the queued turn has no SSE client of its own.
@@ -394,7 +394,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
}) })
} }
func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) { func handleSessionsList(w http.ResponseWriter, r *http.Request, st *session.Store) {
if st == nil { if st == nil {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"sessions": []any{}}) json.NewEncoder(w).Encode(map[string]any{"sessions": []any{}})
@@ -425,7 +425,7 @@ func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
limit = n limit = n
} }
} }
sessions, err := st.listSessionsFiltered(r.Context(), listFilter{ sessions, err := st.ListSessionsFiltered(r.Context(), ListFilter{
Outcome: q.Get("outcome"), Outcome: q.Get("outcome"),
Status: q.Get("status"), Status: q.Get("status"),
EntityID: q.Get("entity_id"), EntityID: q.Get("entity_id"),
@@ -457,7 +457,7 @@ func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
}) })
} }
func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *agent) { func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *session.Store, a *agent) {
if st == nil { if st == nil {
http.Error(w, "not found", 404) http.Error(w, "not found", 404)
return return
@@ -485,7 +485,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
// POST /sessions/{id}/resume — the operator asks the agent to continue. // POST /sessions/{id}/resume — the operator asks the agent to continue.
if len(parts) == 2 && parts[1] == "resume" && r.Method == http.MethodPost { if len(parts) == 2 && parts[1] == "resume" && r.Method == http.MethodPost {
base := "[System: the operator wants you to continue. Pick up where you left off — execute the next step of the plan, diagnose and fix any failures, or report progress if everything is done.]" base := "[System: the operator wants you to continue. Pick up where you left off — execute the next step of the plan, diagnose and fix any failures, or report progress if everything is done.]"
note := st.enrichResumeNote(context.Background(), id, base) note := st.EnrichResumeNote(context.Background(), id, base)
safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), id, note) }) safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), id, note) })
w.WriteHeader(202) w.WriteHeader(202)
return return
@@ -503,7 +503,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
switch parts[1] { switch parts[1] {
case "plan": case "plan":
all := r.URL.Query().Has("all") && r.URL.Query().Get("all") != "0" && r.URL.Query().Get("all") != "false" all := r.URL.Query().Has("all") && r.URL.Query().Get("all") != "0" && r.URL.Query().Get("all") != "false"
steps, err := st.getPlanSteps(r.Context(), id, all) steps, err := st.GetPlanSteps(r.Context(), id, all)
if err != nil { if err != nil {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return
@@ -512,7 +512,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
json.NewEncoder(w).Encode(map[string]any{"steps": steps}) json.NewEncoder(w).Encode(map[string]any{"steps": steps})
return return
case "questions": case "questions":
questions, err := st.getQuestions(r.Context(), id) questions, err := st.GetQuestions(r.Context(), id)
if err != nil { if err != nil {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return
@@ -521,7 +521,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
json.NewEncoder(w).Encode(map[string]any{"questions": questions}) json.NewEncoder(w).Encode(map[string]any{"questions": questions})
return return
case "tool_calls": case "tool_calls":
calls, err := st.getSessionToolCalls(r.Context(), id) calls, err := st.GetSessionToolCalls(r.Context(), id)
if err != nil { if err != nil {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return
@@ -534,7 +534,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
switch r.Method { switch r.Method {
case http.MethodDelete: case http.MethodDelete:
if err := st.deleteSession(r.Context(), id); err != nil { if err := st.DeleteSession(r.Context(), id); err != nil {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return
} }
@@ -551,7 +551,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
// pending_approvals, message_count, tool_call_count, etc. The // pending_approvals, message_count, tool_call_count, etc. The
// messages field is unchanged. Clients that only read // messages field is unchanged. Clients that only read
// `messages` keep working. // `messages` keep working.
sess, err := st.getSession(r.Context(), id) sess, err := st.GetSession(r.Context(), id)
if err != nil { if err != nil {
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
http.Error(w, "session not found", 404) http.Error(w, "session not found", 404)
@@ -560,7 +560,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return
} }
messages, err := st.getMessages(r.Context(), id) messages, err := st.GetMessages(r.Context(), id)
if err != nil { if err != nil {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return
@@ -580,7 +580,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
// handleAnswerQuestion records the operator's answer to a pinned question and // handleAnswerQuestion records the operator's answer to a pinned question and
// resumes the agent in the background with that answer injected. Returns 202 — // resumes the agent in the background with that answer injected. Returns 202 —
// the agent's response lands via the normal message-polling path, not this POST. // the agent's response lands via the normal message-polling path, not this POST.
func handleAnswerQuestion(w http.ResponseWriter, r *http.Request, st *store, a *agent, sessionID, questionID string) { func handleAnswerQuestion(w http.ResponseWriter, r *http.Request, st *session.Store, a *agent, sessionID, questionID string) {
var req struct { var req struct {
Answer string `json:"answer"` Answer string `json:"answer"`
} }
@@ -588,15 +588,15 @@ func handleAnswerQuestion(w http.ResponseWriter, r *http.Request, st *store, a *
http.Error(w, "answer is required", 400) http.Error(w, "answer is required", 400)
return return
} }
prompt, _, _ := st.getQuestion(r.Context(), questionID) prompt, _, _ := st.GetQuestion(r.Context(), questionID)
if err := st.answerQuestion(r.Context(), sessionID, questionID, req.Answer); err != nil { if err := st.AnswerQuestion(r.Context(), sessionID, questionID, req.Answer); err != nil {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return
} }
if a != nil { if a != nil {
base := fmt.Sprintf("[System: the operator answered your question %q with: %q. "+ base := fmt.Sprintf("[System: the operator answered your question %q with: %q. "+
"Continue the task from here — do not re-ask.]", prompt, req.Answer) "Continue the task from here — do not re-ask.]", prompt, req.Answer)
note := st.enrichResumeNote(context.Background(), sessionID, base) note := st.EnrichResumeNote(context.Background(), sessionID, base)
safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), sessionID, note) }) safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), sessionID, note) })
} }
w.WriteHeader(202) w.WriteHeader(202)

View File

@@ -175,7 +175,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
if strings.TrimSpace(goal) == "" { if strings.TrimSpace(goal) == "" {
return "error: set_goal needs a goal", true return "error: set_goal needs a goal", true
} }
if err := a.store.setGoal(ctx, sessionID, goal); err != nil { if err := a.store.SetGoal(ctx, sessionID, goal); err != nil {
return fmt.Sprintf("error setting goal: %v", err), true return fmt.Sprintf("error setting goal: %v", err), true
} }
// P1: the plan window is NOT opened here. Opening it on set_goal // P1: the plan window is NOT opened here. Opening it on set_goal
@@ -196,7 +196,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
// goal + summary; if it looks related, search_knowledge or open // goal + summary; if it looks related, search_knowledge or open
// the prior session's transcript (GET /sessions/{id}) before // the prior session's transcript (GET /sessions/{id}) before
// re-planning. See plans/2026-07-20-session-review-ten-sessions.md. // re-planning. See plans/2026-07-20-session-review-ten-sessions.md.
prior, _ := a.store.recentPartialSessions(ctx, sessionID, 24*time.Hour) prior, _ := a.store.RecentPartialSessions(ctx, sessionID, 24*time.Hour)
if len(prior) > 0 { if len(prior) > 0 {
var b strings.Builder var b strings.Builder
b.WriteString("\n\nNOTE — recent unfinished sessions (last 24h, outcome=partial/failed):") b.WriteString("\n\nNOTE — recent unfinished sessions (last 24h, outcome=partial/failed):")
@@ -222,7 +222,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
case "propose_plan": case "propose_plan":
raw, _ := args["steps"].([]any) raw, _ := args["steps"].([]any)
var steps []planStepInput var steps []PlanStepInput
for _, r := range raw { for _, r := range raw {
m, ok := r.(map[string]any) m, ok := r.(map[string]any)
if !ok { if !ok {
@@ -234,7 +234,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
} }
detail, _ := m["detail"].(string) detail, _ := m["detail"].(string)
target, _ := m["target_slug"].(string) target, _ := m["target_slug"].(string)
steps = append(steps, planStepInput{Title: title, Detail: detail, TargetSlug: target}) steps = append(steps, PlanStepInput{Title: title, Detail: detail, TargetSlug: target})
} }
if len(steps) == 0 { if len(steps) == 0 {
return "error: propose_plan needs at least one step with a title", true return "error: propose_plan needs at least one step with a title", true
@@ -263,15 +263,15 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
} }
appendedNote := "" appendedNote := ""
if !hasWritebackStep { if !hasWritebackStep {
steps = append(steps, planStepInput{ steps = append(steps, PlanStepInput{
Title: "Write back: update_entity_attributes + create_relationship + upsert_knowledge", Title: "Write back: update_entity_attributes + create_relationship + upsert_knowledge",
Detail: "Call update_entity_attributes for every entity you ran against (versions, states, counts, timestamps). Call create_relationship for any edge you discovered. Then upsert_knowledge about the affected entities (pass `about` as an array).", Detail: "Call update_entity_attributes for every entity you ran against (versions, states, counts, timestamps). Call create_relationship for any edge you discovered. Then upsert_knowledge about the affected entities (pass `about` as an array).",
}) })
appendedNote = fmt.Sprintf(" (appended a writeback step — your plan didn't include one; step %d)", len(steps)) appendedNote = fmt.Sprintf(" (appended a writeback step — your plan didn't include one; step %d)", len(steps))
} }
persisted, err := a.store.proposePlan(ctx, sessionID, steps) persisted, err := a.store.ProposePlan(ctx, sessionID, steps)
if err != nil { if err != nil {
if errors.Is(err, errPlanInFlight) { if errors.Is(err, session.ErrPlanInFlight) {
// The plan is already in flight — refuse the re-proposal. // The plan is already in flight — refuse the re-proposal.
// The agent must advance the existing plan with // The agent must advance the existing plan with
// update_plan_step + run. This is the structural fix for // update_plan_step + run. This is the structural fix for
@@ -308,8 +308,8 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
return "error: update_plan_step needs seq (>=1) and status", true return "error: update_plan_step needs seq (>=1) and status", true
} }
reason, _ := args["replaced_reason"].(string) reason, _ := args["replaced_reason"].(string)
if err := a.store.updatePlanStep(ctx, sessionID, seq, status, execID, reason); err != nil { if err := a.store.UpdatePlanStep(ctx, sessionID, seq, status, execID, reason); err != nil {
if errors.Is(err, errPlanStepNotFound) { if errors.Is(err, session.ErrPlanStepNotFound) {
// The seq doesn't address a step in the CURRENT plan — most // The seq doesn't address a step in the CURRENT plan — most
// often a stale 1-based number the model carried across a // often a stale 1-based number the model carried across a
// re-plan, or an out-of-range seq. seq is generation-relative // re-plan, or an out-of-range seq. seq is generation-relative
@@ -337,7 +337,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
if ents := toStringSlice(args["context_entities"]); len(ents) > 0 { if ents := toStringSlice(args["context_entities"]); len(ents) > 0 {
qctx["entities"] = ents qctx["entities"] = ents
} }
if _, err := a.store.askOperator(ctx, sessionID, prompt, qctx); err != nil { if _, err := a.store.AskOperator(ctx, sessionID, prompt, qctx); err != nil {
return fmt.Sprintf("error posting question: %v", err), true return fmt.Sprintf("error posting question: %v", err), true
} }
return "Question posted to the operator; the task is paused until they answer. " + return "Question posted to the operator; the task is paused until they answer. " +
@@ -372,7 +372,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
// then retry complete_task. Only blocks `success`; an explicit // then retry complete_task. Only blocks `success`; an explicit
// `failure` or `partial` is allowed through (the agent is // `failure` or `partial` is allowed through (the agent is
// acknowledging it didn't finish — no reason to force writeback). // acknowledging it didn't finish — no reason to force writeback).
if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) && !a.store.hadEntityWriteback(ctx, sessionID) { if outcome == "success" && a.store.HadDiscovery(ctx, sessionID) && !a.store.HadEntityWriteback(ctx, sessionID) {
return "Refused: this session ran `run` against live targets (discovery) but did not call update_entity_attributes or create_relationship to persist what you learned. The knowledge graph will drift if you complete without writeback. Call update_entity_attributes for each entity you ran against (versions, states, counts, timestamps), and create_relationship for any edge you discovered, then call complete_task again. Outcome is held at 'executing' until you do.", true return "Refused: this session ran `run` against live targets (discovery) but did not call update_entity_attributes or create_relationship to persist what you learned. The knowledge graph will drift if you complete without writeback. Call update_entity_attributes for each entity you ran against (versions, states, counts, timestamps), and create_relationship for any edge you discovered, then call complete_task again. Outcome is held at 'executing' until you do.", true
} }
// D.2: refuse success when the goal mentions a reachability/uptime // D.2: refuse success when the goal mentions a reachability/uptime
@@ -381,20 +381,20 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
// return 200 for a terminal page (ttyd) or fallback while the actual // return 200 for a terminal page (ttyd) or fallback while the actual
// dashboard is still down. Must call ping_service or run a successful // dashboard is still down. Must call ping_service or run a successful
// curl before claiming success. // curl before claiming success.
if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) { if outcome == "success" && a.store.HadDiscovery(ctx, sessionID) {
goal := a.store.sessionGoal(ctx, sessionID) goal := a.store.SessionGoal(ctx, sessionID)
if mentionsReachability(goal) && !a.store.hadRecentVerification(ctx, sessionID) { if mentionsReachability(goal) && !a.store.HadRecentVerification(ctx, sessionID) {
return "Refused: the goal involves a reachability or uptime check (\"make X reachable\", \"get X up\", etc.), but no ping_service call or successful curl/HTTP request against the target was detected. Caddy can return 200 for a terminal or fallback page while the actual service is still down — you must verify the service itself, not just the proxy. Call ping_service(target) or run a curl against the actual service URL, then call complete_task again. Outcome held until verified.", true return "Refused: the goal involves a reachability or uptime check (\"make X reachable\", \"get X up\", etc.), but no ping_service call or successful curl/HTTP request against the target was detected. Caddy can return 200 for a terminal or fallback page while the actual service is still down — you must verify the service itself, not just the proxy. Call ping_service(target) or run a curl against the actual service URL, then call complete_task again. Outcome held until verified.", true
} }
} }
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil { if err := a.store.CompleteTask(ctx, sessionID, outcome, summary); err != nil {
if errors.Is(err, errTaskAlreadyComplete) { if errors.Is(err, session.ErrTaskAlreadyComplete) {
return "Task is already complete. Do not call complete_task again. If the operator pointed out a UI/sidebar inconsistency, fix it with update_plan_step (reconcile step states) or summarize the panel in your reply — do not re-execute the work.", true return "Task is already complete. Do not call complete_task again. If the operator pointed out a UI/sidebar inconsistency, fix it with update_plan_step (reconcile step states) or summarize the panel in your reply — do not re-execute the work.", true
} }
return fmt.Sprintf("error completing task: %v", err), true return fmt.Sprintf("error completing task: %v", err), true
} }
result := fmt.Sprintf("Task marked %s: %s", outcome, summary) result := fmt.Sprintf("Task marked %s: %s", outcome, summary)
if !a.store.hadEntityWriteback(ctx, sessionID) { if !a.store.HadEntityWriteback(ctx, sessionID) {
result += "\n\n⚠ No entity attributes or relationships were updated in this session. Call update_entity_attributes and create_relationship to persist what you learned about entities before the next session starts from scratch." result += "\n\n⚠ No entity attributes or relationships were updated in this session. Call update_entity_attributes and create_relationship to persist what you learned about entities before the next session starts from scratch."
} }
return result, true return result, true
@@ -445,7 +445,7 @@ func (a *agent) autoCompleteTrivialTask(ctx context.Context, sessionID, response
if summary == "" { if summary == "" {
summary = "Answered without further action needed." summary = "Answered without further action needed."
} }
if err := a.store.completeTask(ctx, sessionID, "success", summary); err != nil { if err := a.store.CompleteTask(ctx, sessionID, "success", summary); err != nil {
slog.Error("nomos: auto-complete trivial task failed", "session", sessionID, "error", err) slog.Error("nomos: auto-complete trivial task failed", "session", sessionID, "error", err)
} }
} }
@@ -464,7 +464,7 @@ func (a *agent) autoCompleteIfPlanDone(ctx context.Context, sessionID, responseT
if a.store == nil || sessionID == "" || sessionID == "ephemeral" { if a.store == nil || sessionID == "" || sessionID == "ephemeral" {
return return
} }
sess, err := a.store.getSession(ctx, sessionID) sess, err := a.store.GetSession(ctx, sessionID)
if err != nil || sess.Status != "executing" { if err != nil || sess.Status != "executing" {
return return
} }
@@ -474,13 +474,13 @@ func (a *agent) autoCompleteIfPlanDone(ctx context.Context, sessionID, responseT
// dead task. Confirmed in eval: agent hits P5 approval gate, turn // dead task. Confirmed in eval: agent hits P5 approval gate, turn
// ends, auto-complete fires incorrectly because the approval-queue // ends, auto-complete fires incorrectly because the approval-queue
// `run` responses were logged as success=true in agent_activity. // `run` responses were logged as success=true in agent_activity.
if a.store.hasPendingApprovals(ctx, sessionID) { if a.store.HasPendingApprovals(ctx, sessionID) {
return return
} }
discovery := a.store.hadDiscovery(ctx, sessionID) discovery := a.store.HadDiscovery(ctx, sessionID)
writeback := a.store.hadEntityWriteback(ctx, sessionID) writeback := a.store.HadEntityWriteback(ctx, sessionID)
// (a) all plan steps terminal, OR (b) agent did discovery (ran `run`). // (a) all plan steps terminal, OR (b) agent did discovery (ran `run`).
shouldComplete := a.store.allPlanStepsTerminal(ctx, sessionID) shouldComplete := a.store.AllPlanStepsTerminal(ctx, sessionID)
if !shouldComplete && discovery { if !shouldComplete && discovery {
shouldComplete = true shouldComplete = true
} }
@@ -500,7 +500,7 @@ func (a *agent) autoCompleteIfPlanDone(ctx context.Context, sessionID, responseT
if summary == "" { if summary == "" {
summary = "All plan steps completed." summary = "All plan steps completed."
} }
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil { if err := a.store.CompleteTask(ctx, sessionID, outcome, summary); err != nil {
slog.Error("nomos: auto-complete plan-done task failed", "session", sessionID, "error", err) slog.Error("nomos: auto-complete plan-done task failed", "session", sessionID, "error", err)
} else { } else {
slog.Info("nomos: auto-completed task — agent didn't call complete_task", "session", sessionID, "outcome", outcome) slog.Info("nomos: auto-completed task — agent didn't call complete_task", "session", sessionID, "outcome", outcome)

View File

@@ -28,7 +28,7 @@ func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string
var finalThinking string var finalThinking string
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""}) placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
msgID, err := a.store.insertMessageReturningID(pctx, sessionID, "assistant", placeholder) msgID, err := a.store.InsertMessageReturningID(pctx, sessionID, "assistant", placeholder)
if err != nil { if err != nil {
slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err) slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err)
} }
@@ -42,7 +42,7 @@ func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string
"thinking": finalThinking, "thinking": finalThinking,
"tool_calls": toolCalls, "tool_calls": toolCalls,
}) })
a.store.updateMessage(pctx, msgID, body) a.store.UpdateMessage(pctx, msgID, body)
} }
a.chat(ctx, sessionID, message, func(ev agentEvent) { a.chat(ctx, sessionID, message, func(ev agentEvent) {
@@ -86,7 +86,7 @@ func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string
// empty-response'd and all retries failed), delete the placeholder row // empty-response'd and all retries failed), delete the placeholder row
// instead of persisting an empty bubble. // instead of persisting an empty bubble.
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil { if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
a.store.deleteMessage(pctx, msgID) a.store.DeleteMessage(pctx, msgID)
} else { } else {
persist() // final state — same row, updated one last time persist() // final state — same row, updated one last time
} }
@@ -94,7 +94,7 @@ func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string
// Title: prefer the goal once set; else the first assistant answer. // Title: prefer the goal once set; else the first assistant answer.
if finalText != "" && sessionID != "ephemeral" { if finalText != "" && sessionID != "ephemeral" {
var goalTitle string var goalTitle string
if sess, gerr := a.store.getSession(pctx, sessionID); gerr == nil && sess.Goal != "" { if sess, gerr := a.store.GetSession(pctx, sessionID); gerr == nil && sess.Goal != "" {
goalTitle = truncate(sess.Goal, 120) goalTitle = truncate(sess.Goal, 120)
} }
title := goalTitle title := goalTitle
@@ -102,7 +102,7 @@ func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string
title = truncate(finalText, 80) title = truncate(finalText, 80)
} }
if title != "" { if title != "" {
a.store.updateSessionTitle(pctx, sessionID, title) a.store.UpdateSessionTitle(pctx, sessionID, title)
} }
} }
} }
@@ -125,14 +125,14 @@ var drainAcquireWait = 5 * time.Second
// result via the 3s poller and the status-driven "working" indicator. // result via the 3s poller and the status-driven "working" indicator.
func (a *agent) drainQueued(ctx context.Context, sessionID string) { func (a *agent) drainQueued(ctx context.Context, sessionID string) {
for { for {
msg, ok := a.queue.dequeue(sessionID) msg, ok := a.queue.Dequeue(sessionID)
if !ok { if !ok {
return return
} }
// Block briefly for the gate. If a live turn grabbed it first, put the // Block briefly for the gate. If a live turn grabbed it first, put the
// message back — that turn's release will drain it again. Never stack. // message back — that turn's release will drain it again. Never stack.
if !a.gate.acquire(sessionID, drainAcquireWait) { if !a.gate.Acquire(sessionID, drainAcquireWait) {
a.queue.requeueFront(sessionID, msg) a.queue.RequeueFront(sessionID, msg)
return return
} }
slog.Info("nomos: running queued operator message", "session", sessionID) slog.Info("nomos: running queued operator message", "session", sessionID)
@@ -145,7 +145,7 @@ func (a *agent) drainQueued(ctx context.Context, sessionID string) {
// the loop would be wrong too: Go defers run at function exit, not // the loop would be wrong too: Go defers run at function exit, not
// iteration exit, so the gate would stay held across iterations. // iteration exit, so the gate would stay held across iterations.
func() { func() {
defer a.gate.release(sessionID) defer a.gate.Release(sessionID)
a.runChatTurn(pctx, ctx, sessionID, msg, func(agentEvent) {}) a.runChatTurn(pctx, ctx, sessionID, msg, func(agentEvent) {})
}() }()
} }

View File

@@ -5,12 +5,9 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io/fs"
"log/slog" "log/slog"
"sort"
"strings"
"github.com/dtoro/oikos/migrations" "github.com/dtoro/oikos/internal/migrate"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -39,99 +36,11 @@ func New(ctx context.Context, databaseURL string) (*Pool, error) {
return &Pool{pool}, nil return &Pool{pool}, nil
} }
// migrationLockKey is the advisory-lock key serializing migration runs — // Migrate applies all embedded forward migrations in order (delegates to
// two concurrent `oikos migrate` invocations must not interleave DDL. // the shared runner in internal/migrate — the same one nomos's session
const migrationLockKey = 0x01c05e5 // tests use; ADR 0016 rule 3 keeps nomos off the adapters).
// Migrate runs all embedded forward migrations in order.
// Uses a schema_migrations table to track applied versions. The whole run
// happens on one connection holding a session advisory lock.
func (p *Pool) Migrate(ctx context.Context) error { func (p *Pool) Migrate(ctx context.Context) error {
conn, err := p.Acquire(ctx) return migrate.Apply(ctx, p.Pool)
if err != nil {
return fmt.Errorf("acquire migration conn: %w", err)
}
defer conn.Release()
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
return fmt.Errorf("acquire migration lock: %w", err)
}
defer func() {
if _, err := conn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", migrationLockKey); err != nil {
slog.Warn("postgres: release migration lock failed", "error", err)
}
}()
// Create tracking table if not exists
_, err = conn.Exec(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version INT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
// List migration files
entries, err := fs.ReadDir(migrations.FS, ".")
if err != nil {
return fmt.Errorf("read migration fs: %w", err)
}
var files []string
for _, e := range entries {
if !e.IsDir() && hasSuffix(e.Name(), ".up.sql") {
files = append(files, e.Name())
}
}
sort.Strings(files)
for _, fname := range files {
// Extract version number (001, 002, etc.)
var version int
if _, err := fmt.Sscanf(fname, "%03d", &version); err != nil {
continue
}
// Check if already applied
var applied int
err := conn.QueryRow(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = $1", version).Scan(&applied)
if err != nil {
return fmt.Errorf("check migration %d: %w", version, err)
}
if applied > 0 {
continue
}
// Read and execute migration — split into individual statements
// because TimescaleDB CAGGs and some DDL can't run inside a transaction,
// and pgx's multi-statement Exec wraps them implicitly.
content, err := migrations.FS.ReadFile(fname)
if err != nil {
return fmt.Errorf("read %s: %w", fname, err)
}
stmts := splitSQL(string(content))
for i, stmt := range stmts {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
_, err := conn.Exec(ctx, stmt)
if err != nil {
return fmt.Errorf("exec migration %s stmt %d: %w", fname, i+1, err)
}
}
_, err = conn.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version)
if err != nil {
return fmt.Errorf("record migration %d: %w", version, err)
}
slog.Info("migration applied", "file", fname, "version", version, "statements", len(stmts))
}
return nil
} }
// SeedIngest ingests a YAML seed file into the database. // SeedIngest ingests a YAML seed file into the database.
@@ -191,98 +100,3 @@ func contentHash(content []byte) string {
h := sha256.Sum256(content) h := sha256.Sum256(content)
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:])
} }
// hasSuffix reports whether the string ends with the given suffix.
func hasSuffix(s, suffix string) bool {
return strings.HasSuffix(s, suffix)
}
// splitSQL splits a SQL string into individual statements.
// Handles $$ ... $$ dollar-quoted blocks, $tag$ ... $tag$ tagged quotes,
// -- line comments, /* ... */ block comments, and '...' string literals
// so that semicolons inside any of these constructs are not treated as
// statement boundaries.
func splitSQL(sql string) []string {
var statements []string
var current strings.Builder
inDollarQuote := false
dollarTag := ""
i := 0
for i < len(sql) {
// Handle line comments (-- to end of line)
if !inDollarQuote && i+1 < len(sql) && sql[i] == '-' && sql[i+1] == '-' {
for i < len(sql) && sql[i] != '\n' {
current.WriteByte(sql[i])
i++
}
continue
}
// Handle block comments (/* ... */)
if !inDollarQuote && i+1 < len(sql) && sql[i] == '/' && sql[i+1] == '*' {
end := strings.Index(sql[i+2:], "*/")
if end >= 0 {
current.WriteString(sql[i : i+end+4])
i += end + 4
continue
}
}
// Handle single-quoted string literals ('...')
if !inDollarQuote && sql[i] == '\'' {
j := i + 1
for j < len(sql) {
if sql[j] == '\'' {
if j+1 < len(sql) && sql[j+1] == '\'' {
j += 2 // skip doubled quote ''
continue
}
break
}
j++
}
current.WriteString(sql[i : j+1])
i = j + 1
continue
}
// Check for dollar-quote start/end
if !inDollarQuote && sql[i] == '$' {
j := i + 1
for j < len(sql) && (sql[j] == '_' || (sql[j] >= 'a' && sql[j] <= 'z') || (sql[j] >= 'A' && sql[j] <= 'Z') || (sql[j] >= '0' && sql[j] <= '9')) {
j++
}
if j < len(sql) && sql[j] == '$' {
dollarTag = sql[i : j+1]
current.WriteString(dollarTag)
inDollarQuote = true
i = j + 1
continue
}
}
if inDollarQuote && strings.HasPrefix(sql[i:], dollarTag) {
current.WriteString(dollarTag)
i += len(dollarTag)
inDollarQuote = false
dollarTag = ""
continue
}
if !inDollarQuote && sql[i] == ';' {
statements = append(statements, current.String())
current.Reset()
i++
continue
}
current.WriteByte(sql[i])
i++
}
if strings.TrimSpace(current.String()) != "" {
statements = append(statements, current.String())
}
return statements
}

119
internal/migrate/migrate.go Normal file
View File

@@ -0,0 +1,119 @@
// Package migrate applies the embedded forward-only SQL migrations
// (migrations/*.up.sql). It is shared infrastructure: the oikos postgres
// adapter runs it at pool startup, and nomos's session tests use it to
// build throwaway databases — nomos must not import the adapters, so the
// runner lives here, one level above both (ADR 0016 rule 3).
package migrate
import (
"context"
"fmt"
"io/fs"
"log/slog"
"sort"
"strings"
"github.com/dtoro/oikos/migrations"
"github.com/jackc/pgx/v5/pgxpool"
)
// migrationLockKey is the advisory-lock key serializing migration runs —
// two concurrent migrators must not interleave DDL.
const migrationLockKey = 0x01c05e5
// PgxPool is the surface Apply needs: acquire a dedicated connection for
// the lock-held run.
type PgxPool interface {
Acquire(ctx context.Context) (*pgxpool.Conn, error)
}
// Apply runs all embedded forward migrations in order, on one connection
// holding a session advisory lock. Uses the schema_migrations table to
// track applied versions.
func Apply(ctx context.Context, pool PgxPool) error {
conn, err := pool.Acquire(ctx)
if err != nil {
return fmt.Errorf("acquire migration conn: %w", err)
}
defer conn.Release()
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
return fmt.Errorf("acquire migration lock: %w", err)
}
defer func() {
if _, err := conn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", migrationLockKey); err != nil {
slog.Warn("migrate: release lock failed", "error", err)
}
}()
// Create tracking table if not exists
_, err = conn.Exec(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version INT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
// List migration files
entries, err := fs.ReadDir(migrations.FS, ".")
if err != nil {
return fmt.Errorf("read migration fs: %w", err)
}
var files []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
files = append(files, e.Name())
}
}
sort.Strings(files)
for _, fname := range files {
// Extract version number (001, 002, etc.)
var version int
if _, err := fmt.Sscanf(fname, "%03d", &version); err != nil {
continue
}
// Check if already applied
var applied int
err := conn.QueryRow(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = $1", version).Scan(&applied)
if err != nil {
return fmt.Errorf("check migration %d: %w", version, err)
}
if applied > 0 {
continue
}
// Read and execute migration — split into individual statements
// because TimescaleDB CAGGs and some DDL can't run inside a transaction,
// and pgx's multi-statement Exec wraps them implicitly.
content, err := migrations.FS.ReadFile(fname)
if err != nil {
return fmt.Errorf("read %s: %w", fname, err)
}
stmts := SplitSQL(string(content))
for i, stmt := range stmts {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
_, err := conn.Exec(ctx, stmt)
if err != nil {
return fmt.Errorf("exec migration %s stmt %d: %w", fname, i+1, err)
}
}
_, err = conn.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version)
if err != nil {
return fmt.Errorf("record migration %d: %w", version, err)
}
slog.Info("migration applied", "file", fname, "version", version, "statements", len(stmts))
}
return nil
}

93
internal/migrate/split.go Normal file
View File

@@ -0,0 +1,93 @@
package migrate
import "strings"
// splitSQL splits a SQL string into individual statements.
// Handles $$ ... $$ dollar-quoted blocks, $tag$ ... $tag$ tagged quotes,
// -- line comments, /* ... */ block comments, and '...' string literals
// so that semicolons inside any of these constructs are not treated as
// statement boundaries.
func SplitSQL(sql string) []string {
var statements []string
var current strings.Builder
inDollarQuote := false
dollarTag := ""
i := 0
for i < len(sql) {
// Handle line comments (-- to end of line)
if !inDollarQuote && i+1 < len(sql) && sql[i] == '-' && sql[i+1] == '-' {
for i < len(sql) && sql[i] != '\n' {
current.WriteByte(sql[i])
i++
}
continue
}
// Handle block comments (/* ... */)
if !inDollarQuote && i+1 < len(sql) && sql[i] == '/' && sql[i+1] == '*' {
end := strings.Index(sql[i+2:], "*/")
if end >= 0 {
current.WriteString(sql[i : i+end+4])
i += end + 4
continue
}
}
// Handle single-quoted string literals ('...')
if !inDollarQuote && sql[i] == '\'' {
j := i + 1
for j < len(sql) {
if sql[j] == '\'' {
if j+1 < len(sql) && sql[j+1] == '\'' {
j += 2 // skip doubled quote ''
continue
}
break
}
j++
}
current.WriteString(sql[i : j+1])
i = j + 1
continue
}
// Check for dollar-quote start/end
if !inDollarQuote && sql[i] == '$' {
j := i + 1
for j < len(sql) && (sql[j] == '_' || (sql[j] >= 'a' && sql[j] <= 'z') || (sql[j] >= 'A' && sql[j] <= 'Z') || (sql[j] >= '0' && sql[j] <= '9')) {
j++
}
if j < len(sql) && sql[j] == '$' {
dollarTag = sql[i : j+1]
current.WriteString(dollarTag)
inDollarQuote = true
i = j + 1
continue
}
}
if inDollarQuote && strings.HasPrefix(sql[i:], dollarTag) {
current.WriteString(dollarTag)
i += len(dollarTag)
inDollarQuote = false
dollarTag = ""
continue
}
if !inDollarQuote && sql[i] == ';' {
statements = append(statements, current.String())
current.Reset()
i++
continue
}
current.WriteByte(sql[i])
i++
}
if strings.TrimSpace(current.String()) != "" {
statements = append(statements, current.String())
}
return statements
}

View File

@@ -1,4 +1,4 @@
package db package migrate
import ( import (
"strings" "strings"
@@ -16,7 +16,7 @@ func nonEmpty(stmts []string) []string {
} }
func TestSplitSQLBasic(t *testing.T) { func TestSplitSQLBasic(t *testing.T) {
stmts := nonEmpty(splitSQL("CREATE TABLE a (id int); CREATE TABLE b (id int);")) stmts := nonEmpty(SplitSQL("CREATE TABLE a (id int); CREATE TABLE b (id int);"))
if len(stmts) != 2 { if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts) t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
} }
@@ -27,7 +27,7 @@ func TestSplitSQLDollarQuotedFunction(t *testing.T) {
SELECT 1; SELECT 2; SELECT 1; SELECT 2;
$$ LANGUAGE sql; $$ LANGUAGE sql;
CREATE TABLE t (id int);` CREATE TABLE t (id int);`
stmts := nonEmpty(splitSQL(sql)) stmts := nonEmpty(SplitSQL(sql))
if len(stmts) != 2 { if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts) t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
} }
@@ -38,7 +38,7 @@ CREATE TABLE t (id int);`
func TestSplitSQLTaggedDollarQuote(t *testing.T) { func TestSplitSQLTaggedDollarQuote(t *testing.T) {
sql := `DO $body$ BEGIN PERFORM 1; END $body$;SELECT 1;` sql := `DO $body$ BEGIN PERFORM 1; END $body$;SELECT 1;`
stmts := nonEmpty(splitSQL(sql)) stmts := nonEmpty(SplitSQL(sql))
if len(stmts) != 2 { if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts) t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
} }
@@ -46,7 +46,7 @@ func TestSplitSQLTaggedDollarQuote(t *testing.T) {
func TestSplitSQLSemicolonInComment(t *testing.T) { func TestSplitSQLSemicolonInComment(t *testing.T) {
sql := "-- comment with ; semicolon\nCREATE TABLE t (id int); -- trailing; note\nSELECT 1;" sql := "-- comment with ; semicolon\nCREATE TABLE t (id int); -- trailing; note\nSELECT 1;"
stmts := nonEmpty(splitSQL(sql)) stmts := nonEmpty(SplitSQL(sql))
if len(stmts) != 2 { if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts) t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
} }
@@ -54,7 +54,7 @@ func TestSplitSQLSemicolonInComment(t *testing.T) {
func TestSplitSQLSemicolonInStringLiteral(t *testing.T) { func TestSplitSQLSemicolonInStringLiteral(t *testing.T) {
sql := `SELECT 'hello; world'; INSERT INTO t VALUES (1);` sql := `SELECT 'hello; world'; INSERT INTO t VALUES (1);`
stmts := nonEmpty(splitSQL(sql)) stmts := nonEmpty(SplitSQL(sql))
if len(stmts) != 2 { if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts) t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
} }
@@ -62,7 +62,7 @@ func TestSplitSQLSemicolonInStringLiteral(t *testing.T) {
func TestSplitSQLDollarSignInStringLiteral(t *testing.T) { func TestSplitSQLDollarSignInStringLiteral(t *testing.T) {
sql := `SELECT '$100'; SELECT 2;` sql := `SELECT '$100'; SELECT 2;`
stmts := nonEmpty(splitSQL(sql)) stmts := nonEmpty(SplitSQL(sql))
if len(stmts) != 2 { if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts) t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
} }
@@ -70,7 +70,7 @@ func TestSplitSQLDollarSignInStringLiteral(t *testing.T) {
func TestSplitSQLBlockComment(t *testing.T) { func TestSplitSQLBlockComment(t *testing.T) {
sql := `SELECT 1; /* block; with; semicolons */ SELECT 2;` sql := `SELECT 1; /* block; with; semicolons */ SELECT 2;`
stmts := nonEmpty(splitSQL(sql)) stmts := nonEmpty(SplitSQL(sql))
if len(stmts) != 2 { if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts) t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
} }
@@ -78,7 +78,7 @@ func TestSplitSQLBlockComment(t *testing.T) {
func TestSplitSQLBlockCommentWithDollarQuote(t *testing.T) { func TestSplitSQLBlockCommentWithDollarQuote(t *testing.T) {
sql := `/* $$ not a dollar quote */ SELECT 1;` sql := `/* $$ not a dollar quote */ SELECT 1;`
stmts := nonEmpty(splitSQL(sql)) stmts := nonEmpty(SplitSQL(sql))
if len(stmts) != 1 { if len(stmts) != 1 {
t.Fatalf("got %d statements, want 1: %#v", len(stmts), stmts) t.Fatalf("got %d statements, want 1: %#v", len(stmts), stmts)
} }
@@ -86,21 +86,21 @@ func TestSplitSQLBlockCommentWithDollarQuote(t *testing.T) {
func TestSplitSQLDoubledQuoteInString(t *testing.T) { func TestSplitSQLDoubledQuoteInString(t *testing.T) {
sql := `SELECT 'O''Brien'; SELECT 2;` sql := `SELECT 'O''Brien'; SELECT 2;`
stmts := nonEmpty(splitSQL(sql)) stmts := nonEmpty(SplitSQL(sql))
if len(stmts) != 2 { if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts) t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
} }
} }
func TestSplitSQLEmptyInput(t *testing.T) { func TestSplitSQLEmptyInput(t *testing.T) {
stmts := nonEmpty(splitSQL("")) stmts := nonEmpty(SplitSQL(""))
if len(stmts) != 0 { if len(stmts) != 0 {
t.Fatalf("got %d statements, want 0", len(stmts)) t.Fatalf("got %d statements, want 0", len(stmts))
} }
} }
func TestSplitSQLNoSemicolon(t *testing.T) { func TestSplitSQLNoSemicolon(t *testing.T) {
stmts := nonEmpty(splitSQL("SELECT 1")) stmts := nonEmpty(SplitSQL("SELECT 1"))
if len(stmts) != 1 { if len(stmts) != 1 {
t.Fatalf("got %d statements, want 1", len(stmts)) t.Fatalf("got %d statements, want 1", len(stmts))
} }

View File

@@ -1,11 +1,6 @@
package main package assent
import ( import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"regexp" "regexp"
"strings" "strings"
) )
@@ -19,11 +14,11 @@ import (
// something new. Destructive-risk actions are excluded: they always need the // something new. Destructive-risk actions are excluded: they always need the
// explicit typed-confirmation flow, never loose assent. // explicit typed-confirmation flow, never loose assent.
// pendingApproval is one gated action proposed in the immediately-preceding // PendingApproval is one gated action proposed in the immediately-preceding
// assistant turn, extracted from its tool_result text. // assistant turn, extracted from its tool_result text.
type pendingApproval struct { type PendingApproval struct {
execID string ExecID string
destructive bool Destructive bool
} }
// executionQueuedRE matches the "execution <uuid> queued" phrasing shared by // executionQueuedRE matches the "execution <uuid> queued" phrasing shared by
@@ -32,17 +27,16 @@ var executionQueuedRE = regexp.MustCompile(`(?i)execution\s+([0-9a-f]{8}-[0-9a-f
// extractPendingApprovals scans the tool results of one assistant turn for // extractPendingApprovals scans the tool results of one assistant turn for
// gated actions that are still awaiting a decision. // gated actions that are still awaiting a decision.
func extractPendingApprovals(calls []persistedCall) []pendingApproval { func ExtractPendingApprovals(resultTexts []string) []PendingApproval {
var out []pendingApproval var out []PendingApproval
for _, c := range calls { for _, text := range resultTexts {
text := c.resultText()
m := executionQueuedRE.FindStringSubmatch(text) m := executionQueuedRE.FindStringSubmatch(text)
if m == nil { if m == nil {
continue continue
} }
out = append(out, pendingApproval{ out = append(out, PendingApproval{
execID: m[1], ExecID: m[1],
destructive: strings.Contains(strings.ToUpper(text), "DESTRUCTIVE"), Destructive: strings.Contains(strings.ToUpper(text), "DESTRUCTIVE"),
}) })
} }
return out return out
@@ -115,7 +109,7 @@ func containsPhrase(tokens []string, phrase string) bool {
// pending proposal. Deliberately simple and auditable: a fixed word list, // pending proposal. Deliberately simple and auditable: a fixed word list,
// not a model judgment call, so behavior is predictable and can't be // not a model judgment call, so behavior is predictable and can't be
// prompt-injected via the pending action's own content. // prompt-injected via the pending action's own content.
func isAssent(msg string) bool { func IsAssent(msg string) bool {
tokens := tokenize(msg) tokens := tokenize(msg)
for _, w := range negationWords { for _, w := range negationWords {
if containsPhrase(tokens, w) { if containsPhrase(tokens, w) {
@@ -137,7 +131,7 @@ func isAssent(msg string) bool {
// this is the typed-confirmation phrase SOUL.md tells the operator to use // this is the typed-confirmation phrase SOUL.md tells the operator to use
// ("I confirm destroy 135"). Still negation-aware for the same reason as // ("I confirm destroy 135"). Still negation-aware for the same reason as
// isAssent: "don't confirm yet" must not accidentally match. // isAssent: "don't confirm yet" must not accidentally match.
func isTypedConfirmation(msg string) bool { func IsTypedConfirmation(msg string) bool {
tokens := tokenize(msg) tokens := tokenize(msg)
for _, w := range negationWords { for _, w := range negationWords {
if containsPhrase(tokens, w) { if containsPhrase(tokens, w) {
@@ -147,37 +141,3 @@ func isTypedConfirmation(msg string) bool {
return containsPhrase(tokens, "confirm") || containsPhrase(tokens, "confirmed") return containsPhrase(tokens, "confirm") || containsPhrase(tokens, "confirmed")
} }
// approveExecution grants (or denies) a pending execution via the same HTTP
// endpoint the chat UI's Approve button calls, so both paths share one code
// path server-side (executeApprovedAction) and one audit trail. Returns the
// decided status, or an error if the request failed outright (a 4xx for an
// already-decided/expired approval is reported via ok=false, not a hard err,
// since that's an expected race, not a bug).
func (a *agent) approveExecution(ctx context.Context, execID string) (ok bool, status string, err error) {
if a.apiBase == "" {
return false, "", fmt.Errorf("no API base configured")
}
body, _ := json.Marshal(map[string]string{"decision": "approve"})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
a.apiBase+"/api/v1/approvals/"+execID+"/decision", bytes.NewReader(body))
if err != nil {
return false, "", err
}
req.Header.Set("Content-Type", "application/json")
if a.apiToken != "" {
req.Header.Set("Authorization", "Bearer "+a.apiToken)
}
resp, err := a.httpClient.Do(req)
if err != nil {
return false, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, "", nil // already decided / expired / not found — not a hard failure
}
var out struct {
Status string `json:"status"`
}
json.NewDecoder(resp.Body).Decode(&out)
return true, out.Status, nil
}

View File

@@ -1,7 +1,6 @@
package main package assent
import ( import (
"encoding/json"
"testing" "testing"
) )
@@ -106,31 +105,24 @@ func TestIsTypedConfirmation(t *testing.T) {
} }
func TestExtractPendingApprovals(t *testing.T) { func TestExtractPendingApprovals(t *testing.T) {
mkCall := func(text string) persistedCall { got := ExtractPendingApprovals([]string{
b, _ := json.Marshal(text) "run on host:strong requires approval (risk: config_mutation) - execution 019f4930-e22b-7c47-8c6e-715dcd59df19 queued. Present the command...",
return persistedCall{id: "x", name: "run", result: json.RawMessage(b)} "some unrelated read-only result, no approval here",
} "run on lxc:caddy requires approval (risk: destructive) - execution 019f4931-aaaa-7c47-8c6e-715dcd59df20 queued. This is classified DESTRUCTIVE - flag that clearly.",
calls := []persistedCall{ })
mkCall("run on host:strong requires approval (risk: config_mutation) — execution 019f4930-e22b-7c47-8c6e-715dcd59df19 queued. Present the command..."),
mkCall("some unrelated read-only result, no approval here"),
mkCall("run on lxc:caddy requires approval (risk: destructive) — execution 019f4931-aaaa-7c47-8c6e-715dcd59df20 queued. This is classified DESTRUCTIVE — flag that clearly."),
}
got := extractPendingApprovals(calls)
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("expected 2 pending approvals, got %d: %+v", len(got), got) t.Fatalf("expected 2 pending approvals, got %d", len(got))
} }
if got[0].execID != "019f4930-e22b-7c47-8c6e-715dcd59df19" || got[0].destructive { if got[0].ExecID != "019f4930-e22b-7c47-8c6e-715dcd59df19" || got[0].Destructive {
t.Errorf("first approval wrong: %+v", got[0]) t.Errorf("first approval: ExecID=%s Destructive=%v", got[0].ExecID, got[0].Destructive)
} }
if got[1].execID != "019f4931-aaaa-7c47-8c6e-715dcd59df20" || !got[1].destructive { if got[1].ExecID != "019f4931-aaaa-7c47-8c6e-715dcd59df20" || !got[1].Destructive {
t.Errorf("second approval should be flagged destructive: %+v", got[1]) t.Errorf("second approval should be flagged destructive: %+v", got[1])
} }
} }
func TestExtractPendingApprovals_NoneWhenNoneQueued(t *testing.T) { func TestExtractPendingApprovals_NoneWhenNoneQueued(t *testing.T) {
b, _ := json.Marshal("fleet is healthy, nothing to report") if got := ExtractPendingApprovals(nil); len(got) != 0 {
calls := []persistedCall{{id: "x", result: json.RawMessage(b)}} t.Errorf("expected 0, got %d", len(got))
if got := extractPendingApprovals(calls); len(got) != 0 {
t.Errorf("expected no pending approvals, got %+v", got)
} }
} }

View File

@@ -1,19 +1,19 @@
package main package messagequeue
import ( import (
"log/slog" "log/slog"
"sync" "sync"
) )
// maxQueuedPerSession caps a session's queue. A held turn plus unbounded // MaxQueuedPerSession caps a session's queue. A held turn plus unbounded
// enqueues would grow memory without limit; an operator nudging a long // enqueues would grow memory without limit; an operator nudging a long
// autonomous turn realistically queues only a handful, so a generous cap is // autonomous turn realistically queues only a handful, so a generous cap is
// pure insurance. Overflow drops the newest enqueue and logs (the message is // pure insurance. Overflow drops the newest Enqueue and logs (the message is
// already persisted in the DB by handleChat before enqueue, so it isn't lost // already persisted in the DB by handleChat before Enqueue, so it isn't lost
// from the transcript — it just won't auto-run). // from the transcript — it just won't auto-run).
const maxQueuedPerSession = 20 const MaxQueuedPerSession = 20
// messageQueue holds operator messages that arrived while a turn was already // MessageQueue holds operator messages that arrived while a turn was already
// running for a session. Plan 2026-08-03 (F2): instead of rejecting the // running for a session. Plan 2026-08-03 (F2): instead of rejecting the
// operator's message with "Nomos is still finishing a previous step… send it // operator's message with "Nomos is still finishing a previous step… send it
// again", the message is queued and auto-run when the in-flight turn releases // again", the message is queued and auto-run when the in-flight turn releases
@@ -28,32 +28,32 @@ const maxQueuedPerSession = 20
// main.go), so this cannot stack concurrent turns — the exact hazard the gate // main.go), so this cannot stack concurrent turns — the exact hazard the gate
// itself exists to prevent. Background resumeSession callers never touch this // itself exists to prevent. Background resumeSession callers never touch this
// queue; they keep their non-blocking skip. // queue; they keep their non-blocking skip.
type messageQueue struct { type MessageQueue struct {
mu sync.Mutex mu sync.Mutex
queue map[string][]string queue map[string][]string
} }
func newMessageQueue() *messageQueue { func New() *MessageQueue {
return &messageQueue{queue: map[string][]string{}} return &MessageQueue{queue: map[string][]string{}}
} }
// enqueue appends a message to the back of the session's FIFO. Returns false // Enqueue appends a message to the back of the session's FIFO. Returns false
// (and logs) if the session is already at maxQueuedPerSession — the caller's // (and logs) if the session is already at MaxQueuedPerSession — the caller's
// message is already persisted in the DB, so this only skips auto-running it. // message is already persisted in the DB, so this only skips auto-running it.
func (q *messageQueue) enqueue(sessionID, msg string) bool { func (q *MessageQueue) Enqueue(sessionID, msg string) bool {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock() defer q.mu.Unlock()
if len(q.queue[sessionID]) >= maxQueuedPerSession { if len(q.queue[sessionID]) >= MaxQueuedPerSession {
slog.Warn("nomos: message queue full; dropping auto-run for operator message", "session", sessionID, "cap", maxQueuedPerSession) slog.Warn("nomos: message queue full; dropping auto-run for operator message", "session", sessionID, "cap", MaxQueuedPerSession)
return false return false
} }
q.queue[sessionID] = append(q.queue[sessionID], msg) q.queue[sessionID] = append(q.queue[sessionID], msg)
return true return true
} }
// dequeue pops the next message from the front of the session's FIFO. Returns // Dequeue pops the next message from the front of the session's FIFO. Returns
// ok=false when empty. // ok=false when empty.
func (q *messageQueue) dequeue(sessionID string) (string, bool) { func (q *MessageQueue) Dequeue(sessionID string) (string, bool) {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock() defer q.mu.Unlock()
xs := q.queue[sessionID] xs := q.queue[sessionID]
@@ -65,17 +65,17 @@ func (q *messageQueue) dequeue(sessionID string) (string, bool) {
return m, true return m, true
} }
// requeueFront pushes a message back to the front — used when a drainer popped // RequeueFront pushes a message back to the front — used when a drainer popped
// a message but lost the race for the gate to a live turn; that turn's own // a message but lost the race for the gate to a live turn; that turn's own
// release will drain it again. // release will drain it again.
func (q *messageQueue) requeueFront(sessionID, msg string) { func (q *MessageQueue) RequeueFront(sessionID, msg string) {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock() defer q.mu.Unlock()
q.queue[sessionID] = append([]string{msg}, q.queue[sessionID]...) q.queue[sessionID] = append([]string{msg}, q.queue[sessionID]...)
} }
// peek reports the queued depth for a session (test/diagnostic helper). // Peek reports the queued depth for a session (test/diagnostic helper).
func (q *messageQueue) peek(sessionID string) int { func (q *MessageQueue) Peek(sessionID string) int {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock() defer q.mu.Unlock()
return len(q.queue[sessionID]) return len(q.queue[sessionID])

View File

@@ -0,0 +1,98 @@
package messagequeue
import (
"sync"
"testing"
)
func TestMessageQueue_FIFO(t *testing.T) {
q := New()
q.Enqueue("s", "first")
q.Enqueue("s", "second")
q.Enqueue("s", "third")
want := []string{"first", "second", "third"}
for _, w := range want {
got, ok := q.Dequeue("s")
if !ok || got != w {
t.Fatalf("Dequeue = %q,%v want %q,true", got, ok, w)
}
}
if _, ok := q.Dequeue("s"); ok {
t.Fatal("Dequeue on drained queue should return ok=false")
}
}
func TestMessageQueue_RequeueFront(t *testing.T) {
q := New()
q.Enqueue("s", "a")
q.Enqueue("s", "b")
// Pop "a", then push it back to the front; "a" must come out before "b".
a, _ := q.Dequeue("s")
q.RequeueFront("s", a)
got, _ := q.Dequeue("s")
if got != "a" {
t.Fatalf("after RequeueFront, Dequeue = %q want %q", got, "a")
}
got2, _ := q.Dequeue("s")
if got2 != "b" {
t.Fatalf("next Dequeue = %q want %q", got2, "b")
}
}
func TestMessageQueue_IsolatedPerSession(t *testing.T) {
q := New()
q.Enqueue("s1", "one")
q.Enqueue("s2", "two")
if got, _ := q.Dequeue("s1"); got != "one" {
t.Fatalf("s1 = %q want one", got)
}
if got, _ := q.Dequeue("s2"); got != "two" {
t.Fatalf("s2 = %q want two", got)
}
if q.Peek("s1") != 0 || q.Peek("s2") != 0 {
t.Fatal("both sessions should be drained")
}
}
func TestMessageQueue_Concurrent(t *testing.T) {
q := New()
const n = MaxQueuedPerSession // stay under the cap so every Enqueue lands
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
q.Enqueue("s", "m")
}(i)
}
wg.Wait()
if q.Peek("s") != n {
t.Fatalf("Peek = %d want %d (all enqueues must be counted)", q.Peek("s"), n)
}
seen := 0
for {
if _, ok := q.Dequeue("s"); !ok {
break
}
seen++
}
if seen != n {
t.Fatalf("drained %d want %d", seen, n)
}
}
func TestMessageQueue_CapsOverflow(t *testing.T) {
q := New()
for i := 0; i < MaxQueuedPerSession; i++ {
if !q.Enqueue("s", "m") {
t.Fatalf("Enqueue #%d within cap should succeed", i)
}
}
if q.Enqueue("s", "overflow") {
t.Fatal("Enqueue past the cap should return false (dropped)")
}
if got := q.Peek("s"); got != MaxQueuedPerSession {
t.Fatalf("Peek = %d want %d (overflow must not append)", got, MaxQueuedPerSession)
}
}

View File

@@ -1,4 +1,4 @@
package main package retrycap
import ( import (
"crypto/sha256" "crypto/sha256"
@@ -7,8 +7,8 @@ import (
"sync" "sync"
) )
// maxRunRetries is the per-turn cap on identical failing `run` tool calls. // MaxRunRetries is the per-turn cap on identical failing `run` tool calls.
// After this many failures with the same (target, command) key, the agent // After this many Failures with the same (target, command) key, the agent
// loop refuses to dispatch the call again and instead surfaces a directive // loop refuses to dispatch the call again and instead surfaces a directive
// to investigate *why* (ps/strace/lsof) or escalate to the operator. // to investigate *why* (ps/strace/lsof) or escalate to the operator.
// //
@@ -20,9 +20,9 @@ import (
// only investigated *why* after the operator explicitly asked // only investigated *why* after the operator explicitly asked
// "the command just keeps running?" — see // "the command just keeps running?" — see
// plans/2026-07-18-session-review-three-sessions.md P0.1. // plans/2026-07-18-session-review-three-sessions.md P0.1.
const maxRunRetries = 3 const MaxRunRetries = 3
// runRetryTracker deduplicates failing `run` calls within a single chat // RunRetryTracker deduplicates failing `run` calls within a single chat
// turn (chatWith invocation). It is NOT persisted across turns — the cap // turn (chatWith invocation). It is NOT persisted across turns — the cap
// is per-turn, so a fresh turn after the operator responds can retry once // is per-turn, so a fresh turn after the operator responds can retry once
// more. The intent is to break a tight retry loop within one turn, not to // more. The intent is to break a tight retry loop within one turn, not to
@@ -31,51 +31,51 @@ const maxRunRetries = 3
// Threading: the agent loop is single-goroutine per turn, but the tracker // Threading: the agent loop is single-goroutine per turn, but the tracker
// is guarded by a mutex so future callers (e.g. concurrent tool dispatch) // is guarded by a mutex so future callers (e.g. concurrent tool dispatch)
// stay safe. The mutex is uncontended on the current hot path. // stay safe. The mutex is uncontended on the current hot path.
type runRetryTracker struct { type RunRetryTracker struct {
mu sync.Mutex mu sync.Mutex
counts map[string]int counts map[string]int
} }
func newRunRetryTracker() *runRetryTracker { func New() *RunRetryTracker {
return &runRetryTracker{counts: make(map[string]int)} return &RunRetryTracker{counts: make(map[string]int)}
} }
// runFailureKey is the dedup key for "this is the same command against the // RunFailureKey is the dedup key for "this is the same command against the
// same target." Whitespace is collapsed so trivial reformatting // same target." Whitespace is collapsed so trivial reformatting
// (newlines vs spaces, trailing whitespace) doesn't escape the cap. The // (newlines vs spaces, trailing whitespace) doesn't escape the cap. The
// purpose field is intentionally NOT part of the key: the agent often // purpose field is intentionally NOT part of the key: the agent often
// rephrases purpose between retries while issuing the same command. // rephrases purpose between retries while issuing the same command.
func runFailureKey(target, command string) string { func RunFailureKey(target, command string) string {
collapsed := strings.Join(strings.Fields(command), " ") collapsed := strings.Join(strings.Fields(command), " ")
target = strings.TrimSpace(target) target = strings.TrimSpace(target)
h := sha256.Sum256([]byte(target + "\x00" + collapsed)) h := sha256.Sum256([]byte(target + "\x00" + collapsed))
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:])
} }
// recordFailure increments the failure count for the given key and returns // RecordFailure increments the failure count for the given key and returns
// the new count. The caller should check `count > maxRunRetries` BEFORE // the new count. The caller should check `count > MaxRunRetries` BEFORE
// dispatching to decide whether to skip the call. // dispatching to decide whether to skip the call.
func (r *runRetryTracker) recordFailure(key string) int { func (r *RunRetryTracker) RecordFailure(key string) int {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
r.counts[key]++ r.counts[key]++
return r.counts[key] return r.counts[key]
} }
// failures returns the current failure count for a key (0 if unseen). // Failures returns the current failure count for a key (0 if unseen).
func (r *runRetryTracker) failures(key string) int { func (r *RunRetryTracker) Failures(key string) int {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
return r.counts[key] return r.counts[key]
} }
// isRunFailure reports whether a `run` tool call's outcome should count // IsRunFailure reports whether a `run` tool call's outcome should count
// as a failure for retry-cap purposes. A call counts as failed when: // as a failure for retry-cap purposes. A call counts as failed when:
// - the dispatch itself errored (callErr != nil), OR // - the dispatch itself errored (callErr != nil), OR
// - the result text starts with "run on <target>: ERROR" — the // - the result text starts with "run on <target>: ERROR" — the
// shape classifyAndGate/sshExec produce when SSH or the command fails. // shape classifyAndGate/sshExec produce when SSH or the command fails.
// //
// Approvals queued ("requires approval") do NOT count as failures: they // Approvals queued ("requires approval") do NOT count as Failures: they
// are pending operator action, not a command execution failure. A read // are pending operator action, not a command execution failure. A read
// of the existing code paths (classifyAndGate in internal/mcp/server.go) // of the existing code paths (classifyAndGate in internal/mcp/server.go)
// confirms the "ERROR" prefix is the stable failure signature for `run`. // confirms the "ERROR" prefix is the stable failure signature for `run`.
@@ -85,7 +85,7 @@ func (r *runRetryTracker) failures(key string) int {
// "run on host:strong: ERROR ...", the MCP client unwraps it back to a // "run on host:strong: ERROR ...", the MCP client unwraps it back to a
// plain Go string (see mcpClient.callTool). The caller should pass that // plain Go string (see mcpClient.callTool). The caller should pass that
// raw string, not json.Marshal's output (which would quote-wrap it). // raw string, not json.Marshal's output (which would quote-wrap it).
func isRunFailure(toolName string, resultText string, callErr error) bool { func IsRunFailure(toolName string, resultText string, callErr error) bool {
if callErr != nil { if callErr != nil {
return true return true
} }
@@ -100,12 +100,12 @@ func isRunFailure(toolName string, resultText string, callErr error) bool {
return strings.Contains(resultText, ": ERROR") return strings.Contains(resultText, ": ERROR")
} }
// runResultText extracts the raw text from a `run` tool's result value as // RunResultText extracts the raw text from a `run` tool's result value as
// returned by mcpClient.callTool — typically a Go string, but may also be // returned by mcpClient.callTool — typically a Go string, but may also be
// a []string (multi-content result) or other JSON-decoded shape. Returns // a []string (multi-content result) or other JSON-decoded shape. Returns
// "" for shapes we don't recognize. Used by the retry-cap path so // "" for shapes we don't recognize. Used by the retry-cap path so
// isRunFailure receives the un-quoted text form (see its doc comment). // IsRunFailure receives the un-quoted text form (see its doc comment).
func runResultText(result any) string { func RunResultText(result any) string {
switch v := result.(type) { switch v := result.(type) {
case string: case string:
return v return v
@@ -125,13 +125,13 @@ func runResultText(result any) string {
return "" return ""
} }
// runRetryDirective is the synthetic tool result returned to the model // RunRetryDirective is the synthetic tool result returned to the model
// when the retry cap is hit, in place of dispatching the call again. It // when the retry cap is hit, in place of dispatching the call again. It
// directs the agent to investigate *why* the command keeps failing before // directs the agent to investigate *why* the command keeps failing before
// retrying, or to surface the blocker to the operator. // retrying, or to surface the blocker to the operator.
func runRetryDirective(target, command string, failures int) string { func RunRetryDirective(target, command string, Failures int) string {
return "Refused: this `run` against " + target + " has failed " + return "Refused: this `run` against " + target + " has failed " +
itoa(failures) + " times this turn — retry cap hit. The command:\n " + itoa(Failures) + " times this turn — retry cap hit. The command:\n " +
command + "\nis almost certainly blocked by something on the target " + command + "\nis almost certainly blocked by something on the target " +
"(a hung process, a kernel lock, an unexported FS, a stuck SSH " + "(a hung process, a kernel lock, an unexported FS, a stuck SSH " +
"session, …) — NOT a transient gateway issue. Do NOT retry with " + "session, …) — NOT a transient gateway issue. Do NOT retry with " +

View File

@@ -1,4 +1,4 @@
package main package retrycap
import ( import (
"strings" "strings"
@@ -15,8 +15,8 @@ func TestRunFailureKey_StableAcrossWhitespace(t *testing.T) {
" chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"}, " chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"},
} }
for i, c := range cases { for i, c := range cases {
ka := runFailureKey("host:strong", c.a) ka := RunFailureKey("host:strong", c.a)
kb := runFailureKey("host:strong", c.b) kb := RunFailureKey("host:strong", c.b)
if ka != kb { if ka != kb {
t.Errorf("case %d: keys differ for whitespace-equivalent commands:\n a=%q\n b=%q", i, c.a, c.b) t.Errorf("case %d: keys differ for whitespace-equivalent commands:\n a=%q\n b=%q", i, c.a, c.b)
} }
@@ -24,49 +24,49 @@ func TestRunFailureKey_StableAcrossWhitespace(t *testing.T) {
} }
func TestRunFailureKey_DiffersByTarget(t *testing.T) { func TestRunFailureKey_DiffersByTarget(t *testing.T) {
a := runFailureKey("host:strong", "echo hi") a := RunFailureKey("host:strong", "echo hi")
b := runFailureKey("host:hubris", "echo hi") b := RunFailureKey("host:hubris", "echo hi")
if a == b { if a == b {
t.Error("keys should differ when target differs") t.Error("keys should differ when target differs")
} }
} }
func TestRunFailureKey_DiffersByCommand(t *testing.T) { func TestRunFailureKey_DiffersByCommand(t *testing.T) {
a := runFailureKey("host:strong", "echo hi") a := RunFailureKey("host:strong", "echo hi")
b := runFailureKey("host:strong", "echo bye") b := RunFailureKey("host:strong", "echo bye")
if a == b { if a == b {
t.Error("keys should differ when command differs") t.Error("keys should differ when command differs")
} }
} }
func TestRunRetryTracker_CountsAndCaps(t *testing.T) { func TestRunRetryTracker_CountsAndCaps(t *testing.T) {
r := newRunRetryTracker() r := New()
key := runFailureKey("host:strong", "chown :10000 /mnt/media_local") key := RunFailureKey("host:strong", "chown :10000 /mnt/media_local")
for i := 1; i <= maxRunRetries; i++ { for i := 1; i <= MaxRunRetries; i++ {
if got := r.recordFailure(key); got != i { if got := r.RecordFailure(key); got != i {
t.Errorf("recordFailure #%d = %d, want %d", i, got, i) t.Errorf("RecordFailure #%d = %d, want %d", i, got, i)
} }
} }
// At the cap, failures() should report maxRunRetries, and the next // At the cap, Failures() should report MaxRunRetries, and the next
// identical call should be refused by the agent loop (failures() >= // identical call should be refused by the agent loop (Failures() >=
// maxRunRetries). // MaxRunRetries).
if got := r.failures(key); got != maxRunRetries { if got := r.Failures(key); got != MaxRunRetries {
t.Errorf("failures = %d, want %d", got, maxRunRetries) t.Errorf("Failures = %d, want %d", got, MaxRunRetries)
} }
if r.failures(key) < maxRunRetries { if r.Failures(key) < MaxRunRetries {
t.Errorf("cap should be enforced at maxRunRetries=%d", maxRunRetries) t.Errorf("cap should be enforced at MaxRunRetries=%d", MaxRunRetries)
} }
} }
func TestRunRetryTracker_PerTurnIsolation(t *testing.T) { func TestRunRetryTracker_PerTurnIsolation(t *testing.T) {
// Different keys don't interfere. // Different keys don't interfere.
r := newRunRetryTracker() r := New()
k1 := runFailureKey("host:strong", "echo a") k1 := RunFailureKey("host:strong", "echo a")
k2 := runFailureKey("host:strong", "echo b") k2 := RunFailureKey("host:strong", "echo b")
r.recordFailure(k1) r.RecordFailure(k1)
r.recordFailure(k1) r.RecordFailure(k1)
if got := r.failures(k2); got != 0 { if got := r.Failures(k2); got != 0 {
t.Errorf("k2 failures = %d, want 0 (keys are isolated)", got) t.Errorf("k2 Failures = %d, want 0 (keys are isolated)", got)
} }
} }
@@ -88,9 +88,9 @@ func TestIsRunFailure(t *testing.T) {
{"callErr set on non-run tool", "get_entity", "some result", errFake{}, true}, // callErr trumps name {"callErr set on non-run tool", "get_entity", "some result", errFake{}, true}, // callErr trumps name
} }
for i, c := range cases { for i, c := range cases {
got := isRunFailure(c.tool, c.result, c.callErr) got := IsRunFailure(c.tool, c.result, c.callErr)
if got != c.want { if got != c.want {
t.Errorf("case %d (%s): isRunFailure = %v, want %v", i, c.desc, got, c.want) t.Errorf("case %d (%s): IsRunFailure = %v, want %v", i, c.desc, got, c.want)
} }
} }
} }
@@ -100,7 +100,7 @@ type errFake struct{}
func (errFake) Error() string { return "fake dispatch error" } func (errFake) Error() string { return "fake dispatch error" }
func TestRunRetryDirective_Content(t *testing.T) { func TestRunRetryDirective_Content(t *testing.T) {
d := runRetryDirective("host:strong", "chown :10000 /mnt/media_local", 3) d := RunRetryDirective("host:strong", "chown :10000 /mnt/media_local", 3)
for _, want := range []string{ for _, want := range []string{
"Refused:", "Refused:",
"host:strong", "host:strong",

View File

@@ -23,10 +23,10 @@ import (
) )
// newTestStore creates a throwaway, fully-migrated database and returns a // newTestStore creates a throwaway, fully-migrated database and returns a
// *store connected to it, cleaned up (including a matching task:<session> // *Store connected to it, cleaned up (including a matching task:<session>
// entity type in the ontology, needed by createTaskEntity/proposePlan tests) // entity type in the ontology, needed by createTaskEntity/proposePlan tests)
// via t.Cleanup. // via t.Cleanup.
func newTestStore(t *testing.T) *store { func newTestStore(t *testing.T) *Store {
t.Helper() t.Helper()
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL") baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
if baseURL == "" { if baseURL == "" {
@@ -74,7 +74,7 @@ func newTestStore(t *testing.T) *store {
t.Fatalf("seed minimal ontology: %v", err) t.Fatalf("seed minimal ontology: %v", err)
} }
return &store{pool: pool.Pool} return &Store{pool: pool.Pool}
} }
func swapTestDatabase(url, dbName string) string { func swapTestDatabase(url, dbName string) string {
@@ -180,7 +180,7 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
// First call: no steps exist yet — must persist as-is (replace mode, // First call: no steps exist yet — must persist as-is (replace mode,
// trivially: nothing to replace). // 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 { if err != nil {
t.Fatalf("proposePlan #1: %v", err) t.Fatalf("proposePlan #1: %v", err)
} }
@@ -199,7 +199,7 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
// Second call, simulating a model that re-proposes mid-flight (the // Second call, simulating a model that re-proposes mid-flight (the
// operator-reported "proceed" bug): since step 1 has left 'pending', // operator-reported "proceed" bug): since step 1 has left 'pending',
// this MUST refuse with errPlanInFlight, not append or replace. // this MUST refuse with errPlanInFlight, not append or replace.
_, err = s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "Step B"}}) _, err = s.proposePlan(ctx, sess.ID, []PlanStepInput{{Title: "Step B"}})
if !errors.Is(err, errPlanInFlight) { if !errors.Is(err, errPlanInFlight) {
t.Fatalf("proposePlan #2: err = %v, want errPlanInFlight (refuse mid-flight re-proposal)", err) t.Fatalf("proposePlan #2: err = %v, want errPlanInFlight (refuse mid-flight re-proposal)", err)
} }
@@ -223,10 +223,10 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("createSession: %v", err) 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) 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) t.Fatalf("proposePlan (revise before execution): %v", err)
} }
// Default (current generation) view: only the revised step. // Default (current generation) view: only the revised step.
@@ -275,14 +275,14 @@ func TestUpdatePlanStep_GenerationRelative(t *testing.T) {
t.Fatalf("createSession: %v", err) t.Fatalf("createSession: %v", err)
} }
// Generation 1: two steps. // 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) t.Fatalf("proposePlan #1: %v", err)
} }
// Re-plan: setGoal marks the gen-1 plan `replaced`, proposePlan starts gen 2. // 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) 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) t.Fatalf("proposePlan #2: %v", err)
} }
@@ -337,7 +337,7 @@ func TestCompleteTask_AutoCloseEmitsEvents(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("createSession: %v", err) 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) t.Fatalf("proposePlan: %v", err)
} }
// A is running, B still pending at completion time. // A is running, B still pending at completion time.
@@ -372,7 +372,7 @@ func TestCompleteTask_AutoCloseEmitsEvents(t *testing.T) {
} }
} }
// TestHadDiscoveryAndWriteback is the store-level proof for D.1 (refuse // TestHadDiscoveryAndWriteback is the Store-level proof for D.1 (refuse
// complete_task when discovery ran without writeback). hadDiscovery must // complete_task when discovery ran without writeback). hadDiscovery must
// report true only after a successful `run` call; hadEntityWriteback must // report true only after a successful `run` call; hadEntityWriteback must
// report true only after a successful update_entity_attributes or // report true only after a successful update_entity_attributes or
@@ -444,7 +444,7 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
} }
} }
// TestSetGoal_SupersessionEvent is the store-level proof for P1.4 from // TestSetGoal_SupersessionEvent is the Store-level proof for P1.4 from
// plans/2026-07-18-session-review-three-sessions.md: when setGoal is called // plans/2026-07-18-session-review-three-sessions.md: when setGoal is called
// and a non-empty prior goal already exists with a DIFFERENT value, a // and a non-empty prior goal already exists with a DIFFERENT value, a
// task.superseded event must be emitted (so the audit trail records the // task.superseded event must be emitted (so the audit trail records the
@@ -502,7 +502,7 @@ func TestSetGoal_SupersededEvent(t *testing.T) {
// countEvents counts observability events of the given type correlated to // countEvents counts observability events of the given type correlated to
// the given session. Used by TestSetGoal_SupersededEvent to assert the // the given session. Used by TestSetGoal_SupersededEvent to assert the
// task.superseded audit-trail signal was emitted. // task.superseded audit-trail signal was emitted.
func countEvents(ctx context.Context, s *store, sessionID, eventType string) int { func countEvents(ctx context.Context, s *Store, sessionID, eventType string) int {
var n int var n int
s.pool.QueryRow(ctx, s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM events WHERE correlation_id = $1 AND type = $2`, `SELECT COUNT(*) FROM events WHERE correlation_id = $1 AND type = $2`,

View File

@@ -1,11 +1,11 @@
package main package turngate
import ( import (
"sync" "sync"
"time" "time"
) )
// turnGate enforces at most one in-flight agent turn per session. // TurnGate enforces at most one in-flight agent turn per session.
// //
// Why this exists (plan 2026-08-03, F1): handleChat runs a turn in the HTTP // Why this exists (plan 2026-08-03, F1): handleChat runs a turn in the HTTP
// request goroutine, and every "resume" path (the empty-message reconnect, // request goroutine, and every "resume" path (the empty-message reconnect,
@@ -20,7 +20,7 @@ import (
// Model: one permit (buffered-1 channel seeded with a single token) per // Model: one permit (buffered-1 channel seeded with a single token) per
// session id. Acquiring consumes the token; releasing puts it back. // session id. Acquiring consumes the token; releasing puts it back.
// - Background/best-effort callers (resumeSession and everything it backs) // - Background/best-effort callers (resumeSession and everything it backs)
// use a non-blocking acquire and SKIP when busy — a duplicate nudge while a // use a non-blocking Acquire and SKIP when busy — a duplicate nudge while a
// turn is already running adds nothing, and the continuation/idle tickers // turn is already running adds nothing, and the continuation/idle tickers
// will retry on their own. // will retry on their own.
// - The live chat path (an operator message) waits briefly for a finishing // - The live chat path (an operator message) waits briefly for a finishing
@@ -29,21 +29,21 @@ import (
// //
// The permits map grows one entry per session id seen. For this single-agent // The permits map grows one entry per session id seen. For this single-agent
// homelab process that set is small and bounded by real sessions; cleanup is // homelab process that set is small and bounded by real sessions; cleanup is
// intentionally omitted (a sweep would race with acquire/release and the // intentionally omitted (a sweep would race with Acquire/Release and the
// memory is negligible). // memory is negligible).
type turnGate struct { type TurnGate struct {
mu sync.Mutex mu sync.Mutex
permits map[string]chan struct{} permits map[string]chan struct{}
} }
func newTurnGate() *turnGate { func New() *TurnGate {
return &turnGate{permits: make(map[string]chan struct{})} return &TurnGate{permits: make(map[string]chan struct{})}
} }
// permit returns the single token-channel for sessionID, creating and seeding // permit returns the single token-channel for sessionID, creating and seeding
// it on first use. Creation is guarded so two concurrent first-callers for the // it on first use. Creation is guarded so two concurrent first-callers for the
// same id share one channel. // same id share one channel.
func (g *turnGate) permit(sessionID string) chan struct{} { func (g *TurnGate) permit(sessionID string) chan struct{} {
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() defer g.mu.Unlock()
ch, ok := g.permits[sessionID] ch, ok := g.permits[sessionID]
@@ -55,11 +55,11 @@ func (g *turnGate) permit(sessionID string) chan struct{} {
return ch return ch
} }
// acquire takes the session's permit. With wait <= 0 it is non-blocking // Acquire takes the session's permit. With wait <= 0 it is non-blocking
// (returns false immediately if a turn is active). With wait > 0 it blocks up // (returns false immediately if a turn is active). With wait > 0 it blocks up
// to wait for the permit, returning false on timeout. Every true return MUST // to wait for the permit, returning false on timeout. Every true return MUST
// be paired with exactly one release. // be paired with exactly one Release.
func (g *turnGate) acquire(sessionID string, wait time.Duration) bool { func (g *TurnGate) Acquire(sessionID string, wait time.Duration) bool {
ch := g.permit(sessionID) ch := g.permit(sessionID)
if wait <= 0 { if wait <= 0 {
select { select {
@@ -79,9 +79,9 @@ func (g *turnGate) acquire(sessionID string, wait time.Duration) bool {
} }
} }
// release returns the session's permit. Idempotent: a release with no matching // Release returns the session's permit. Idempotent: a Release with no matching
// acquire (or a double release) is a no-op rather than a blocking send. // Acquire (or a double Release) is a no-op rather than a blocking send.
func (g *turnGate) release(sessionID string) { func (g *TurnGate) Release(sessionID string) {
ch := g.permit(sessionID) ch := g.permit(sessionID)
select { select {
case ch <- struct{}{}: case ch <- struct{}{}:

View File

@@ -1,4 +1,4 @@
package main package turngate
import ( import (
"sync" "sync"
@@ -8,74 +8,74 @@ import (
) )
func TestTurnGate_NonBlockingSkipsWhenBusy(t *testing.T) { func TestTurnGate_NonBlockingSkipsWhenBusy(t *testing.T) {
g := newTurnGate() g := New()
if !g.acquire("s1", 0) { if !g.Acquire("s1", 0) {
t.Fatal("first non-blocking acquire should succeed on a free session") t.Fatal("first non-blocking Acquire should succeed on a free session")
} }
// A second non-blocking acquire (a background resume) must skip, not queue. // A second non-blocking Acquire (a background resume) must skip, not queue.
if g.acquire("s1", 0) { if g.Acquire("s1", 0) {
t.Fatal("second non-blocking acquire should fail while a turn is active") t.Fatal("second non-blocking Acquire should fail while a turn is active")
} }
// A different session is independent. // A different session is independent.
if !g.acquire("s2", 0) { if !g.Acquire("s2", 0) {
t.Fatal("acquire on a different session should succeed") t.Fatal("Acquire on a different session should succeed")
} }
g.release("s2") g.Release("s2")
g.release("s1") g.Release("s1")
// After release, the session is free again. // After Release, the session is free again.
if !g.acquire("s1", 0) { if !g.Acquire("s1", 0) {
t.Fatal("acquire should succeed again after release") t.Fatal("Acquire should succeed again after Release")
} }
g.release("s1") g.Release("s1")
} }
func TestTurnGate_BlockingAcquireWaitsForRelease(t *testing.T) { func TestTurnGate_BlockingAcquireWaitsForRelease(t *testing.T) {
g := newTurnGate() g := New()
if !g.acquire("s1", 0) { if !g.Acquire("s1", 0) {
t.Fatal("first acquire should succeed") t.Fatal("first Acquire should succeed")
} }
got := make(chan bool, 1) got := make(chan bool, 1)
go func() { got <- g.acquire("s1", 2*time.Second) }() go func() { got <- g.Acquire("s1", 2*time.Second) }()
select { select {
case <-got: case <-got:
t.Fatal("blocking acquire should wait, not return before release") t.Fatal("blocking Acquire should wait, not return before Release")
case <-time.After(50 * time.Millisecond): case <-time.After(50 * time.Millisecond):
// expected: still waiting // expected: still waiting
} }
g.release("s1") g.Release("s1")
select { select {
case ok := <-got: case ok := <-got:
if !ok { if !ok {
t.Fatal("blocking acquire should succeed after release") t.Fatal("blocking Acquire should succeed after Release")
} }
case <-time.After(time.Second): case <-time.After(time.Second):
t.Fatal("blocking acquire did not return after release") t.Fatal("blocking Acquire did not return after Release")
} }
g.release("s1") g.Release("s1")
} }
func TestTurnGate_BlockingAcquireTimesOut(t *testing.T) { func TestTurnGate_BlockingAcquireTimesOut(t *testing.T) {
g := newTurnGate() g := New()
g.acquire("s1", 0) // hold the permit g.Acquire("s1", 0) // hold the permit
start := time.Now() start := time.Now()
if g.acquire("s1", 60*time.Millisecond) { if g.Acquire("s1", 60*time.Millisecond) {
t.Fatal("acquire should time out while permit is held") t.Fatal("Acquire should time out while permit is held")
} }
if elapsed := time.Since(start); elapsed < 50*time.Millisecond { if elapsed := time.Since(start); elapsed < 50*time.Millisecond {
t.Fatalf("acquire returned too fast (%v); expected to wait ~60ms", elapsed) t.Fatalf("Acquire returned too fast (%v); expected to wait ~60ms", elapsed)
} }
g.release("s1") g.Release("s1")
} }
// TestTurnGate_SingleFlightConcurrent is the core F1 guarantee: many concurrent // TestTurnGate_SingleFlightConcurrent is the core F1 guarantee: many concurrent
// background acquirers on the SAME session, exactly one runs at a time. This is // background acquirers on the SAME session, exactly one runs at a time. This is
// the property that prevents two turns interleaving tool calls. // the property that prevents two turns interleaving tool calls.
func TestTurnGate_SingleFlightConcurrent(t *testing.T) { func TestTurnGate_SingleFlightConcurrent(t *testing.T) {
g := newTurnGate() g := New()
const n = 50 const n = 50
var inFlight, maxInFlight int64 var inFlight, maxInFlight int64
var runs int64 var runs int64
@@ -86,10 +86,10 @@ func TestTurnGate_SingleFlightConcurrent(t *testing.T) {
go func() { go func() {
defer wg.Done() defer wg.Done()
<-start <-start
if !g.acquire("shared", 0) { // background-style: skip if busy if !g.Acquire("shared", 0) { // background-style: skip if busy
return return
} }
defer g.release("shared") defer g.Release("shared")
cur := atomic.AddInt64(&inFlight, 1) cur := atomic.AddInt64(&inFlight, 1)
for { for {
m := atomic.LoadInt64(&maxInFlight) m := atomic.LoadInt64(&maxInFlight)