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

@@ -10,6 +10,11 @@ import (
"strings"
"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/openai/openai-go"
"github.com/openai/openai-go/option"
@@ -51,7 +56,7 @@ type agent struct {
system string
provider *openai.Client
model string
store *store
store *session.Store
agentID uuid.UUID
reqOpts []option.RequestOption
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
// gate serializes turns per session (at most one in-flight turn per
// 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
// running; they are auto-run when the gate frees (plan 2026-08-03 F2).
// 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()
apiKey := openrouterAPIKey
model := os.Getenv("NOMOS_MODEL")
@@ -82,7 +87,7 @@ func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug
option.WithAPIKey(apiKey),
)
agentID := st.resolveAgentID(ctx, agentSlug)
agentID := st.ResolveAgentID(ctx, agentSlug)
if agentID == uuid.Nil {
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,
apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"),
httpClient: &http.Client{Timeout: 15 * time.Second},
gate: newTurnGate(),
queue: newMessageQueue(),
gate: turngate.New(),
queue: messagequeue.New(),
}, nil
}
@@ -155,12 +160,12 @@ const assentWindowDuration = 30 * time.Minute
// session dimension, approving one task's plan would silently auto-run
// unapproved actions in any other concurrently-running task.
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
}
key := assentWindowKey(a.agentID, sessionID)
key := session.AssentWindowKey(a.agentID, sessionID)
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)
ON CONFLICT (key) DO UPDATE SET value = $2`, key, expires)
if err != nil {
@@ -233,7 +238,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
system += "\n\n" + snapshot
}
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)}
history, truncatedHistory, _ := a.store.getRecentMessages(ctx, sessionID, historyWindowSize)
history, truncatedHistory, _ := a.store.GetRecentMessages(ctx, sessionID, historyWindowSize)
if truncatedHistory {
// Tell the model explicitly rather than silently dropping older
// 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
// isTypedConfirmation ("I confirm ...", per SOUL.md's guidance for what
// to ask the operator to type).
pending := extractPendingApprovals(lastAssistantCalls)
assent := isAssent(message)
typedConfirm := isTypedConfirmation(message)
if len(pending) > 0 && (assent || typedConfirm) {
pending := assent.ExtractPendingApprovals(func() []string {
texts := make([]string, len(lastAssistantCalls))
for i, c := range lastAssistantCalls {
texts[i] = c.resultText()
}
return texts
}())
operatorAssented := assent.IsAssent(message)
typedConfirm := assent.IsTypedConfirmation(message)
if len(pending) > 0 && (operatorAssented || typedConfirm) {
var granted, blocked []string
for _, p := range pending {
if p.destructive && !typedConfirm {
blocked = append(blocked, p.execID)
if p.Destructive && !typedConfirm {
blocked = append(blocked, p.ExecID)
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
}
ok, status, aerr := a.approveExecution(ctx, p.execID)
ok, status, aerr := a.approveExecution(ctx, p.ExecID)
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
}
if ok {
granted = append(granted, p.execID)
slog.Info("nomos: chat-assent granted", "execution", p.execID, "status", status, "session", sessionID)
granted = append(granted, p.ExecID)
slog.Info("nomos: chat-assent granted", "execution", p.ExecID, "status", status, "session", sessionID)
// An explicit typed confirmation for a destructive action
// opens a short, target-scoped window so the rest of a
// destructive recovery sequence on the SAME target (e.g.
// stop -> destroy) doesn't need a second typed confirmation.
if p.destructive && typedConfirm {
if execUUID, perr := uuid.Parse(p.execID); perr == nil {
if target := a.store.executionTarget(ctx, execUUID); target != "" {
a.store.openDestructiveWindow(ctx, a.agentID, target, sessionID)
if p.Destructive && typedConfirm {
if execUUID, perr := uuid.Parse(p.ExecID); perr == nil {
if target := a.store.ExecutionTarget(ctx, execUUID); target != "" {
a.store.OpenDestructiveWindow(ctx, a.agentID, target, 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.
for _, execID := range granted {
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
@@ -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, ", "))
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
// pending approvals — the agent proposed a plan (via propose_plan)
// 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):
// 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
// 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
// retry once more, so this doesn't permanently block recovery.
retries := newRunRetryTracker()
retries := retrycap.New()
for i := 0; i < maxIterations; i++ {
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
// 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
// tool result directing the agent to investigate *why* the
// 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" {
t, _ := args["target"].(string)
c, _ := args["command"].(string)
key := runFailureKey(t, c)
if n := retries.failures(key); n >= maxRunRetries {
directive := runRetryDirective(t, c, n)
key := retrycap.RunFailureKey(t, c)
if n := retries.Failures(key); n >= retrycap.MaxRunRetries {
directive := retrycap.RunRetryDirective(t, c, n)
slog.Warn("nomos: run retry cap hit — refusing dispatch",
"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)
emit(agentEvent{
Type: "tool_result",
@@ -566,7 +577,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
inputStr := string(inputJSON)
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)
// 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" {
t, _ := args["target"].(string)
c, _ := args["command"].(string)
key := runFailureKey(t, c)
n := retries.recordFailure(key)
if n >= maxRunRetries {
key := retrycap.RunFailureKey(t, c)
n := retries.RecordFailure(key)
if n >= retrycap.MaxRunRetries {
slog.Warn("nomos: run failure cap reached — next identical call will be refused",
"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)
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
// 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 —
// their result lands after this turn ends.
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)
// and pulse them on the live context panel. Args only — never
// 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
// the task's outcome view shows what it learned (and pulse it live).
if tc.Function.Name == "upsert_knowledge" {
a.store.linkKnowledgeToTask(ctx, sessionID, string(resultJSON))
a.store.LinkKnowledgeToTask(ctx, sessionID, string(resultJSON))
}
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).
// Pass the RAW result text (not JSON-encoded) so the helper's
// 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)
c, _ := args["command"].(string)
key := runFailureKey(t, c)
n := retries.recordFailure(key)
if n >= maxRunRetries {
key := retrycap.RunFailureKey(t, c)
n := retries.RecordFailure(key)
if n >= retrycap.MaxRunRetries {
slog.Warn("nomos: run failure cap reached — next identical call will be refused",
"target", t, "failures", n, "session", sessionID)
}

View File

@@ -1,183 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
)
// Chat-assent approval: the operator authorizes a proposed action by
// replying normally in chat ("go ahead", "yes", "do it") instead of clicking
// a separate Approve button. This is deterministic (not LLM-judged) so it
// can't be talked around by a model that misreads intent, and it only ever
// looks at the assistant turn immediately preceding the operator's reply —
// an old "yes" from three messages ago can never retroactively approve
// something new. Destructive-risk actions are excluded: they always need the
// explicit typed-confirmation flow, never loose assent.
// pendingApproval is one gated action proposed in the immediately-preceding
// assistant turn, extracted from its tool_result text.
type pendingApproval struct {
execID string
destructive bool
}
// executionQueuedRE matches the "execution <uuid> queued" phrasing shared by
// the run and request_execution/pct_create tool result messages.
var executionQueuedRE = regexp.MustCompile(`(?i)execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\s+queued`)
// extractPendingApprovals scans the tool results of one assistant turn for
// gated actions that are still awaiting a decision.
func extractPendingApprovals(calls []persistedCall) []pendingApproval {
var out []pendingApproval
for _, c := range calls {
text := c.resultText()
m := executionQueuedRE.FindStringSubmatch(text)
if m == nil {
continue
}
out = append(out, pendingApproval{
execID: m[1],
destructive: strings.Contains(strings.ToUpper(text), "DESTRUCTIVE"),
})
}
return out
}
// negationWords, checked first: any of these anywhere in the message means
// the reply is NOT assent, even if a positive word also appears (e.g. "no,
// don't restart it yet" contains neither "yes" nor "go ahead", but "wait"
// alone should also block a stray "yes" a sentence later — checking negation
// first and returning false errs toward re-confirming rather than assuming
// consent, per "when in doubt, escalate"). Includes contracted negatives
// ("haven't", "isn't", ...) alongside "don't"/"do not" — found live: "I
// haven't confirmed anything yet" was reading as an explicit confirmation
// because none of the contracted forms were covered, only "don't"/"do not".
// Deliberately does NOT include a bare "not": that's broad enough to false-
// negative ordinary assent ("go ahead, this is not risky") — the specific
// contracted-verb forms below are unambiguous negation on their own.
var negationWords = []string{
"no", "nope", "don't", "do not", "stop", "wait", "hold on", "hold off",
"not yet", "cancel", "nevermind", "never mind", "actually don't", "skip that",
"haven't", "hasn't", "isn't", "wasn't", "aren't", "can't", "cannot",
"won't", "wouldn't", "shouldn't", "didn't", "doesn't",
}
// assentWords, checked only if no negation matched.
var assentWords = []string{
"go ahead", "goahead", "yes", "yep", "yeah", "yup", "do it", "proceed",
"approve", "approved", "confirm", "confirmed", "ship it", "sounds good",
"lgtm", "run it", "execute", "ok go", "okay go", "please do",
}
// wordTokenRe splits a message into lowercase word tokens. Apostrophes
// (straight ' and curly ) stay attached to their word so "don't"/"haven't"
// tokenize as one token, not two.
var wordTokenRe = regexp.MustCompile(`[a-z0-9']+`)
func tokenize(msg string) []string {
return wordTokenRe.FindAllString(strings.ToLower(strings.ReplaceAll(msg, "", "'")), -1)
}
// containsPhrase reports whether phrase (one or more words) appears as a
// consecutive run of WHOLE tokens in tokens — never a mid-word substring
// match. This is the fix for a real false positive found live: the old
// substring check (`strings.Contains(m, "yes")`) matched "yes" inside
// "yesterday", and "confirm" inside "confirmed"/"unconfirmed" without regard
// for word boundaries. Negation already used a word-boundary check
// (space-padded); assent/confirm words didn't — this brings both onto the
// same, more robust tokenized comparison instead of ad-hoc string padding.
func containsPhrase(tokens []string, phrase string) bool {
words := strings.Fields(phrase)
if len(words) == 0 || len(words) > len(tokens) {
return false
}
for i := 0; i+len(words) <= len(tokens); i++ {
match := true
for j, w := range words {
if tokens[i+j] != w {
match = false
break
}
}
if match {
return true
}
}
return false
}
// isAssent reports whether msg is a plain-language authorization of a
// pending proposal. Deliberately simple and auditable: a fixed word list,
// not a model judgment call, so behavior is predictable and can't be
// prompt-injected via the pending action's own content.
func isAssent(msg string) bool {
tokens := tokenize(msg)
for _, w := range negationWords {
if containsPhrase(tokens, w) {
return false
}
}
for _, w := range assentWords {
if containsPhrase(tokens, w) {
return true
}
}
return false
}
// isTypedConfirmation reports whether msg is an explicit confirmation strong
// enough to grant a DESTRUCTIVE pending action. Deliberately a separate,
// stricter check from isAssent: a bare "yes"/"go ahead"/"proceed" must never
// grant something destructive, only an explicit "confirm" statement does —
// 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
// isAssent: "don't confirm yet" must not accidentally match.
func isTypedConfirmation(msg string) bool {
tokens := tokenize(msg)
for _, w := range negationWords {
if containsPhrase(tokens, w) {
return false
}
}
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,136 +0,0 @@
package main
import (
"encoding/json"
"testing"
)
func TestIsAssent_Positive(t *testing.T) {
cases := []string{
"go ahead", "Go ahead.", "yes", "Yes!", "yeah", "yep", "do it",
"proceed", "approve", "ship it", "sounds good", "lgtm", "please do",
"ok go ahead and run it",
}
for _, c := range cases {
if !isAssent(c) {
t.Errorf("isAssent(%q) = false, want true", c)
}
}
}
func TestIsAssent_Negative(t *testing.T) {
cases := []string{
"no", "no, don't", "wait", "hold on", "not yet", "cancel that",
"nevermind", "what's the plan for tomorrow?", "how many CPUs does strong have?",
"maybe later", "",
}
for _, c := range cases {
if isAssent(c) {
t.Errorf("isAssent(%q) = true, want false", c)
}
}
}
func TestIsAssent_NegationBeatsAssentWord(t *testing.T) {
// Contains "yes" as a substring pattern risk word but is clearly not
// assent — negation must win.
cases := []string{
"no, don't do it yet",
"wait, not yet please",
}
for _, c := range cases {
if isAssent(c) {
t.Errorf("isAssent(%q) = true, want false (negation should block)", c)
}
}
}
// TestIsAssent_WholeWordBoundary regression-tests a real false positive found
// live: the old substring check matched "yes" inside "yesterday" (and would
// equally match "confirm" inside "confirmed"/"unconfirmed" for
// isTypedConfirmation below) because only negation used a word-boundary
// check — assent/confirm words used a bare strings.Contains. Confirmed via a
// throwaway probe before being fixed; kept here permanently so a future
// change can't silently reintroduce it.
func TestIsAssent_WholeWordBoundary(t *testing.T) {
cases := []string{
"not sure, maybe yesterday's logs show something useful",
"my eyesight isn't great, what does that say",
}
for _, c := range cases {
if isAssent(c) {
t.Errorf("isAssent(%q) = true, want false (word-boundary: 'yes' must not match inside 'yesterday'/'eyesight')", c)
}
}
}
// TestIsTypedConfirmation_ContractedNegation regression-tests the other real
// false positive: isTypedConfirmation gates DESTRUCTIVE actions, and
// "confirm" matching inside "confirmed" combined with contracted negatives
// ("haven't") not being in negationWords meant a message that explicitly
// says the operator has NOT confirmed something could read as confirming it.
func TestIsTypedConfirmation_ContractedNegation(t *testing.T) {
cases := []string{
"I haven't confirmed anything yet, let me think",
"that isn't confirmed on my end",
"we can't confirm that until tomorrow",
}
for _, c := range cases {
if isTypedConfirmation(c) {
t.Errorf("isTypedConfirmation(%q) = true, want false (contracted negation should block)", c)
}
}
}
func TestIsTypedConfirmation(t *testing.T) {
positive := []string{
"I confirm destroy 135 in strong",
"confirm",
"Confirmed.",
"yes I confirm",
}
for _, c := range positive {
if !isTypedConfirmation(c) {
t.Errorf("isTypedConfirmation(%q) = false, want true", c)
}
}
negative := []string{
"yes", "go ahead", "do it", "proceed", "lgtm", // loose assent must NOT satisfy this
"no, don't confirm yet", "wait", "",
}
for _, c := range negative {
if isTypedConfirmation(c) {
t.Errorf("isTypedConfirmation(%q) = true, want false (only explicit confirm should pass)", c)
}
}
}
func TestExtractPendingApprovals(t *testing.T) {
mkCall := func(text string) persistedCall {
b, _ := json.Marshal(text)
return persistedCall{id: "x", name: "run", result: json.RawMessage(b)}
}
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 {
t.Fatalf("expected 2 pending approvals, got %d: %+v", len(got), got)
}
if got[0].execID != "019f4930-e22b-7c47-8c6e-715dcd59df19" || got[0].destructive {
t.Errorf("first approval wrong: %+v", got[0])
}
if got[1].execID != "019f4931-aaaa-7c47-8c6e-715dcd59df20" || !got[1].destructive {
t.Errorf("second approval should be flagged destructive: %+v", got[1])
}
}
func TestExtractPendingApprovals_NoneWhenNoneQueued(t *testing.T) {
b, _ := json.Marshal("fleet is healthy, nothing to report")
calls := []persistedCall{{id: "x", result: json.RawMessage(b)}}
if got := extractPendingApprovals(calls); len(got) != 0 {
t.Errorf("expected no pending approvals, got %+v", got)
}
}

View File

@@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/dtoro/oikos/internal/nomos/session"
"github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid"
)
@@ -71,7 +72,7 @@ func (a *agent) runIdleSweepWorker(ctx context.Context) {
// reasoning resumeSession already applies below for a different failure
// mode (a resume that produces no response at all).
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 {
s := s
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 "+
"one-line summary. If you're still genuinely working through the plan, ignore this and continue.]",
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
// skips (returns false) when a turn is already active; bumping the
// counter anyway would make the next sweep auto-close a merely-busy
// session as "unanswered."
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)
}
}
@@ -95,7 +96,7 @@ func (a *agent) processIdleSweep(ctx context.Context) {
}
safego.Go("nomos:idle-autoclose:"+s.ID, func() {
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)
}
})
@@ -141,19 +142,19 @@ func (a *agent) runContinuationWorker(ctx context.Context) {
// silently die until nomos restarted. Now a single bad item can only ever
// take down its own goroutine.
func (a *agent) processContinuations(ctx context.Context) {
pending := a.store.pendingContinuations(ctx, 5)
pending := a.store.PendingContinuations(ctx, 5)
for _, p := range pending {
// Scope gate: only auto-continue while an approved plan is active FOR
// 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
// 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
// executing (plan was approved, work is in progress) — the
// window may have expired while the execution ran. Don't
// penalize timing: the plan was approved, the work happened,
// 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") {
a.openAssentWindow(ctx, p.SessionID)
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.
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})
a.store.saveMessage(context.Background(), p.SessionID, "assistant", body)
a.store.markContinued(ctx, p.ExecID)
a.store.SaveMessage(context.Background(), p.SessionID, "assistant", body)
a.store.MarkContinued(ctx, p.ExecID)
continue
}
}
@@ -183,7 +184,7 @@ func (a *agent) processContinuations(ctx context.Context) {
// that takes, which is exactly the "I just wait while nothing happens"
// complaint this exists to fix — polling alone only helps if there's
// 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)
// P0 (plans/2026-08-03-nomos-chat-changes-review.md): mark the execution
// 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)
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 —
@@ -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).
// See plans/2026-08-03-nomos-chat-changes-review.md P0/P1.
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)
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
// real user turns server-side; resumeSession itself never enqueues.
defer func() {
a.gate.release(sessionID)
a.gate.Release(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": "",
"auto": true,
})
msgID, err := a.store.insertMessageReturningID(ctx, sessionID, "assistant", placeholder)
msgID, err := a.store.InsertMessageReturningID(ctx, sessionID, "assistant", placeholder)
if err != nil {
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,
"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 /
@@ -362,10 +363,10 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
"auto": true,
})
if msgID != uuid.Nil {
a.store.updateMessage(context.Background(), msgID, body)
a.store.UpdateMessage(context.Background(), msgID, body)
} else {
// 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
}
@@ -376,7 +377,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
// buildContinuationNote frames the finished execution for the model: what
// happened, and what to do about it. The persist-through-errors instruction
// 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
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

