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)
}