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

@@ -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