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.
115 lines
2.8 KiB
Go
115 lines
2.8 KiB
Go
package turngate
|
|
|
|
import (
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestTurnGate_NonBlockingSkipsWhenBusy(t *testing.T) {
|
|
g := New()
|
|
if !g.Acquire("s1", 0) {
|
|
t.Fatal("first non-blocking Acquire should succeed on a free session")
|
|
}
|
|
// A second non-blocking Acquire (a background resume) must skip, not queue.
|
|
if g.Acquire("s1", 0) {
|
|
t.Fatal("second non-blocking Acquire should fail while a turn is active")
|
|
}
|
|
// A different session is independent.
|
|
if !g.Acquire("s2", 0) {
|
|
t.Fatal("Acquire on a different session should succeed")
|
|
}
|
|
g.Release("s2")
|
|
g.Release("s1")
|
|
// After Release, the session is free again.
|
|
if !g.Acquire("s1", 0) {
|
|
t.Fatal("Acquire should succeed again after Release")
|
|
}
|
|
g.Release("s1")
|
|
}
|
|
|
|
func TestTurnGate_BlockingAcquireWaitsForRelease(t *testing.T) {
|
|
g := New()
|
|
if !g.Acquire("s1", 0) {
|
|
t.Fatal("first Acquire should succeed")
|
|
}
|
|
|
|
got := make(chan bool, 1)
|
|
go func() { got <- g.Acquire("s1", 2*time.Second) }()
|
|
|
|
select {
|
|
case <-got:
|
|
t.Fatal("blocking Acquire should wait, not return before Release")
|
|
case <-time.After(50 * time.Millisecond):
|
|
// expected: still waiting
|
|
}
|
|
|
|
g.Release("s1")
|
|
select {
|
|
case ok := <-got:
|
|
if !ok {
|
|
t.Fatal("blocking Acquire should succeed after Release")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("blocking Acquire did not return after Release")
|
|
}
|
|
g.Release("s1")
|
|
}
|
|
|
|
func TestTurnGate_BlockingAcquireTimesOut(t *testing.T) {
|
|
g := New()
|
|
g.Acquire("s1", 0) // hold the permit
|
|
|
|
start := time.Now()
|
|
if g.Acquire("s1", 60*time.Millisecond) {
|
|
t.Fatal("Acquire should time out while permit is held")
|
|
}
|
|
if elapsed := time.Since(start); elapsed < 50*time.Millisecond {
|
|
t.Fatalf("Acquire returned too fast (%v); expected to wait ~60ms", elapsed)
|
|
}
|
|
g.Release("s1")
|
|
}
|
|
|
|
// TestTurnGate_SingleFlightConcurrent is the core F1 guarantee: many concurrent
|
|
// background acquirers on the SAME session, exactly one runs at a time. This is
|
|
// the property that prevents two turns interleaving tool calls.
|
|
func TestTurnGate_SingleFlightConcurrent(t *testing.T) {
|
|
g := New()
|
|
const n = 50
|
|
var inFlight, maxInFlight int64
|
|
var runs int64
|
|
var wg sync.WaitGroup
|
|
wg.Add(n)
|
|
start := make(chan struct{})
|
|
for i := 0; i < n; i++ {
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
if !g.Acquire("shared", 0) { // background-style: skip if busy
|
|
return
|
|
}
|
|
defer g.Release("shared")
|
|
cur := atomic.AddInt64(&inFlight, 1)
|
|
for {
|
|
m := atomic.LoadInt64(&maxInFlight)
|
|
if cur <= m || atomic.CompareAndSwapInt64(&maxInFlight, m, cur) {
|
|
break
|
|
}
|
|
}
|
|
atomic.AddInt64(&runs, 1)
|
|
time.Sleep(2 * time.Millisecond)
|
|
atomic.AddInt64(&inFlight, -1)
|
|
}()
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
if maxInFlight != 1 {
|
|
t.Fatalf("max in-flight turns = %d, want 1 (turns must not overlap)", maxInFlight)
|
|
}
|
|
if runs == 0 {
|
|
t.Fatal("expected at least one turn to run")
|
|
}
|
|
}
|