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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user