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

@@ -0,0 +1,90 @@
package turngate
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 New() *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:
}
}