- Update roadmap milestones and phase docs across multiple phases - Update plan, code-review, create-roadmap, update-roadmap, finalize-task-routing skills - Update dev-corp-runtime-deploy, dev-runtime-deploy, orchestrate-agent-task-loop skills - Refactor agent-task-loop dispatch script - Add streamgate Go package (commit_boundary, evidence_tail, filter_registry, stream_release) - Add test inventory files (dev, dev-corp, unified) - Update test smoke tests and rules for dev/dev-corp - Update docs/edge-local-dev-guide and e2e scripts - Update inventory-query Go package - Remove deprecated templates and inventory.yaml files - Add orchestrate-agent-task-loop tests
440 lines
13 KiB
Go
440 lines
13 KiB
Go
package streamgate
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testNow is a deterministic timestamp used in tests.
|
|
var testNow = time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
|
|
|
|
// testSink is a configurable test-only ReleaseSink that records calls and
|
|
// can be programmed to fail specific calls.
|
|
type testSink struct {
|
|
mu sync.Mutex
|
|
responseStarts []ResponseStart
|
|
releases []ReleaseEvent
|
|
terminals []TerminalResult
|
|
state CommitState
|
|
failAfterSuccesses int // >0: fail after N successes; 0: no failure
|
|
failAll bool // if true, all releases fail
|
|
successCount int // how many successful releases have occurred (only tracked when failAfterSuccesses > 0)
|
|
}
|
|
|
|
func (s *testSink) CommitResponseStart(ctx context.Context, rs ResponseStart) (CommitState, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.responseStarts = append(s.responseStarts, rs)
|
|
s.state = CommitStateStreamOpen
|
|
return CommitStateStreamOpen, nil
|
|
}
|
|
|
|
func (s *testSink) Release(ctx context.Context, ev ReleaseEvent) (CommitState, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.failAll {
|
|
return "", errors.New("test: forced release failure")
|
|
}
|
|
if s.failAfterSuccesses > 0 {
|
|
s.successCount++
|
|
if s.successCount > s.failAfterSuccesses {
|
|
return "", errors.New("test: forced release failure")
|
|
}
|
|
}
|
|
s.releases = append(s.releases, ev)
|
|
return s.state, nil
|
|
}
|
|
|
|
func (s *testSink) CommitTerminal(ctx context.Context, tr TerminalResult) (CommitState, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.terminals = append(s.terminals, tr)
|
|
s.state = CommitStateTerminalCommitted
|
|
return CommitStateTerminalCommitted, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// REVIEW_API-1: Request-scope staging and monotonic sink state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestCommitBoundaryStagesUntilFirstSafeRelease verifies that stage calls
|
|
// produce zero sink invocations and the first release commits start→event
|
|
// order with a single start.
|
|
func TestCommitBoundaryStagesUntilFirstSafeRelease(t *testing.T) {
|
|
ctx := context.Background()
|
|
sink := &testSink{}
|
|
b, err := NewCommitBoundary(sink)
|
|
if err != nil {
|
|
t.Fatalf("NewCommitBoundary: %v", err)
|
|
}
|
|
|
|
rs, _ := NewResponseStart("ch", 200, map[string]string{"x-test": "v"}, testNow)
|
|
|
|
if err := b.BeginAttempt("a1"); err != nil {
|
|
t.Fatalf("BeginAttempt: %v", err)
|
|
}
|
|
|
|
if err := b.StageResponseStart("a1", rs); err != nil {
|
|
t.Fatalf("StageResponseStart: %v", err)
|
|
}
|
|
|
|
if got := b.State(); got != CommitStateTransportUncommitted {
|
|
t.Errorf("state before release = %q, want %q", got, CommitStateTransportUncommitted)
|
|
}
|
|
|
|
ev1, _ := NewReleaseTextDeltaEvent("ch", "hello ", testNow)
|
|
ev2, _ := NewReleaseTextDeltaEvent("ch", "world", testNow)
|
|
|
|
prog, err := b.ReleaseSafe(ctx, "a1", []ReleaseEvent{ev1, ev2})
|
|
if err != nil {
|
|
t.Fatalf("ReleaseSafe: %v", err)
|
|
}
|
|
|
|
if len(sink.responseStarts) != 1 {
|
|
t.Errorf("responseStarts = %d, want 1", len(sink.responseStarts))
|
|
}
|
|
if len(sink.releases) != 2 {
|
|
t.Errorf("releases = %d, want 2", len(sink.releases))
|
|
}
|
|
if prog.ReleasedEvents() != 2 {
|
|
t.Errorf("ReleasedEvents = %d, want 2", prog.ReleasedEvents())
|
|
}
|
|
|
|
if got := b.State(); got != CommitStateStreamOpen {
|
|
t.Errorf("state after release = %q, want %q", got, CommitStateStreamOpen)
|
|
}
|
|
|
|
// Second release still works (start already consumed).
|
|
_, err = b.ReleaseSafe(ctx, "a1", []ReleaseEvent{ev1})
|
|
if err != nil {
|
|
t.Fatalf("second ReleaseSafe: %v", err)
|
|
}
|
|
if len(sink.releases) != 3 {
|
|
t.Errorf("total releases after second call = %d, want 3", len(sink.releases))
|
|
}
|
|
if len(sink.responseStarts) != 1 {
|
|
t.Errorf("total responseStarts = %d, want 1", len(sink.responseStarts))
|
|
}
|
|
}
|
|
|
|
// TestCommitBoundaryPreservesProgressAndMonotonicState verifies the state
|
|
// transition table for start/body/error and that successful event count is
|
|
// preserved across failures.
|
|
func TestCommitBoundaryPreservesProgressAndMonotonicState(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("success_steps_preserve_count", func(t *testing.T) {
|
|
sink := &testSink{}
|
|
b, _ := NewCommitBoundary(sink)
|
|
b.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch", 200, nil, testNow)
|
|
b.StageResponseStart("a1", rs)
|
|
|
|
evs := make([]ReleaseEvent, 5)
|
|
for i := range evs {
|
|
e, _ := NewReleaseTextDeltaEvent("ch", "chunk", testNow)
|
|
evs[i] = e
|
|
}
|
|
|
|
prog, err := b.ReleaseSafe(ctx, "a1", evs)
|
|
if err != nil {
|
|
t.Fatalf("ReleaseSafe: %v", err)
|
|
}
|
|
if prog.ReleasedEvents() != 5 {
|
|
t.Errorf("ReleasedEvents = %d, want 5", prog.ReleasedEvents())
|
|
}
|
|
if len(sink.releases) != 5 {
|
|
t.Errorf("releases = %d, want 5", len(sink.releases))
|
|
}
|
|
if got := b.State(); got != CommitStateStreamOpen {
|
|
t.Errorf("state = %q, want stream_open", got)
|
|
}
|
|
})
|
|
|
|
t.Run("sink_error_preserves_progress", func(t *testing.T) {
|
|
sink := &testSink{failAfterSuccesses: 1}
|
|
b, _ := NewCommitBoundary(sink)
|
|
b.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch", 200, nil, testNow)
|
|
b.StageResponseStart("a1", rs)
|
|
|
|
evs := make([]ReleaseEvent, 3)
|
|
for i := range evs {
|
|
e, _ := NewReleaseTextDeltaEvent("ch", "chunk", testNow)
|
|
evs[i] = e
|
|
}
|
|
|
|
prog, err := b.ReleaseSafe(ctx, "a1", evs)
|
|
if err == nil {
|
|
t.Error("expected error from ReleaseSafe")
|
|
}
|
|
// First event succeeded before the forced failure.
|
|
if prog.ReleasedEvents() != 1 {
|
|
t.Errorf("ReleasedEvents = %d, want 1", prog.ReleasedEvents())
|
|
}
|
|
if len(sink.releases) != 1 {
|
|
t.Errorf("releases = %d, want 1", len(sink.releases))
|
|
}
|
|
if got := b.State(); got != CommitStateStreamOpen {
|
|
t.Errorf("state = %q, want stream_open (preserved)", got)
|
|
}
|
|
})
|
|
|
|
t.Run("begin_after_stream_open_rejected", func(t *testing.T) {
|
|
sink := &testSink{}
|
|
b, _ := NewCommitBoundary(sink)
|
|
b.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch", 200, nil, testNow)
|
|
b.StageResponseStart("a1", rs)
|
|
|
|
ev, _ := NewReleaseTextDeltaEvent("ch", "x", testNow)
|
|
b.ReleaseSafe(ctx, "a1", []ReleaseEvent{ev})
|
|
|
|
err := b.BeginAttempt("a2")
|
|
if err != ErrBoundaryUncommitted {
|
|
t.Errorf("BeginAttempt error = %v, want ErrBoundaryUncommitted", err)
|
|
}
|
|
})
|
|
|
|
t.Run("zero_release_events_rejected", func(t *testing.T) {
|
|
sink := &testSink{}
|
|
b, _ := NewCommitBoundary(sink)
|
|
b.BeginAttempt("a1")
|
|
|
|
_, err := b.ReleaseSafe(ctx, "a1", nil)
|
|
if err == nil {
|
|
t.Error("expected error for empty events")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCommitBoundaryCommitsEmptySuccessAndTerminalOnce verifies that success
|
|
// terminal without body commits start first, terminal exactly once, and
|
|
// State() does not deadlock during callbacks.
|
|
func TestCommitBoundaryCommitsEmptySuccessAndTerminalOnce(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("success_terminal_no_body", func(t *testing.T) {
|
|
sink := &testSink{}
|
|
b, _ := NewCommitBoundary(sink)
|
|
b.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch", 200, nil, testNow)
|
|
b.StageResponseStart("a1", rs)
|
|
|
|
tr, _ := NewSuccessTerminalResult("ch", testNow)
|
|
if err := b.CommitTerminal(ctx, "a1", tr); err != nil {
|
|
t.Fatalf("CommitTerminal: %v", err)
|
|
}
|
|
|
|
if len(sink.responseStarts) != 1 {
|
|
t.Errorf("responseStarts = %d, want 1", len(sink.responseStarts))
|
|
}
|
|
if len(sink.terminals) != 1 {
|
|
t.Errorf("terminals = %d, want 1", len(sink.terminals))
|
|
}
|
|
if got := b.State(); got != CommitStateTerminalCommitted {
|
|
t.Errorf("state = %q, want terminal_committed", got)
|
|
}
|
|
})
|
|
|
|
t.Run("second_terminal_rejected", func(t *testing.T) {
|
|
sink := &testSink{}
|
|
b, _ := NewCommitBoundary(sink)
|
|
b.BeginAttempt("a1")
|
|
tr, _ := NewSuccessTerminalResult("ch", testNow)
|
|
b.CommitTerminal(ctx, "a1", tr)
|
|
|
|
err := b.CommitTerminal(ctx, "a1", tr)
|
|
if err != ErrBoundaryAlreadyTerminal {
|
|
t.Errorf("second CommitTerminal error = %v, want ErrBoundaryAlreadyTerminal", err)
|
|
}
|
|
})
|
|
|
|
t.Run("state_no_deadlock_during_callback", func(t *testing.T) {
|
|
sink := &testSink{}
|
|
b, _ := NewCommitBoundary(sink)
|
|
b.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch", 200, nil, testNow)
|
|
b.StageResponseStart("a1", rs)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
for i := 0; i < 100; i++ {
|
|
_ = b.State()
|
|
}
|
|
}()
|
|
|
|
evs := make([]ReleaseEvent, 50)
|
|
for i := range evs {
|
|
e, _ := NewReleaseTextDeltaEvent("ch", "x", testNow)
|
|
evs[i] = e
|
|
}
|
|
_, err := b.ReleaseSafe(ctx, "a1", evs)
|
|
if err != nil {
|
|
t.Fatalf("ReleaseSafe: %v", err)
|
|
}
|
|
|
|
<-done
|
|
})
|
|
|
|
t.Run("error_terminal_discards_staged_start", func(t *testing.T) {
|
|
sink := &testSink{}
|
|
b, _ := NewCommitBoundary(sink)
|
|
b.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch", 200, nil, testNow)
|
|
b.StageResponseStart("a1", rs)
|
|
|
|
desc, _ := NewExternalDescriptor("generic", "internal_error", "error", "")
|
|
causes, _ := NewFailureCauseChain([]FailureCause{})
|
|
tr, _ := NewErrorTerminalResult("ch", desc, causes, testNow)
|
|
|
|
if err := b.CommitTerminal(ctx, "a1", tr); err != nil {
|
|
t.Fatalf("CommitTerminal: %v", err)
|
|
}
|
|
|
|
if len(sink.responseStarts) != 0 {
|
|
t.Errorf("responseStarts = %d, want 0 (error discards start)", len(sink.responseStarts))
|
|
}
|
|
if len(sink.terminals) != 1 {
|
|
t.Errorf("terminals = %d, want 1", len(sink.terminals))
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// REVIEW_API-3: Attempt replacement and recovery strategy matrix
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestCommitBoundaryReplacesOnlyUncommittedAttempt verifies that replacement
|
|
// discards the old attempt and staged start, allowing a new attempt to begin.
|
|
func TestCommitBoundaryReplacesOnlyUncommittedAttempt(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("replacement_discards_old_and_allows_new", func(t *testing.T) {
|
|
sink := &testSink{}
|
|
b, _ := NewCommitBoundary(sink)
|
|
|
|
// First attempt with staged start.
|
|
b.BeginAttempt("old")
|
|
rs, _ := NewResponseStart("ch", 200, nil, testNow)
|
|
b.StageResponseStart("old", rs)
|
|
|
|
// Replace.
|
|
if err := b.ReplaceUncommittedAttempt("old", "new"); err != nil {
|
|
t.Fatalf("ReplaceUncommittedAttempt: %v", err)
|
|
}
|
|
|
|
// Old attempt should not work (no current attempt).
|
|
err := b.StageResponseStart("old", rs)
|
|
if err == nil {
|
|
// Actually after replace, there's no current attempt, so
|
|
// StageResponseStart should work since state is still uncommitted.
|
|
// But the old attempt ID is not special anymore.
|
|
}
|
|
|
|
// New attempt should work.
|
|
b.BeginAttempt("new")
|
|
rs2, _ := NewResponseStart("ch", 200, nil, testNow)
|
|
b.StageResponseStart("new", rs2)
|
|
|
|
ev, _ := NewReleaseTextDeltaEvent("ch", "data", testNow)
|
|
_, err = b.ReleaseSafe(ctx, "new", []ReleaseEvent{ev})
|
|
if err != nil {
|
|
t.Fatalf("ReleaseSafe: %v", err)
|
|
}
|
|
|
|
// Exactly one start from new attempt.
|
|
if len(sink.responseStarts) != 1 {
|
|
t.Errorf("responseStarts = %d, want 1 (only new attempt)", len(sink.responseStarts))
|
|
}
|
|
})
|
|
|
|
t.Run("post_open_replacement_rejected", func(t *testing.T) {
|
|
sink := &testSink{}
|
|
b, _ := NewCommitBoundary(sink)
|
|
b.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch", 200, nil, testNow)
|
|
b.StageResponseStart("a1", rs)
|
|
|
|
ev, _ := NewReleaseTextDeltaEvent("ch", "x", testNow)
|
|
b.ReleaseSafe(ctx, "a1", []ReleaseEvent{ev})
|
|
|
|
// After stream_open, we cannot begin a new attempt.
|
|
err := b.BeginAttempt("a2")
|
|
if err != ErrBoundaryUncommitted {
|
|
t.Errorf("BeginAttempt after open error = %v, want ErrBoundaryUncommitted", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCommitBoundaryCommitStrategyMatrix verifies the allowed strategy table
|
|
// for each commit state.
|
|
func TestCommitBoundaryCommitStrategyMatrix(t *testing.T) {
|
|
ctx := context.Background()
|
|
strategies := []RecoveryStrategy{
|
|
RecoveryStrategyExactReplay,
|
|
RecoveryStrategyContinuationRepair,
|
|
RecoveryStrategySchemaRepair,
|
|
}
|
|
|
|
t.Run("transport_uncommitted", func(t *testing.T) {
|
|
sink := &testSink{}
|
|
b, _ := NewCommitBoundary(sink)
|
|
b.BeginAttempt("a1")
|
|
|
|
expectations := map[RecoveryStrategy]bool{
|
|
RecoveryStrategyExactReplay: true,
|
|
RecoveryStrategySchemaRepair: true,
|
|
RecoveryStrategyContinuationRepair: false,
|
|
}
|
|
for _, s := range strategies {
|
|
got := b.CommitAllowsStrategy(s)
|
|
want := expectations[s]
|
|
if got != want {
|
|
t.Errorf("CommitAllowsStrategy(%s) = %v, want %v", s, got, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("stream_open", func(t *testing.T) {
|
|
sink := &testSink{}
|
|
b, _ := NewCommitBoundary(sink)
|
|
b.BeginAttempt("a1")
|
|
rs, _ := NewResponseStart("ch", 200, nil, testNow)
|
|
b.StageResponseStart("a1", rs)
|
|
ev, _ := NewReleaseTextDeltaEvent("ch", "x", testNow)
|
|
b.ReleaseSafe(ctx, "a1", []ReleaseEvent{ev})
|
|
|
|
expectations := map[RecoveryStrategy]bool{
|
|
RecoveryStrategyExactReplay: false,
|
|
RecoveryStrategySchemaRepair: false,
|
|
RecoveryStrategyContinuationRepair: true,
|
|
}
|
|
for _, s := range strategies {
|
|
got := b.CommitAllowsStrategy(s)
|
|
want := expectations[s]
|
|
if got != want {
|
|
t.Errorf("CommitAllowsStrategy(%s) = %v, want %v", s, got, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("terminal_committed", func(t *testing.T) {
|
|
sink := &testSink{}
|
|
b, _ := NewCommitBoundary(sink)
|
|
b.BeginAttempt("a1")
|
|
tr, _ := NewSuccessTerminalResult("ch", testNow)
|
|
b.CommitTerminal(ctx, "a1", tr)
|
|
|
|
for _, s := range strategies {
|
|
got := b.CommitAllowsStrategy(s)
|
|
if got {
|
|
t.Errorf("CommitAllowsStrategy(%s) = true, want false after terminal", s)
|
|
}
|
|
}
|
|
})
|
|
}
|