View File

@@ -54,7 +54,7 @@ func TestExtractExecutionIDs(t *testing.T) {
// would dereference the nil provider and panic. Returning false cleanly proves
// the body was skipped.
func TestResumeSession_SkipsWhenBusy(t *testing.T) {
a := &agent{gate: newTurnGate()}
a := &agent{gate: turngate.New()}
if !a.gate.acquire("sess", 0) {
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
// without reaching resumeSession's body (nil provider → panic) or markContinued.
func TestContinueSession_DefersWhenBusy(t *testing.T) {
a := &agent{gate: newTurnGate()}
a := &agent{gate: turngate.New()}
if !a.gate.acquire("sess", 0) {
t.Fatal("precondition: initial acquire should succeed on a free session")
}

View File

@@ -1,82 +0,0 @@
package main
import (
"log/slog"
"sync"
)
// maxQueuedPerSession caps a session's queue. A held turn plus unbounded
// enqueues would grow memory without limit; an operator nudging a long
// autonomous turn realistically queues only a handful, so a generous cap 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
// from the transcript — it just won't auto-run).
const maxQueuedPerSession = 20
// messageQueue holds operator messages that arrived while a turn was already
// 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
// again", the message is queued and auto-run when the in-flight turn releases
// the session's turn-gate permit.
//
// The queue only schedules WHEN a turn runs, not WHETHER the message is stored
// — handleChat persists the user message before acquiring the gate, so a queued
// message is already in the transcript; this just makes sure a turn eventually
// acts on it.
//
// Draining is strictly one-at-a-time under the turn gate (see drainQueued in
// main.go), so this cannot stack concurrent turns — the exact hazard the gate
// itself exists to prevent. Background resumeSession callers never touch this
// queue; they keep their non-blocking skip.
type messageQueue struct {
mu sync.Mutex
queue map[string][]string
}
func newMessageQueue() *messageQueue {
return &messageQueue{queue: map[string][]string{}}
}
// 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
// message is already persisted in the DB, so this only skips auto-running it.
func (q *messageQueue) enqueue(sessionID, msg string) bool {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.queue[sessionID]) >= maxQueuedPerSession {
slog.Warn("nomos: message queue full; dropping auto-run for operator message", "session", sessionID, "cap", maxQueuedPerSession)
return false
}
q.queue[sessionID] = append(q.queue[sessionID], msg)
return true
}
// dequeue pops the next message from the front of the session's FIFO. Returns
// ok=false when empty.
func (q *messageQueue) dequeue(sessionID string) (string, bool) {
q.mu.Lock()
defer q.mu.Unlock()
xs := q.queue[sessionID]
if len(xs) == 0 {
return "", false
}
m := xs[0]
q.queue[sessionID] = xs[1:]
return m, true
}
// 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
// release will drain it again.
func (q *messageQueue) requeueFront(sessionID, msg string) {
q.mu.Lock()
defer q.mu.Unlock()
q.queue[sessionID] = append([]string{msg}, q.queue[sessionID]...)
}
// peek reports the queued depth for a session (test/diagnostic helper).
func (q *messageQueue) peek(sessionID string) int {
q.mu.Lock()
defer q.mu.Unlock()
return len(q.queue[sessionID])
}

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

@@ -1,170 +0,0 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"strings"
"sync"
)
// maxRunRetries is the per-turn cap on identical failing `run` tool calls.
// After this many failures with the same (target, command) key, the agent
// loop refuses to dispatch the call again and instead surfaces a directive
// to investigate *why* (ps/strace/lsof) or escalate to the operator.
//
// Background: session 1e9c7691 (2026-07-18) retried the same
// `chown :10000 /mnt/media_local && chmod 2775 …` ~20 times across direct
// runs, SSH-hop-via-hubris, wrapping in a shell script, and bare `echo test`
// sanity checks. Each retry piled up another zombie process on the target
// (knfsd was holding a kernel lock on the exported directory). The agent
// only investigated *why* after the operator explicitly asked
// "the command just keeps running?" — see
// plans/2026-07-18-session-review-three-sessions.md P0.1.
const maxRunRetries = 3
// runRetryTracker deduplicates failing `run` calls within a single chat
// 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
// more. The intent is to break a tight retry loop within one turn, not to
// permanently block the agent from ever attempting the operation again.
//
// 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)
// stay safe. The mutex is uncontended on the current hot path.
type runRetryTracker struct {
mu sync.Mutex
counts map[string]int
}
func newRunRetryTracker() *runRetryTracker {
return &runRetryTracker{counts: make(map[string]int)}
}
// runFailureKey is the dedup key for "this is the same command against the
// same target." Whitespace is collapsed so trivial reformatting
// (newlines vs spaces, trailing whitespace) doesn't escape the cap. The
// purpose field is intentionally NOT part of the key: the agent often
// rephrases purpose between retries while issuing the same command.
func runFailureKey(target, command string) string {
collapsed := strings.Join(strings.Fields(command), " ")
target = strings.TrimSpace(target)
h := sha256.Sum256([]byte(target + "\x00" + collapsed))
return hex.EncodeToString(h[:])
}
// recordFailure increments the failure count for the given key and returns
// the new count. The caller should check `count > maxRunRetries` BEFORE
// dispatching to decide whether to skip the call.
func (r *runRetryTracker) recordFailure(key string) int {
r.mu.Lock()
defer r.mu.Unlock()
r.counts[key]++
return r.counts[key]
}
// failures returns the current failure count for a key (0 if unseen).
func (r *runRetryTracker) failures(key string) int {
r.mu.Lock()
defer r.mu.Unlock()
return r.counts[key]
}
// isRunFailure reports whether a `run` tool call's outcome should count
// as a failure for retry-cap purposes. A call counts as failed when:
// - the dispatch itself errored (callErr != nil), OR
// - the result text starts with "run on <target>: ERROR" — the
// shape classifyAndGate/sshExec produce when SSH or the command fails.
//
// Approvals queued ("requires approval") do NOT count as failures: they
// are pending operator action, not a command execution failure. A read
// of the existing code paths (classifyAndGate in internal/mcp/server.go)
// confirms the "ERROR" prefix is the stable failure signature for `run`.
//
// The resultText parameter is the MCP tool's RAW text result (not JSON-
// re-encoded): when classifyAndGate returns a textResult like
// "run on host:strong: ERROR ...", the MCP client unwraps it back to a
// plain Go string (see mcpClient.callTool). The caller should pass that
// raw string, not json.Marshal's output (which would quote-wrap it).
func isRunFailure(toolName string, resultText string, callErr error) bool {
if callErr != nil {
return true
}
if toolName != "run" {
return false
}
// "run on host:strong: ERROR ..." or "run on lxc:caddy: ERROR ..."
// Both shapes start with "run on ".
if !strings.HasPrefix(resultText, "run on ") {
return false
}
return strings.Contains(resultText, ": ERROR")
}
// 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
// a []string (multi-content result) or other JSON-decoded shape. Returns
// "" for shapes we don't recognize. Used by the retry-cap path so
// isRunFailure receives the un-quoted text form (see its doc comment).
func runResultText(result any) string {
switch v := result.(type) {
case string:
return v
case []string:
if len(v) > 0 {
return v[0]
}
case []any:
var b strings.Builder
for _, e := range v {
if s, ok := e.(string); ok {
b.WriteString(s)
}
}
return b.String()
}
return ""
}
// runRetryDirective is the synthetic tool result returned to the model
// 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
// retrying, or to surface the blocker to the operator.
func runRetryDirective(target, command string, failures int) string {
return "Refused: this `run` against " + target + " has failed " +
itoa(failures) + " times this turn — retry cap hit. The command:\n " +
command + "\nis almost certainly blocked by something on the target " +
"(a hung process, a kernel lock, an unexported FS, a stuck SSH " +
"session, …) — NOT a transient gateway issue. Do NOT retry with " +
"different routing or quoting. Instead, BEFORE calling `run` again, " +
"investigate *why* the command hangs: e.g. `ps aux | grep <cmd>`, " +
"`lsof <path>`, `strace -f -p <pid>` or `strace -f <cmd>`, " +
"`mount | grep <path>`, `dmesg | tail`. If you find a structural " +
"blocker (e.g. a kernel lock on an exported NFS directory → " +
"unexport → mutate → re-export), say so to the operator and fix it " +
"with a different command. If you genuinely cannot diagnose, " +
"surface the blocker to the operator with what you've tried — do " +
"not just retry the same command."
}
// itoa is a tiny strconv.Itoa to keep this file dependency-free.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}

View File

@@ -1,129 +0,0 @@
package main
import (
"strings"
"testing"
)
func TestRunFailureKey_StableAcrossWhitespace(t *testing.T) {
cases := []struct{ a, b string }{
{"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local",
"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"},
{"chown :10000 /mnt/media_local\n&& chmod 2775 /mnt/media_local",
"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"},
{"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 {
ka := runFailureKey("host:strong", c.a)
kb := runFailureKey("host:strong", c.b)
if ka != kb {
t.Errorf("case %d: keys differ for whitespace-equivalent commands:\n a=%q\n b=%q", i, c.a, c.b)
}
}
}
func TestRunFailureKey_DiffersByTarget(t *testing.T) {
a := runFailureKey("host:strong", "echo hi")
b := runFailureKey("host:hubris", "echo hi")
if a == b {
t.Error("keys should differ when target differs")
}
}
func TestRunFailureKey_DiffersByCommand(t *testing.T) {
a := runFailureKey("host:strong", "echo hi")
b := runFailureKey("host:strong", "echo bye")
if a == b {
t.Error("keys should differ when command differs")
}
}
func TestRunRetryTracker_CountsAndCaps(t *testing.T) {
r := newRunRetryTracker()
key := runFailureKey("host:strong", "chown :10000 /mnt/media_local")
for i := 1; i <= maxRunRetries; i++ {
if got := r.recordFailure(key); got != i {
t.Errorf("recordFailure #%d = %d, want %d", i, got, i)
}
}
// At the cap, failures() should report maxRunRetries, and the next
// identical call should be refused by the agent loop (failures() >=
// maxRunRetries).
if got := r.failures(key); got != maxRunRetries {
t.Errorf("failures = %d, want %d", got, maxRunRetries)
}
if r.failures(key) < maxRunRetries {
t.Errorf("cap should be enforced at maxRunRetries=%d", maxRunRetries)
}
}
func TestRunRetryTracker_PerTurnIsolation(t *testing.T) {
// Different keys don't interfere.
r := newRunRetryTracker()
k1 := runFailureKey("host:strong", "echo a")
k2 := runFailureKey("host:strong", "echo b")
r.recordFailure(k1)
r.recordFailure(k1)
if got := r.failures(k2); got != 0 {
t.Errorf("k2 failures = %d, want 0 (keys are isolated)", got)
}
}
func TestIsRunFailure(t *testing.T) {
cases := []struct {
desc string
tool string
result string
callErr error
want bool
}{
{"run with ERROR prefix", "run", "run on host:strong: ERROR ssh: signal: killed", nil, true},
{"run with exit error", "run", "run on lxc:caddy: ERROR exit status 1", nil, true},
{"run success (read-only auto)", "run", "run on host:strong (read_only, auto): hello", nil, false},
{"run success (assent window)", "run", "run on host:strong (config_mutation, auto via assent window): done", nil, false},
{"run queued for approval", "run", "run on host:strong requires approval (risk: config_mutation) — execution 019f4930 queued. Present the command and purpose to the operator and wait; do not re-request.", nil, false},
{"non-run tool", "get_entity", "lxc list result", nil, false},
{"callErr set (dispatch failure)", "run", "", errFake{}, true},
{"callErr set on non-run tool", "get_entity", "some result", errFake{}, true}, // callErr trumps name
}
for i, c := range cases {
got := isRunFailure(c.tool, c.result, c.callErr)
if got != c.want {
t.Errorf("case %d (%s): isRunFailure = %v, want %v", i, c.desc, got, c.want)
}
}
}
type errFake struct{}
func (errFake) Error() string { return "fake dispatch error" }
func TestRunRetryDirective_Content(t *testing.T) {
d := runRetryDirective("host:strong", "chown :10000 /mnt/media_local", 3)
for _, want := range []string{
"Refused:",
"host:strong",
"3 times",
"retry cap hit",
"Do NOT retry",
"strace",
"ps aux",
"lsof",
"surface the blocker",
} {
if !strings.Contains(d, want) {
t.Errorf("directive missing %q; got:\n%s", want, d)
}
}
}
func TestItoa(t *testing.T) {
cases := map[int]string{0: "0", 1: "1", 9: "9", 10: "10", 42: "42",
100: "100", -1: "-1", -42: "-42"}
for in, want := range cases {
if got := itoa(in); got != want {
t.Errorf("itoa(%d) = %q, want %q", in, got, want)
}
}
}

View File

@@ -14,9 +14,9 @@ import (
"syscall"
"time"
"github.com/dtoro/oikos/internal/nomos/session"
"github.com/dtoro/oikos/internal/safego"
"github.com/dtoro/oikos/internal/secrets"
"github.com/jackc/pgx/v5"
)
func main() {
@@ -90,13 +90,13 @@ func main() {
probe.close()
}
st, err := newStore(ctx, databaseURL)
st, err := session.New(ctx, databaseURL)
if err != nil {
slog.Error("nomos: db connect", "error", err)
os.Exit(1)
}
if st != nil {
defer st.close()
defer st.Close()
}
nAgent, err := newAgent(ctx, clientPool, st, agentSlug, openrouterAPIKey)
@@ -138,7 +138,7 @@ func main() {
case <-ctx.Done():
return
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()
}
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 {
http.Error(w, "method not allowed", 405)
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"
// turn there is just a spare turn the operator never asked for (P2.1).
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 {
case "done", "failed", "abandoned":
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)
safego.Go("nomos:reconnect:"+req.SessionID, func() {
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)
})
// 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 == "" {
title := truncate(req.Message, 80)
sess, err := st.createSession(pctx, title)
sess, err := st.CreateSession(pctx, title)
if err != nil {
slog.Error("nomos: create session", "error", err)
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
// `replaced` (proposePlan ignores those) and clears outcome/
// 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
// just a continuation of in-flight work.
st.reopenSession(pctx, sessionID)
st.touchSession(pctx, sessionID)
st.ReopenSession(pctx, sessionID)
st.TouchSession(pctx, sessionID)
}
slog.Info("nomos: chat", "session", sessionID, "message", truncate(req.Message, 100))
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
// 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
// replayed history.
if qid := st.openQuestionID(pctx, sessionID); qid != "" {
st.answerQuestion(pctx, sessionID, qid, req.Message)
if qid := st.OpenQuestionID(pctx, sessionID); qid != "" {
st.AnswerQuestion(pctx, sessionID, qid, req.Message)
}
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
// guarantees one in-flight turn per session.
const turnWait = 5 * time.Second
if !a.gate.acquire(sessionID, turnWait) {
a.queue.enqueue(sessionID, req.Message)
if !a.gate.Acquire(sessionID, turnWait) {
a.queue.Enqueue(sessionID, req.Message)
slog.Info("nomos: turn already active, queued operator message", "session", sessionID)
writeEvent(agentEvent{Type: "queued", Data: sessionID, SessionID: sessionID})
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
}
defer func() {
a.gate.release(sessionID)
a.gate.Release(sessionID)
// 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
// 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 {
w.Header().Set("Content-Type", "application/json")
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
}
}
sessions, err := st.listSessionsFiltered(r.Context(), listFilter{
sessions, err := st.ListSessionsFiltered(r.Context(), ListFilter{
Outcome: q.Get("outcome"),
Status: q.Get("status"),
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 {
http.Error(w, "not found", 404)
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.
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.]"
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) })
w.WriteHeader(202)
return
@@ -503,7 +503,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
switch parts[1] {
case "plan":
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 {
http.Error(w, err.Error(), 500)
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})
return
case "questions":
questions, err := st.getQuestions(r.Context(), id)
questions, err := st.GetQuestions(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
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})
return
case "tool_calls":
calls, err := st.getSessionToolCalls(r.Context(), id)
calls, err := st.GetSessionToolCalls(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
@@ -534,7 +534,7 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
switch r.Method {
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)
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
// messages field is unchanged. Clients that only read
// `messages` keep working.
sess, err := st.getSession(r.Context(), id)
sess, err := st.GetSession(r.Context(), id)
if err != nil {
if err == pgx.ErrNoRows {
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)
return
}
messages, err := st.getMessages(r.Context(), id)
messages, err := st.GetMessages(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
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
// 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.
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 {
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)
return
}
prompt, _, _ := st.getQuestion(r.Context(), questionID)
if err := st.answerQuestion(r.Context(), sessionID, questionID, req.Answer); err != nil {
prompt, _, _ := st.GetQuestion(r.Context(), questionID)
if err := st.AnswerQuestion(r.Context(), sessionID, questionID, req.Answer); err != nil {
http.Error(w, err.Error(), 500)
return
}
if a != nil {
base := fmt.Sprintf("[System: the operator answered your question %q with: %q. "+
"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) })
}
w.WriteHeader(202)

File diff suppressed because it is too large Load Diff

View File

@@ -1,511 +0,0 @@
package main
// Integration tests against a real Postgres, mirroring
// internal/db/integration_test.go's pattern: guarded by
// OIKOS_TEST_DATABASE_URL (skipped when unset), throwaway database per run,
// full migrations applied, dropped on cleanup. Run with:
//
// docker compose up -d postgres
// OIKOS_TEST_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" go test ./cmd/nomos/
import (
"context"
"errors"
"fmt"
"math/rand"
"os"
"strings"
"testing"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// newTestStore creates a throwaway, fully-migrated database and returns a
// *store connected to it, cleaned up (including a matching task:<session>
// entity type in the ontology, needed by createTaskEntity/proposePlan tests)
// via t.Cleanup.
func newTestStore(t *testing.T) *store {
t.Helper()
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
if baseURL == "" {
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
}
ctx := context.Background()
admin, err := pgx.Connect(ctx, baseURL)
if err != nil {
t.Fatalf("connect admin: %v", err)
}
dbName := fmt.Sprintf("oikos_test_nomos_%08x", rand.Int63())
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
admin.Close(ctx)
t.Fatalf("create test db: %v", err)
}
admin.Close(ctx)
testURL := swapTestDatabase(baseURL, dbName)
pool, err := db.New(ctx, testURL)
if err != nil {
t.Fatalf("connect test db: %v", err)
}
t.Cleanup(func() {
pool.Close()
admin, err := pgx.Connect(ctx, baseURL)
if err == nil {
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
admin.Close(ctx)
}
})
if err := pool.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
// session_plan_steps/session_questions tests don't need the ontology
// seed, but createTaskEntity's INSERT INTO entities (type='task') has an
// FK to entity_types — seed the minimal rows it needs directly rather
// than pulling in the full seeds/ontology.yaml ingest path.
if _, err := pool.Exec(ctx, `
INSERT INTO entity_types (name, domain, layer) VALUES ('entity', 'meta', 'meta')
ON CONFLICT DO NOTHING;
INSERT INTO entity_types (name, parent_type, domain, layer) VALUES ('task', 'entity', 'cognition', 'cognition')
ON CONFLICT DO NOTHING;`); err != nil {
t.Fatalf("seed minimal ontology: %v", err)
}
return &store{pool: pool.Pool}
}
func swapTestDatabase(url, dbName string) string {
qi := strings.Index(url, "?")
params, base := "", url
if qi >= 0 {
params = url[qi:]
base = url[:qi]
}
si := strings.LastIndex(base, "/")
return base[:si+1] + dbName + params
}
// TestGetRecentMessages_Truncation is the concrete proof for fix A2 of
// plans/2026-07-11-nomos-agent-code-review.md: chatWith used to replay a
// session's ENTIRE history on every turn with no bound. getRecentMessages
// caps that; this test checks both sides — under the limit, nothing is
// dropped and truncated=false; over it, only the most recent `limit` come
// back, in chronological order, with truncated=true.
func TestGetRecentMessages_Truncation(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.createSession(ctx, "history window test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
const total = 35
const limit = 30
for i := 0; i < total; i++ {
role := "user"
if i%2 == 1 {
role = "assistant"
}
body := fmt.Appendf(nil, `{"role":%q,"text":"msg-%d"}`, role, i)
if err := s.saveMessage(ctx, sess.ID, role, body); err != nil {
t.Fatalf("saveMessage %d: %v", i, err)
}
}
msgs, truncated, err := s.getRecentMessages(ctx, sess.ID, limit)
if err != nil {
t.Fatalf("getRecentMessages: %v", err)
}
if !truncated {
t.Errorf("truncated = false, want true (%d messages > limit %d)", total, limit)
}
if len(msgs) != limit {
t.Fatalf("got %d messages, want %d", len(msgs), limit)
}
// Chronological order: the oldest of the RETAINED messages should be the
// (total-limit)-th one saved (msg-5, since msg-0..4 were dropped), and
// the last should be the most recently saved (msg-34).
wantFirst := fmt.Sprintf("msg-%d", total-limit)
wantLast := fmt.Sprintf("msg-%d", total-1)
if got := extractText(msgs[0].Content); got != wantFirst {
t.Errorf("first retained message = %q, want %q", got, wantFirst)
}
if got := extractText(msgs[len(msgs)-1].Content); got != wantLast {
t.Errorf("last retained message = %q, want %q", got, wantLast)
}
// Under the limit: nothing dropped.
sess2, err := s.createSession(ctx, "small session")
if err != nil {
t.Fatalf("createSession: %v", err)
}
for i := 0; i < 5; i++ {
body := fmt.Appendf(nil, `{"role":"user","text":"msg-%d"}`, i)
if err := s.saveMessage(ctx, sess2.ID, "user", body); err != nil {
t.Fatalf("saveMessage: %v", err)
}
}
msgs2, truncated2, err := s.getRecentMessages(ctx, sess2.ID, limit)
if err != nil {
t.Fatalf("getRecentMessages (small): %v", err)
}
if truncated2 {
t.Errorf("truncated = true for a 5-message session under a %d limit, want false", limit)
}
if len(msgs2) != 5 {
t.Errorf("got %d messages, want 5", len(msgs2))
}
}
// TestProposePlan_RefuseInFlight is the concrete proof for the plan-drift
// fix (2026-07-14, "plan added twice in the sidebar"): proposePlan must
// REPLACE the step list only while every existing step is still 'pending'
// (a genuine pre-execution revision), and REFUSE the call once any step has
// started. The prior append-mode safety net (commit 5384499) preserved
// history but duplicated the plan in the sidebar when the agent re-proposed
// on "proceed". Refusing is the correct default — the agent must advance
// with update_plan_step + run.
func TestProposePlan_RefuseInFlight(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.createSession(ctx, "plan refuse test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
// First call: no steps exist yet — must persist as-is (replace mode,
// trivially: nothing to replace).
out1, err := s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "Step A"}})
if err != nil {
t.Fatalf("proposePlan #1: %v", err)
}
if len(out1) != 1 || out1[0]["seq"] != 1 {
t.Fatalf("proposePlan #1 = %+v, want one step at seq 1", out1)
}
if out1[0]["generation"] != 1 {
t.Fatalf("proposePlan #1 generation = %v, want 1", out1[0]["generation"])
}
// Mark step 1 as started.
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
t.Fatalf("updatePlanStep: %v", err)
}
// Second call, simulating a model that re-proposes mid-flight (the
// operator-reported "proceed" bug): since step 1 has left 'pending',
// this MUST refuse with errPlanInFlight, not append or replace.
_, err = s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "Step B"}})
if !errors.Is(err, errPlanInFlight) {
t.Fatalf("proposePlan #2: err = %v, want errPlanInFlight (refuse mid-flight re-proposal)", err)
}
// The original step 1 must be untouched — not erased, not appended to.
steps, err := s.getPlanSteps(ctx, sess.ID, false)
if err != nil {
t.Fatalf("getPlanSteps: %v", err)
}
if len(steps) != 1 {
t.Fatalf("got %d persisted steps, want 1 (refused call must not mutate the plan)", len(steps))
}
if steps[0].Title != "Step A" || steps[0].Status != "running" {
t.Errorf("step 1 = %+v, want Step A still running (refused call must not touch it)", steps[0])
}
// Third call BEFORE anything runs on a fresh session: every step is
// still pending, so this must REPLACE (mark the prior plan `replaced`),
// not refuse. The new plan becomes generation 2.
sess2, err := s.createSession(ctx, "plan replace test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
if _, err := s.proposePlan(ctx, sess2.ID, []planStepInput{{Title: "Original"}}); err != nil {
t.Fatalf("proposePlan (initial): %v", err)
}
if _, err := s.proposePlan(ctx, sess2.ID, []planStepInput{{Title: "Revised"}}); err != nil {
t.Fatalf("proposePlan (revise before execution): %v", err)
}
// Default (current generation) view: only the revised step.
revisedSteps, err := s.getPlanSteps(ctx, sess2.ID, false)
if err != nil {
t.Fatalf("getPlanSteps: %v", err)
}
if len(revisedSteps) != 1 || revisedSteps[0].Title != "Revised" {
t.Fatalf("got %+v, want a single 'Revised' step (current-generation view)", revisedSteps)
}
if revisedSteps[0].Seq != 1 {
t.Fatalf("revised step seq = %d, want 1 (seq is generation-relative, resets to 1..N)", revisedSteps[0].Seq)
}
if revisedSteps[0].Generation != 2 {
t.Fatalf("revised step generation = %d, want 2 (prior pending plan is replaced, not deleted, so the counter increments)", revisedSteps[0].Generation)
}
// all=true audit view: both generations, the original marked `replaced`.
allSteps, err := s.getPlanSteps(ctx, sess2.ID, true)
if err != nil {
t.Fatalf("getPlanSteps(all): %v", err)
}
if len(allSteps) != 2 {
t.Fatalf("all=true got %d steps, want 2 (Original replaced gen1 + Revised gen2)", len(allSteps))
}
if allSteps[0].Title != "Original" || allSteps[0].Status != "replaced" || allSteps[0].Generation != 1 {
t.Errorf("gen1 step = %+v, want Original/replaced/gen1", allSteps[0])
}
if allSteps[1].Title != "Revised" || allSteps[1].Generation != 2 || allSteps[1].Seq != 1 {
t.Errorf("gen2 step = %+v, want Revised/gen2/seq1", allSteps[1])
}
}
// TestUpdatePlanStep_GenerationRelative is the P0.1 regression proof: after a
// re-plan, update_plan_step(seq=N) — using the 1-based number the model
// naturally carries — must address the CURRENT generation and never resurrect
// a superseded generation's `replaced` row. Before the fix, seq was globally
// increasing across generations, so seq=1 after a re-plan flipped the gen-1
// `replaced` step back to `running`/`done` while the real gen-2 work went
// unrecorded.
func TestUpdatePlanStep_GenerationRelative(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.createSession(ctx, "gen-relative seq test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
// Generation 1: two steps.
if _, err := s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "A"}, {Title: "B"}}); err != nil {
t.Fatalf("proposePlan #1: %v", err)
}
// Re-plan: setGoal marks the gen-1 plan `replaced`, proposePlan starts gen 2.
if err := s.setGoal(ctx, sess.ID, "follow-up sub-task"); err != nil {
t.Fatalf("setGoal: %v", err)
}
if _, err := s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "C"}, {Title: "D"}}); err != nil {
t.Fatalf("proposePlan #2: %v", err)
}
// The model addresses the new plan with 1-based seq. seq=1 must hit
// gen-2 "C", leaving gen-1 "A" (replaced) untouched.
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
t.Fatalf("updatePlanStep(seq=1, running): %v", err)
}
if err := s.updatePlanStep(ctx, sess.ID, 1, "done", "", ""); err != nil {
t.Fatalf("updatePlanStep(seq=1, done): %v", err)
}
all, err := s.getPlanSteps(ctx, sess.ID, true)
if err != nil {
t.Fatalf("getPlanSteps(all): %v", err)
}
byTitle := map[string]planStep{}
for _, st := range all {
byTitle[st.Title] = st
}
// gen-1 steps stay `replaced` — NOT resurrected to running/done.
if byTitle["A"].Status != "replaced" || byTitle["A"].Generation != 1 {
t.Errorf("A = %+v, want replaced/gen1 (a superseded row must never be touched)", byTitle["A"])
}
if byTitle["B"].Status != "replaced" || byTitle["B"].Generation != 1 {
t.Errorf("B = %+v, want replaced/gen1", byTitle["B"])
}
// gen-2 seq=1 advanced; seq=2 untouched.
if byTitle["C"].Status != "done" || byTitle["C"].Generation != 2 || byTitle["C"].Seq != 1 {
t.Errorf("C = %+v, want done/gen2/seq1 (the 1-based update must address the current generation)", byTitle["C"])
}
if byTitle["D"].Status != "pending" || byTitle["D"].Seq != 2 {
t.Errorf("D = %+v, want pending/seq2", byTitle["D"])
}
// Out-of-range seq must be refused (no current-gen step there).
if err := s.updatePlanStep(ctx, sess.ID, 99, "running", "", ""); !errors.Is(err, errPlanStepNotFound) {
t.Fatalf("updatePlanStep(seq=99) err = %v, want errPlanStepNotFound", err)
}
}
// TestCompleteTask_AutoCloseEmitsEvents is the P1.1 regression proof:
// completeTask's bulk auto-close of in-flight steps must emit one
// plan.step.finished event per closed step (so the live panel converges
// instead of freezing on "running" after the task completes) and must stamp
// started_at so no closed step is left un-timestamped (P0.1 fix 5).
func TestCompleteTask_AutoCloseEmitsEvents(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.createSession(ctx, "auto-close events test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
if _, err := s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "A"}, {Title: "B"}}); err != nil {
t.Fatalf("proposePlan: %v", err)
}
// A is running, B still pending at completion time.
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
t.Fatalf("updatePlanStep(1, running): %v", err)
}
if err := s.completeTask(ctx, sess.ID, "success", "done"); err != nil {
t.Fatalf("completeTask: %v", err)
}
// Every auto-closed step should now carry both a started_at and a
// finished_at (no NULL-started `done` step).
steps, err := s.getPlanSteps(ctx, sess.ID, true)
if err != nil {
t.Fatalf("getPlanSteps: %v", err)
}
for _, st := range steps {
if st.Status == "done" && st.StartedAt == nil {
t.Errorf("step %q done but started_at is NULL (P0.1 fix 5: stamp it)", st.Title)
}
}
// Exactly two plan.step.finished events — one per closed step (A and B).
var finished int
if err := s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM events WHERE type = 'plan.step.finished' AND correlation_id = $1`,
sess.ID).Scan(&finished); err != nil {
t.Fatalf("count events: %v", err)
}
if finished != 2 {
t.Fatalf("plan.step.finished events = %d, want 2 (one per auto-closed step)", finished)
}
}
// TestHadDiscoveryAndWriteback is the store-level proof for D.1 (refuse
// 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 update_entity_attributes or
// create_relationship call. The D.1 gate in tasks.go combines these: refuse
// success when hadDiscovery && !hadEntityWriteback.
func TestHadDiscoveryAndWriteback(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.createSession(ctx, "discovery test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
// Before any tool calls: no discovery, no writeback.
if s.hadDiscovery(ctx, sess.ID) {
t.Fatal("hadDiscovery = true before any tool calls, want false")
}
if s.hadEntityWriteback(ctx, sess.ID) {
t.Fatal("hadEntityWriteback = true before any tool calls, want false")
}
// A `run` call (discovery) — should set hadDiscovery, not hadEntityWriteback.
agentID := uuid.New()
s.logActivity(ctx, agentID, sess.ID, "run", nil, "", "uptime output", 100, true, "corr-1", 0)
if !s.hadDiscovery(ctx, sess.ID) {
t.Fatal("hadDiscovery = false after a successful run call, want true")
}
if s.hadEntityWriteback(ctx, sess.ID) {
t.Fatal("hadEntityWriteback = true after only a run call, want false")
}
// A failed run call should NOT count as discovery (no facts learned).
sess2, err := s.createSession(ctx, "failed discovery test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
s.logActivity(ctx, agentID, sess2.ID, "run", nil, "", "ssh timeout", 100, false, "corr-2", 0)
if s.hadDiscovery(ctx, sess2.ID) {
t.Fatal("hadDiscovery = true after a failed run call, want false (no facts learned)")
}
// A get_entity call should NOT count as discovery (DB lookup, not live state).
sess3, err := s.createSession(ctx, "lookup test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
s.logActivity(ctx, agentID, sess3.ID, "get_entity", nil, "", "entity row", 10, true, "corr-3", 0)
if s.hadDiscovery(ctx, sess3.ID) {
t.Fatal("hadDiscovery = true after get_entity, want false (DB lookups are not discovery)")
}
// update_entity_attributes sets hadEntityWriteback.
sess4, err := s.createSession(ctx, "writeback test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
s.logActivity(ctx, agentID, sess4.ID, "update_entity_attributes", nil, "", "ok", 10, true, "corr-4", 0)
if !s.hadEntityWriteback(ctx, sess4.ID) {
t.Fatal("hadEntityWriteback = false after update_entity_attributes, want true")
}
// And the discovery+writeback combination (the conv3 scenario).
s.logActivity(ctx, agentID, sess4.ID, "run", nil, "", "apt-get update output", 100, true, "corr-5", 0)
if !s.hadDiscovery(ctx, sess4.ID) {
t.Fatal("hadDiscovery = false after run+writeback, want true")
}
if !s.hadEntityWriteback(ctx, sess4.ID) {
t.Fatal("hadEntityWriteback = false after run+writeback, want true")
}
}
// TestSetGoal_SupersessionEvent is the store-level proof for P1.4 from
// 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
// task.superseded event must be emitted (so the audit trail records the
// pivot — the row's goal column will be overwritten, losing the prior intent
// without this event). When the goal is identical OR no prior goal exists,
// no supersession event is emitted.
//
// Background: session 55927f0a had two set_goal calls; the first was
// implicitly abandoned when the operator said "lets just keep ludo-library
// then." Without the event, the prior goal silently disappeared.
func TestSetGoal_SupersededEvent(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.createSession(ctx, "goal pivot test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
// First set_goal — no prior, no supersession event expected.
if err := s.setGoal(ctx, sess.ID, "Fix sabnzbd download folder to use ludo-lvm"); err != nil {
t.Fatalf("setGoal #1: %v", err)
}
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 0 {
t.Errorf("after first set_goal: %d task.superseded events, want 0", n)
}
// Second set_goal with a DIFFERENT goal — supersession event expected.
if err := s.setGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
t.Fatalf("setGoal #2: %v", err)
}
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 {
t.Errorf("after second set_goal with a different goal: %d task.superseded events, want 1", n)
}
// Third set_goal with the SAME goal as the second — no new supersession
// event (idempotent: same goal is a no-op, not a pivot).
if err := s.setGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil {
t.Fatalf("setGoal #3: %v", err)
}
if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 {
t.Errorf("after third set_goal with same goal as second: %d task.superseded events, want 1 (no new pivot)", n)
}
// The session's current goal must be the latest one set.
got, err := s.getSession(ctx, sess.ID)
if err != nil {
t.Fatalf("getSession: %v", err)
}
if got.Goal != "Add NFS export of ludo-lvm to ZimaOS" {
t.Errorf("session goal = %q, want the second (latest) goal", got.Goal)
}
}
// countEvents counts observability events of the given type correlated to
// the given session. Used by TestSetGoal_SupersededEvent to assert the
// task.superseded audit-trail signal was emitted.
func countEvents(ctx context.Context, s *store, sessionID, eventType string) int {
var n int
s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM events WHERE correlation_id = $1 AND type = $2`,
sessionID, eventType).Scan(&n)
return n
}

