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,98 @@
package messagequeue
import (
"sync"
"testing"
)
func TestMessageQueue_FIFO(t *testing.T) {
q := New()
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 := New()
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 := New()
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 := New()
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 := New()
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)
}
}