View File

@@ -175,7 +175,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
if strings.TrimSpace(goal) == "" {
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
}
// 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
// the prior session's transcript (GET /sessions/{id}) before
// 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 {
var b strings.Builder
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":
raw, _ := args["steps"].([]any)
var steps []planStepInput
var steps []PlanStepInput
for _, r := range raw {
m, ok := r.(map[string]any)
if !ok {
@@ -234,7 +234,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
}
detail, _ := m["detail"].(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 {
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 := ""
if !hasWritebackStep {
steps = append(steps, planStepInput{
steps = append(steps, PlanStepInput{
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).",
})
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 errors.Is(err, errPlanInFlight) {
if errors.Is(err, session.ErrPlanInFlight) {
// The plan is already in flight — refuse the re-proposal.
// The agent must advance the existing plan with
// 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
}
reason, _ := args["replaced_reason"].(string)
if err := a.store.updatePlanStep(ctx, sessionID, seq, status, execID, reason); err != nil {
if errors.Is(err, errPlanStepNotFound) {
if err := a.store.UpdatePlanStep(ctx, sessionID, seq, status, execID, reason); err != nil {
if errors.Is(err, session.ErrPlanStepNotFound) {
// The seq doesn't address a step in the CURRENT plan — most
// often a stale 1-based number the model carried across a
// 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 {
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 "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
// `failure` or `partial` is allowed through (the agent is
// 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
}
// 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
// dashboard is still down. Must call ping_service or run a successful
// curl before claiming success.
if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) {
goal := a.store.sessionGoal(ctx, sessionID)
if mentionsReachability(goal) && !a.store.hadRecentVerification(ctx, sessionID) {
if outcome == "success" && a.store.HadDiscovery(ctx, sessionID) {
goal := a.store.SessionGoal(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
}
}
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
if errors.Is(err, errTaskAlreadyComplete) {
if err := a.store.CompleteTask(ctx, sessionID, outcome, summary); err != nil {
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 fmt.Sprintf("error completing task: %v", err), true
}
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."
}
return result, true
@@ -445,7 +445,7 @@ func (a *agent) autoCompleteTrivialTask(ctx context.Context, sessionID, response
if summary == "" {
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)
}
}
@@ -464,7 +464,7 @@ func (a *agent) autoCompleteIfPlanDone(ctx context.Context, sessionID, responseT
if a.store == nil || sessionID == "" || sessionID == "ephemeral" {
return
}
sess, err := a.store.getSession(ctx, sessionID)
sess, err := a.store.GetSession(ctx, sessionID)
if err != nil || sess.Status != "executing" {
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
// ends, auto-complete fires incorrectly because the approval-queue
// `run` responses were logged as success=true in agent_activity.
if a.store.hasPendingApprovals(ctx, sessionID) {
if a.store.HasPendingApprovals(ctx, sessionID) {
return
}
discovery := a.store.hadDiscovery(ctx, sessionID)
writeback := a.store.hadEntityWriteback(ctx, sessionID)
discovery := a.store.HadDiscovery(ctx, sessionID)
writeback := a.store.HadEntityWriteback(ctx, sessionID)
// (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 {
shouldComplete = true
}
@@ -500,7 +500,7 @@ func (a *agent) autoCompleteIfPlanDone(ctx context.Context, sessionID, responseT
if summary == "" {
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)
} else {
slog.Info("nomos: auto-completed task — agent didn't call complete_task", "session", sessionID, "outcome", outcome)

View File

@@ -1,90 +0,0 @@
package main
import (
"sync"
"time"
)
// 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
// request goroutine, and every "resume" path (the empty-message reconnect,
// the auto-continuation worker, the idle sweep, answer-question, the /resume
// endpoint) launches ANOTHER goroutine running a full turn. Nothing prevented
// two turns for the SAME session at once, so a network blip that triggered a
// reconnect would spawn a duplicate resumeSession while the original turn was
// still alive — their tool calls interleaved on the wire and in the persisted
// transcript, which is the root cause behind the "parallel/nesting/sequence
// is off" and "task didn't end / flaky" reports.
//
// Model: one permit (buffered-1 channel seeded with a single token) per
// session id. Acquiring consumes the token; releasing puts it back.
// - Background/best-effort callers (resumeSession and everything it backs)
// 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
// will retry on their own.
// - The live chat path (an operator message) waits briefly for a finishing
// background turn, then bails with an actionable error if still busy — see
// handleChat.
//
// 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
// intentionally omitted (a sweep would race with acquire/release and the
// memory is negligible).
type turnGate struct {
mu sync.Mutex
permits map[string]chan struct{}
}
func newTurnGate() *turnGate {
return &turnGate{permits: make(map[string]chan struct{})}
}
// 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
// same id share one channel.
func (g *turnGate) permit(sessionID string) chan struct{} {
g.mu.Lock()
defer g.mu.Unlock()
ch, ok := g.permits[sessionID]
if !ok {
ch = make(chan struct{}, 1)
ch <- struct{}{}
g.permits[sessionID] = ch
}
return ch
}
// 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
// to wait for the permit, returning false on timeout. Every true return MUST
// be paired with exactly one release.
func (g *turnGate) acquire(sessionID string, wait time.Duration) bool {
ch := g.permit(sessionID)
if wait <= 0 {
select {
case <-ch:
return true
default:
return false
}
}
t := time.NewTimer(wait)
defer t.Stop()
select {
case <-ch:
return true
case <-t.C:
return false
}
}
// 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.
func (g *turnGate) release(sessionID string) {
ch := g.permit(sessionID)
select {
case ch <- struct{}{}:
default:
}
}

View File

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

View File

@@ -28,7 +28,7 @@ func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string
var finalThinking string
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 {
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,
"tool_calls": toolCalls,
})
a.store.updateMessage(pctx, msgID, body)
a.store.UpdateMessage(pctx, msgID, body)
}
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
// instead of persisting an empty bubble.
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
a.store.deleteMessage(pctx, msgID)
a.store.DeleteMessage(pctx, msgID)
} else {
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.
if finalText != "" && sessionID != "ephemeral" {
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)
}
title := goalTitle
@@ -102,7 +102,7 @@ func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string
title = truncate(finalText, 80)
}
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.
func (a *agent) drainQueued(ctx context.Context, sessionID string) {
for {
msg, ok := a.queue.dequeue(sessionID)
msg, ok := a.queue.Dequeue(sessionID)
if !ok {
return
}
// 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.
if !a.gate.acquire(sessionID, drainAcquireWait) {
a.queue.requeueFront(sessionID, msg)
if !a.gate.Acquire(sessionID, drainAcquireWait) {
a.queue.RequeueFront(sessionID, msg)
return
}
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
// iteration exit, so the gate would stay held across iterations.
func() {
defer a.gate.release(sessionID)
defer a.gate.Release(sessionID)
a.runChatTurn(pctx, ctx, sessionID, msg, func(agentEvent) {})
}()
}