5215cd4555
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
841 lines
32 KiB
Go
841 lines
32 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"expvar"
|
|
"testing"
|
|
)
|
|
|
|
// fakeDecisionStore lets tests pre-populate decisions and watch writes.
|
|
type fakeDecisionStore struct {
|
|
byKey map[string][]DecisionInfo
|
|
cleared map[string]int64
|
|
recordErr error
|
|
clearedErr error
|
|
listErr error
|
|
}
|
|
|
|
func newFakeDecisionStore() *fakeDecisionStore {
|
|
return &fakeDecisionStore{byKey: map[string][]DecisionInfo{}, cleared: map[string]int64{}}
|
|
}
|
|
|
|
func dkey(taskID, stepID string) string { return taskID + "|" + stepID }
|
|
|
|
func (f *fakeDecisionStore) ListStepDecisions(_ context.Context, taskID, stepID string) ([]DecisionInfo, error) {
|
|
if f.listErr != nil {
|
|
return nil, f.listErr
|
|
}
|
|
return f.byKey[dkey(taskID, stepID)], nil
|
|
}
|
|
|
|
func (f *fakeDecisionStore) RecordStepDecision(_ context.Context, d DecisionInfo) error {
|
|
if f.recordErr != nil {
|
|
return f.recordErr
|
|
}
|
|
k := dkey(d.TaskID, d.StepID)
|
|
f.byKey[k] = append(f.byKey[k], d)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeDecisionStore) ClearStepDecisions(_ context.Context, taskID, stepID string) (int64, error) {
|
|
if f.clearedErr != nil {
|
|
return 0, f.clearedErr
|
|
}
|
|
k := dkey(taskID, stepID)
|
|
n := int64(len(f.byKey[k]))
|
|
delete(f.byKey, k)
|
|
f.cleared[k] += n
|
|
return n, nil
|
|
}
|
|
|
|
// stepStoreForQuorum is a TransitionStore where the step holds a single
|
|
// transition action gated by a quorum guard.
|
|
type stepStoreForQuorum struct {
|
|
state MachineState
|
|
step StepSpec
|
|
next StepSpec
|
|
applied map[string]bool
|
|
}
|
|
|
|
func (s *stepStoreForQuorum) LoadState(_ context.Context, _, _ string) (MachineState, error) {
|
|
return s.state, nil
|
|
}
|
|
func (s *stepStoreForQuorum) LoadStep(_ context.Context, _, _ string) (StepSpec, error) {
|
|
return s.step, nil
|
|
}
|
|
func (s *stepStoreForQuorum) LoadNextStep(_ context.Context, _ string, _ int) (StepSpec, error) {
|
|
return s.next, nil
|
|
}
|
|
func (s *stepStoreForQuorum) LoadPreviousStep(_ context.Context, _ string, _ int) (StepSpec, error) {
|
|
return StepSpec{}, nil
|
|
}
|
|
func (s *stepStoreForQuorum) ApplyTransition(_ context.Context, _, _, _, _ string, _ Trigger) error {
|
|
return nil
|
|
}
|
|
func (s *stepStoreForQuorum) ApplyTransitionIfAtStep(
|
|
_ context.Context, _, _, expectedStepID, toStepID string, _ Trigger,
|
|
) (bool, error) {
|
|
if s.state.CurrentStepID != expectedStepID {
|
|
return false, nil
|
|
}
|
|
s.state.CurrentStepID = toStepID
|
|
return true, nil
|
|
}
|
|
func (s *stepStoreForQuorum) PersistData(_ context.Context, _ string, _ map[string]any) error {
|
|
return nil
|
|
}
|
|
func (s *stepStoreForQuorum) IsOperationApplied(_ context.Context, op string) (bool, error) {
|
|
return s.applied[op], nil
|
|
}
|
|
func (s *stepStoreForQuorum) MarkOperationApplied(_ context.Context, op string) error {
|
|
s.applied[op] = true
|
|
return nil
|
|
}
|
|
|
|
func quorumStore(guard *TransitionGuard) *stepStoreForQuorum {
|
|
return &stepStoreForQuorum{
|
|
state: MachineState{TaskID: "task-1", SessionID: "sess-1", WorkflowID: "wf", CurrentStepID: "review"},
|
|
step: StepSpec{
|
|
ID: "review", WorkflowID: "wf", Position: 1,
|
|
Events: map[Trigger][]Action{
|
|
TriggerOnTurnComplete: {
|
|
{Kind: ActionMoveToNext, Guard: guard},
|
|
},
|
|
},
|
|
},
|
|
next: StepSpec{ID: "approval", Position: 2},
|
|
applied: map[string]bool{},
|
|
}
|
|
}
|
|
|
|
// scopedParticipants distinguishes per-task rows from step-template rows,
|
|
// unlike the shared fakeParticipants (which returns the same static list
|
|
// for both). Required for tests that exercise AC-50's gather/canonicalize/
|
|
// collapse distinctions.
|
|
type scopedParticipants struct {
|
|
perTask []ParticipantInfo
|
|
template []ParticipantInfo
|
|
err error
|
|
}
|
|
|
|
func (s scopedParticipants) ListTaskParticipants(_ context.Context, taskID string) ([]ParticipantInfo, error) {
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
out := make([]ParticipantInfo, 0, len(s.perTask))
|
|
for _, p := range s.perTask {
|
|
if p.TaskID == taskID {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s scopedParticipants) ListStepParticipants(_ context.Context, stepID, taskID string) ([]ParticipantInfo, error) {
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
if taskID != "" {
|
|
return nil, nil
|
|
}
|
|
out := make([]ParticipantInfo, 0, len(s.template))
|
|
for _, p := range s.template {
|
|
if p.StepID == stepID {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func quorumEngine(decisions DecisionStore, participants ParticipantStore) *Engine {
|
|
return New(quorumStore(nil), MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(participants))
|
|
}
|
|
|
|
// --- evaluateApproveStyle (AC-21/39/40/41/43a/53) ---
|
|
|
|
func TestEvaluateApproveStyle_AllApprove(t *testing.T) {
|
|
seats := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}}
|
|
cases := []struct {
|
|
name string
|
|
decisions []DecisionInfo
|
|
want bool
|
|
}{
|
|
{"all approved", []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionApproved}}, true},
|
|
{"partial approved", []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}}, false},
|
|
{"none yet", nil, false},
|
|
{"approve+reject", []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionRejected}}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := evaluateApproveStyle(QuorumAllApprove, seats, tc.decisions)
|
|
if got.Satisfied != tc.want {
|
|
t.Fatalf("all_approve satisfied = %v, want %v (reason=%s)", got.Satisfied, tc.want, got.Reason)
|
|
}
|
|
if !got.Satisfied && got.Reason != ReasonThresholdNotMet {
|
|
t.Fatalf("expected reason %s, got %s", ReasonThresholdNotMet, got.Reason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEvaluateApproveStyle_AllDecide(t *testing.T) {
|
|
seats := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}}
|
|
d1 := []DecisionInfo{{ParticipantID: "p1", Decision: "approved"}, {ParticipantID: "p2", Decision: "rejected"}}
|
|
if !evaluateApproveStyle(QuorumAllDecide, seats, d1).Satisfied {
|
|
t.Fatalf("all_decide should be true when both decided")
|
|
}
|
|
d2 := []DecisionInfo{{ParticipantID: "p1", Decision: "approved"}}
|
|
if evaluateApproveStyle(QuorumAllDecide, seats, d2).Satisfied {
|
|
t.Fatalf("all_decide should be false when only one decided")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateApproveStyle_MajorityApprove(t *testing.T) {
|
|
seats := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}, {ID: "p3"}}
|
|
// 2/3 approve => majority
|
|
d := []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionApproved}}
|
|
if !evaluateApproveStyle(QuorumMajorityApprove, seats, d).Satisfied {
|
|
t.Fatalf("majority_approve true expected for 2/3 approves")
|
|
}
|
|
// 1/3 approve, 1 reject => not majority
|
|
d2 := []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionRejected}}
|
|
if evaluateApproveStyle(QuorumMajorityApprove, seats, d2).Satisfied {
|
|
t.Fatalf("majority_approve false expected when not strictly more than half")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateApproveStyle_NApprove(t *testing.T) {
|
|
seats := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}, {ID: "p3"}}
|
|
d := []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionApproved}}
|
|
if !evaluateApproveStyle("n_approve:2", seats, d).Satisfied {
|
|
t.Fatalf("n_approve:2 expected true")
|
|
}
|
|
if evaluateApproveStyle("n_approve:3", seats, d).Satisfied {
|
|
t.Fatalf("n_approve:3 expected false")
|
|
}
|
|
if got := evaluateApproveStyle("n_approve:notanint", seats, d); got.Satisfied || got.Reason != ReasonThresholdUnrecognized {
|
|
t.Fatalf("malformed n_approve threshold should be threshold_unrecognized, got %+v", got)
|
|
}
|
|
if got := evaluateApproveStyle("n_approve:0", seats, d); got.Satisfied || got.Reason != ReasonThresholdUnrecognized {
|
|
t.Fatalf("n_approve:0 should be threshold_unrecognized, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateApproveStyle_NApprove_ExceedsSlate_Unsatisfiable(t *testing.T) {
|
|
seats := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}}
|
|
got := evaluateApproveStyle("n_approve:5", seats, nil)
|
|
if got.Satisfied || got.Reason != ReasonThresholdUnsatisfiable {
|
|
t.Fatalf("n_approve:5 over a 2-seat slate should be threshold_unsatisfiable, got %+v", got)
|
|
}
|
|
if got.RequiredCount != 5 {
|
|
t.Fatalf("expected RequiredCount=5, got %d", got.RequiredCount)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateApproveStyle_UnrecognizedThreshold(t *testing.T) {
|
|
got := evaluateApproveStyle("quorum_of_the_moon", []ParticipantInfo{{ID: "p1"}}, nil)
|
|
if got.Satisfied || got.Reason != ReasonThresholdUnrecognized {
|
|
t.Fatalf("expected threshold_unrecognized, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateApproveStyle_UnmappedDecisionDropped(t *testing.T) {
|
|
// p1 is not (or no longer) a seat; its decision does not count.
|
|
seats := []ParticipantInfo{{ID: "p2"}}
|
|
decisions := []DecisionInfo{
|
|
{ParticipantID: "p1", Decision: DecisionApproved},
|
|
{ParticipantID: "p2", Decision: DecisionApproved},
|
|
}
|
|
if !evaluateApproveStyle(QuorumAllApprove, seats, decisions).Satisfied {
|
|
t.Fatalf("all_approve should be true when the unmapped decision is ignored")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateApproveStyle_LatestDecisionPerSeat(t *testing.T) {
|
|
seats := []ParticipantInfo{{ID: "p1"}}
|
|
// p1 approved then rejected — AC-26 ordering means the later row wins.
|
|
decisions := []DecisionInfo{
|
|
{ParticipantID: "p1", Decision: DecisionApproved},
|
|
{ParticipantID: "p1", Decision: DecisionRejected},
|
|
}
|
|
if evaluateApproveStyle(QuorumAllApprove, seats, decisions).Satisfied {
|
|
t.Fatalf("expected latest reject to override earlier approve")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateApproveStyle_MapsByRoleAndAgentWhenParticipantIDUnset(t *testing.T) {
|
|
// AC-51: an agent decider maps to its seat by (role, decider_id) when the
|
|
// decision carries no seat id.
|
|
seats := []ParticipantInfo{{ID: "p1", Role: "reviewer", AgentProfileID: "rev-A"}}
|
|
decisions := []DecisionInfo{
|
|
{DeciderType: DeciderTypeAgent, DeciderID: "rev-A", Role: "reviewer", Decision: DecisionApproved},
|
|
}
|
|
if !evaluateApproveStyle(QuorumAllApprove, seats, decisions).Satisfied {
|
|
t.Fatalf("expected decider-identity mapping to satisfy all_approve")
|
|
}
|
|
}
|
|
|
|
// --- evaluateAnyReject (AC-43/43a/43b/43c/58/59) ---
|
|
|
|
func TestEvaluateAnyReject_VetoOnRejection(t *testing.T) {
|
|
decisions := []DecisionInfo{
|
|
{DeciderType: DeciderTypeAgent, DeciderID: "rev-A", Role: "reviewer", Decision: DecisionApproved},
|
|
{DeciderType: DeciderTypeAgent, DeciderID: "rev-B", Role: "reviewer", Decision: DecisionRejected},
|
|
}
|
|
satisfied, received := evaluateAnyReject(decisions, "reviewer")
|
|
if !satisfied || received != 1 {
|
|
t.Fatalf("expected veto satisfied with 1 rejecter, got satisfied=%v received=%d", satisfied, received)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateAnyReject_NoRejection(t *testing.T) {
|
|
decisions := []DecisionInfo{
|
|
{DeciderType: DeciderTypeAgent, DeciderID: "rev-A", Role: "reviewer", Decision: DecisionApproved},
|
|
}
|
|
if satisfied, _ := evaluateAnyReject(decisions, "reviewer"); satisfied {
|
|
t.Fatalf("any_reject should be false when no rejection")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateAnyReject_LatestDecisionOverrides(t *testing.T) {
|
|
// Same decider rejects then later approves — AC-26 ordering means the
|
|
// later row (approve) is the decider's current stance, so no veto.
|
|
decisions := []DecisionInfo{
|
|
{DeciderType: DeciderTypeAgent, DeciderID: "rev-A", Role: "reviewer", Decision: DecisionRejected},
|
|
{DeciderType: DeciderTypeAgent, DeciderID: "rev-A", Role: "reviewer", Decision: DecisionApproved},
|
|
}
|
|
if satisfied, _ := evaluateAnyReject(decisions, "reviewer"); satisfied {
|
|
t.Fatalf("expected latest approve to clear the earlier veto")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateAnyReject_RoleScopedForAgent(t *testing.T) {
|
|
// AC-42: an agent decider's rejection under a different role does not
|
|
// veto this guard's role.
|
|
decisions := []DecisionInfo{
|
|
{DeciderType: DeciderTypeAgent, DeciderID: "rev-A", Role: "qa", Decision: DecisionRejected},
|
|
}
|
|
if satisfied, _ := evaluateAnyReject(decisions, "reviewer"); satisfied {
|
|
t.Fatalf("agent rejection under a different role must not veto")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateAnyReject_RoleAgnosticForUser(t *testing.T) {
|
|
// AC-58: a human decider's rejection vetoes regardless of stored role.
|
|
decisions := []DecisionInfo{
|
|
{DeciderType: DeciderTypeUser, DeciderID: "user-1", Role: "qa", Decision: DecisionRejected},
|
|
}
|
|
if satisfied, _ := evaluateAnyReject(decisions, "reviewer"); !satisfied {
|
|
t.Fatalf("user rejection should veto regardless of role")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateAnyReject_ChangesRequestedCountsAsRejection(t *testing.T) {
|
|
decisions := []DecisionInfo{
|
|
{DeciderType: DeciderTypeUser, DeciderID: "user-1", Role: "reviewer", Decision: DecisionChangesRequested},
|
|
}
|
|
if satisfied, _ := evaluateAnyReject(decisions, "reviewer"); !satisfied {
|
|
t.Fatalf("changes_requested should be treated as a rejection veto")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateAnyReject_EmptyDecisions(t *testing.T) {
|
|
if satisfied, received := evaluateAnyReject(nil, "reviewer"); satisfied || received != 0 {
|
|
t.Fatalf("expected no veto with no decisions, got satisfied=%v received=%d", satisfied, received)
|
|
}
|
|
}
|
|
|
|
// --- requiredSeats / canonicalize / collapse (AC-20/44/49/50) ---
|
|
|
|
func TestRequiredSeats_FiltersByRoleAndDecisionRequired(t *testing.T) {
|
|
parts := scopedParticipants{template: []ParticipantInfo{
|
|
{ID: "p1", StepID: "review", Role: "reviewer", AgentProfileID: "rev-A", DecisionRequired: true},
|
|
{ID: "p2", StepID: "review", Role: "reviewer", AgentProfileID: "rev-B", DecisionRequired: false},
|
|
{ID: "p3", StepID: "review", Role: "qa", AgentProfileID: "qa-A", DecisionRequired: true},
|
|
}}
|
|
eng := quorumEngine(newFakeDecisionStore(), parts)
|
|
seats, err := eng.requiredSeats(context.Background(), "review", "task-1", "reviewer")
|
|
if err != nil {
|
|
t.Fatalf("requiredSeats: %v", err)
|
|
}
|
|
if len(seats) != 1 || seats[0].ID != "p1" {
|
|
t.Fatalf("expected only p1 to survive role+decision_required filtering, got %+v", seats)
|
|
}
|
|
}
|
|
|
|
func TestRequiredSeats_PerTaskWinsOverTemplateOnCollapse(t *testing.T) {
|
|
// A template seat for rev-A at the evaluating step, and a per-task
|
|
// override for rev-A recorded at a different step (AC-49: per-task
|
|
// participation is not step-scoped). Collapse must produce exactly one
|
|
// seat, carrying the per-task row.
|
|
parts := scopedParticipants{
|
|
template: []ParticipantInfo{
|
|
{ID: "tmpl-1", StepID: "review", Role: "reviewer", AgentProfileID: "rev-A", DecisionRequired: true},
|
|
},
|
|
perTask: []ParticipantInfo{
|
|
{ID: "task-row-1", TaskID: "task-1", StepID: "approval", Role: "reviewer", AgentProfileID: "rev-A", DecisionRequired: true},
|
|
},
|
|
}
|
|
eng := quorumEngine(newFakeDecisionStore(), parts)
|
|
seats, err := eng.requiredSeats(context.Background(), "review", "task-1", "reviewer")
|
|
if err != nil {
|
|
t.Fatalf("requiredSeats: %v", err)
|
|
}
|
|
if len(seats) != 1 {
|
|
t.Fatalf("expected exactly one collapsed seat, got %d: %+v", len(seats), seats)
|
|
}
|
|
if seats[0].ID != "task-row-1" {
|
|
t.Fatalf("expected the per-task row to win collapse, got %+v", seats[0])
|
|
}
|
|
}
|
|
|
|
func TestRequiredSeats_CanonicalizePrefersEvaluatingStep(t *testing.T) {
|
|
// Two per-task rows for the same (task, role, agent) at different steps
|
|
// — canonicalize must keep the one at the evaluating step.
|
|
parts := scopedParticipants{perTask: []ParticipantInfo{
|
|
{ID: "row-other", TaskID: "task-1", StepID: "other", Role: "reviewer", AgentProfileID: "rev-A", DecisionRequired: true},
|
|
{ID: "row-review", TaskID: "task-1", StepID: "review", Role: "reviewer", AgentProfileID: "rev-A", DecisionRequired: true},
|
|
}}
|
|
eng := quorumEngine(newFakeDecisionStore(), parts)
|
|
seats, err := eng.requiredSeats(context.Background(), "review", "task-1", "reviewer")
|
|
if err != nil {
|
|
t.Fatalf("requiredSeats: %v", err)
|
|
}
|
|
if len(seats) != 1 || seats[0].ID != "row-review" {
|
|
t.Fatalf("expected canonicalize to prefer the evaluating-step row, got %+v", seats)
|
|
}
|
|
}
|
|
|
|
func TestRequiredSeats_CanonicalizeFallsBackToLowestID(t *testing.T) {
|
|
// Neither duplicate row is at the evaluating step — fall back to the
|
|
// lowest id in ASCII order.
|
|
parts := scopedParticipants{perTask: []ParticipantInfo{
|
|
{ID: "row-z", TaskID: "task-1", StepID: "other-b", Role: "reviewer", AgentProfileID: "rev-A", DecisionRequired: true},
|
|
{ID: "row-a", TaskID: "task-1", StepID: "other-a", Role: "reviewer", AgentProfileID: "rev-A", DecisionRequired: true},
|
|
}}
|
|
eng := quorumEngine(newFakeDecisionStore(), parts)
|
|
seats, err := eng.requiredSeats(context.Background(), "review", "task-1", "reviewer")
|
|
if err != nil {
|
|
t.Fatalf("requiredSeats: %v", err)
|
|
}
|
|
if len(seats) != 1 || seats[0].ID != "row-a" {
|
|
t.Fatalf("expected canonicalize to fall back to lowest id, got %+v", seats)
|
|
}
|
|
}
|
|
|
|
func TestRequiredSeats_EmptyAgentProfileNeverGrouped(t *testing.T) {
|
|
parts := scopedParticipants{template: []ParticipantInfo{
|
|
{ID: "p1", StepID: "review", Role: "reviewer", AgentProfileID: "", DecisionRequired: true},
|
|
{ID: "p2", StepID: "review", Role: "reviewer", AgentProfileID: "", DecisionRequired: true},
|
|
}}
|
|
eng := quorumEngine(newFakeDecisionStore(), parts)
|
|
seats, err := eng.requiredSeats(context.Background(), "review", "task-1", "reviewer")
|
|
if err != nil {
|
|
t.Fatalf("requiredSeats: %v", err)
|
|
}
|
|
if len(seats) != 2 {
|
|
t.Fatalf("expected empty-agent_profile_id rows to remain distinct seats, got %d: %+v", len(seats), seats)
|
|
}
|
|
}
|
|
|
|
func TestRequiredSeats_EmptySlate(t *testing.T) {
|
|
parts := scopedParticipants{}
|
|
eng := quorumEngine(newFakeDecisionStore(), parts)
|
|
seats, err := eng.requiredSeats(context.Background(), "review", "task-1", "reviewer")
|
|
if err != nil {
|
|
t.Fatalf("requiredSeats: %v", err)
|
|
}
|
|
if len(seats) != 0 {
|
|
t.Fatalf("expected empty slate, got %+v", seats)
|
|
}
|
|
}
|
|
|
|
// --- computeGuardOutcome / evaluateTransitionGuard precedence (AC-23/52) ---
|
|
|
|
func TestEvaluateTransitionGuard_NilGuardPermits(t *testing.T) {
|
|
eng := quorumEngine(newFakeDecisionStore(), scopedParticipants{})
|
|
got := eng.evaluateTransitionGuard(context.Background(), MachineState{}, Action{Kind: ActionMoveToNext})
|
|
if !got.Satisfied {
|
|
t.Fatalf("nil guard should always permit the transition")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateTransitionGuard_UnrecognizedGuardVariant(t *testing.T) {
|
|
eng := quorumEngine(newFakeDecisionStore(), scopedParticipants{})
|
|
got := eng.evaluateTransitionGuard(context.Background(), MachineState{}, Action{
|
|
Kind: ActionMoveToNext,
|
|
Guard: &TransitionGuard{},
|
|
})
|
|
if got.Satisfied || got.Reason != ReasonGuardVariantUnrecognized {
|
|
t.Fatalf("expected guard_variant_unrecognized, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestComputeGuardOutcome_DecisionStoreUnwired(t *testing.T) {
|
|
eng := New(quorumStore(nil), MapRegistry{}, WithParticipantStore(scopedParticipants{}))
|
|
got := eng.computeGuardOutcome(context.Background(), MachineState{}, &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove})
|
|
if got.Satisfied || got.Reason != ReasonDecisionStoreUnwired {
|
|
t.Fatalf("expected decision_store_unwired, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestComputeGuardOutcome_ParticipantStoreUnwired(t *testing.T) {
|
|
eng := New(quorumStore(nil), MapRegistry{}, WithDecisionStore(newFakeDecisionStore()))
|
|
got := eng.computeGuardOutcome(context.Background(), MachineState{}, &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove})
|
|
if got.Satisfied || got.Reason != ReasonParticipantStoreUnwired {
|
|
t.Fatalf("expected participant_store_unwired, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestComputeGuardOutcome_SlateEmpty(t *testing.T) {
|
|
eng := quorumEngine(newFakeDecisionStore(), scopedParticipants{})
|
|
state := MachineState{TaskID: "task-1", CurrentStepID: "review"}
|
|
got := eng.computeGuardOutcome(context.Background(), state, &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove})
|
|
if got.Satisfied || got.Reason != ReasonSlateEmpty {
|
|
t.Fatalf("expected slate_empty, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func readQuorumSlateEmptyCounter(role string) int64 {
|
|
m := expvar.Get("workflow_quorum_slate_empty_total")
|
|
if m == nil {
|
|
return 0
|
|
}
|
|
kv := m.(*expvar.Map).Get(role)
|
|
if kv == nil {
|
|
return 0
|
|
}
|
|
iv, ok := kv.(*expvar.Int)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
return iv.Value()
|
|
}
|
|
|
|
// TestComputeGuardOutcome_MalformedRoleSanitizesSlateEmptyCounterLabel covers
|
|
// AC-OFFICE-REVIEW-SEATS-004.6/.11 on the slate_empty path: guard.Role is
|
|
// operator-editable step config, and a value outside the fixed
|
|
// ParticipantRole set must fold to the SeatRoleLabelInvalid sentinel on the
|
|
// counter rather than growing its label cardinality with an arbitrary
|
|
// string.
|
|
func TestComputeGuardOutcome_MalformedRoleSanitizesSlateEmptyCounterLabel(t *testing.T) {
|
|
const malformedRole = "not-a-real-role"
|
|
eng := quorumEngine(newFakeDecisionStore(), scopedParticipants{})
|
|
state := MachineState{TaskID: "task-1", CurrentStepID: "review"}
|
|
|
|
before := readQuorumSlateEmptyCounter(SeatRoleLabelInvalid)
|
|
got := eng.computeGuardOutcome(context.Background(), state, &WaitForQuorumGuard{Role: malformedRole, Threshold: QuorumAllApprove})
|
|
if got.Satisfied || got.Reason != ReasonSlateEmpty {
|
|
t.Fatalf("expected slate_empty, got %+v", got)
|
|
}
|
|
after := readQuorumSlateEmptyCounter(SeatRoleLabelInvalid)
|
|
if after-before != 1 {
|
|
t.Fatalf("%s counter delta = %d, want 1", SeatRoleLabelInvalid, after-before)
|
|
}
|
|
if got := readQuorumSlateEmptyCounter(malformedRole); got != 0 {
|
|
t.Fatalf("expected no counter entry under the raw operator string %q, got %d", malformedRole, got)
|
|
}
|
|
}
|
|
|
|
func TestComputeGuardOutcome_EvaluationError(t *testing.T) {
|
|
parts := scopedParticipants{err: errors.New("boom")}
|
|
eng := quorumEngine(newFakeDecisionStore(), parts)
|
|
state := MachineState{TaskID: "task-1", CurrentStepID: "review"}
|
|
got := eng.computeGuardOutcome(context.Background(), state, &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove})
|
|
if got.Satisfied || got.Reason != ReasonEvaluationError || got.Err == nil {
|
|
t.Fatalf("expected evaluation_error with a wrapped error, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestComputeGuardOutcome_AnyReject_NoSlateNeeded(t *testing.T) {
|
|
// AC-59: any_reject must not short-circuit on an empty seat slate.
|
|
decisions := newFakeDecisionStore()
|
|
if err := decisions.RecordStepDecision(context.Background(), DecisionInfo{
|
|
TaskID: "task-1", StepID: "review",
|
|
DeciderType: DeciderTypeUser, DeciderID: "user-1", Role: "reviewer",
|
|
Decision: DecisionRejected,
|
|
}); err != nil {
|
|
t.Fatalf("record: %v", err)
|
|
}
|
|
eng := quorumEngine(decisions, scopedParticipants{}) // no seats configured at all
|
|
state := MachineState{TaskID: "task-1", CurrentStepID: "review"}
|
|
got := eng.computeGuardOutcome(context.Background(), state, &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAnyReject})
|
|
if !got.Satisfied {
|
|
t.Fatalf("expected any_reject veto to fire despite an empty slate, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestComputeGuardOutcome_ThresholdUnrecognizedTakesPrecedenceOverThresholdNotMet(t *testing.T) {
|
|
parts := scopedParticipants{template: []ParticipantInfo{
|
|
{ID: "p1", StepID: "review", Role: "reviewer", AgentProfileID: "rev-A", DecisionRequired: true},
|
|
}}
|
|
eng := quorumEngine(newFakeDecisionStore(), parts)
|
|
state := MachineState{TaskID: "task-1", CurrentStepID: "review"}
|
|
got := eng.computeGuardOutcome(context.Background(), state, &WaitForQuorumGuard{Role: "reviewer", Threshold: "not_a_real_threshold"})
|
|
if got.Satisfied || got.Reason != ReasonThresholdUnrecognized {
|
|
t.Fatalf("expected threshold_unrecognized, got %+v", got)
|
|
}
|
|
}
|
|
|
|
// --- Engine-level integration (unchanged from the pre-rewrite suite) ---
|
|
|
|
func TestEngine_WaitForQuorum_BlocksUntilSatisfied(t *testing.T) {
|
|
store := quorumStore(&TransitionGuard{
|
|
WaitForQuorum: &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove},
|
|
})
|
|
decisions := newFakeDecisionStore()
|
|
parts := fakeParticipants{list: []ParticipantInfo{
|
|
{ID: "p1", Role: "reviewer", DecisionRequired: true, AgentProfileID: "rev-A"},
|
|
{ID: "p2", Role: "reviewer", DecisionRequired: true, AgentProfileID: "rev-B"},
|
|
}}
|
|
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(parts))
|
|
|
|
res, err := eng.HandleTrigger(context.Background(), HandleInput{
|
|
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if res.Transitioned {
|
|
t.Fatalf("expected guard to block transition with no decisions")
|
|
}
|
|
|
|
// Record one approval - still not enough for all_approve.
|
|
if err := decisions.RecordStepDecision(context.Background(), DecisionInfo{
|
|
TaskID: "task-1", StepID: "review", ParticipantID: "p1", Decision: DecisionApproved,
|
|
}); err != nil {
|
|
t.Fatalf("record: %v", err)
|
|
}
|
|
res, err = eng.HandleTrigger(context.Background(), HandleInput{
|
|
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if res.Transitioned {
|
|
t.Fatalf("expected guard to block transition with partial decisions")
|
|
}
|
|
|
|
// Record second approval - quorum reached.
|
|
if err := decisions.RecordStepDecision(context.Background(), DecisionInfo{
|
|
TaskID: "task-1", StepID: "review", ParticipantID: "p2", Decision: DecisionApproved,
|
|
}); err != nil {
|
|
t.Fatalf("record: %v", err)
|
|
}
|
|
res, err = eng.HandleTrigger(context.Background(), HandleInput{
|
|
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !res.Transitioned {
|
|
t.Fatalf("expected transition once quorum satisfied")
|
|
}
|
|
if res.ToStepID != "approval" {
|
|
t.Fatalf("expected target = approval, got %q", res.ToStepID)
|
|
}
|
|
}
|
|
|
|
func TestEngine_WaitForQuorum_NoRequiredParticipants_FailsClosed(t *testing.T) {
|
|
store := quorumStore(&TransitionGuard{
|
|
WaitForQuorum: &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove},
|
|
})
|
|
decisions := newFakeDecisionStore()
|
|
// All participants present but DecisionRequired=false.
|
|
parts := fakeParticipants{list: []ParticipantInfo{
|
|
{ID: "p1", Role: "reviewer", DecisionRequired: false, AgentProfileID: "rev-A"},
|
|
}}
|
|
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(parts))
|
|
res, err := eng.HandleTrigger(context.Background(), HandleInput{
|
|
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if res.Transitioned {
|
|
t.Fatalf("expected guard to fail closed when no required participants exist")
|
|
}
|
|
}
|
|
|
|
func TestEngine_WaitForQuorum_NoStoresWired_FailsClosed(t *testing.T) {
|
|
store := quorumStore(&TransitionGuard{
|
|
WaitForQuorum: &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove},
|
|
})
|
|
eng := New(store, MapRegistry{}) // no DecisionStore / ParticipantStore
|
|
res, err := eng.HandleTrigger(context.Background(), HandleInput{
|
|
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if res.Transitioned {
|
|
t.Fatalf("expected guard to fail closed when stores not wired")
|
|
}
|
|
}
|
|
|
|
func TestClearDecisionsCallback_DeletesRows(t *testing.T) {
|
|
decisions := newFakeDecisionStore()
|
|
_ = decisions.RecordStepDecision(context.Background(), DecisionInfo{TaskID: "t", StepID: "s", ParticipantID: "p", Decision: "approved"})
|
|
cb := ClearDecisionsCallback{Decisions: decisions}
|
|
_, err := cb.Execute(context.Background(), ActionInput{
|
|
State: MachineState{TaskID: "t"},
|
|
Step: StepSpec{ID: "s"},
|
|
Action: Action{Kind: ActionClearDecisions, ClearDecisions: &ClearDecisionsAction{}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("clear: %v", err)
|
|
}
|
|
if got, _ := decisions.ListStepDecisions(context.Background(), "t", "s"); len(got) != 0 {
|
|
t.Fatalf("expected decisions cleared, got %d", len(got))
|
|
}
|
|
if decisions.cleared[dkey("t", "s")] != 1 {
|
|
t.Fatalf("expected cleared count for (t,s) to be 1, got %d", decisions.cleared[dkey("t", "s")])
|
|
}
|
|
}
|
|
|
|
func TestClearDecisionsCallback_NoStore_Errors(t *testing.T) {
|
|
cb := ClearDecisionsCallback{}
|
|
_, err := cb.Execute(context.Background(), ActionInput{
|
|
Action: Action{Kind: ActionClearDecisions, ClearDecisions: &ClearDecisionsAction{}},
|
|
})
|
|
if err == nil || !errors.Is(err, ErrActionNotYetWired) {
|
|
t.Fatalf("expected ErrActionNotYetWired, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEngine_RecordParticipantDecision_PersistsAndReevaluates(t *testing.T) {
|
|
store := quorumStore(&TransitionGuard{
|
|
WaitForQuorum: &WaitForQuorumGuard{Role: "reviewer", Threshold: QuorumAllApprove},
|
|
})
|
|
decisions := newFakeDecisionStore()
|
|
parts := fakeParticipants{list: []ParticipantInfo{
|
|
{ID: "p1", Role: "reviewer", DecisionRequired: true, AgentProfileID: "rev-A"},
|
|
}}
|
|
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(parts))
|
|
|
|
result, err := eng.RecordParticipantDecision(context.Background(), "sess-1", DecisionInfo{
|
|
TaskID: "task-1", StepID: "review", ParticipantID: "p1", Decision: DecisionApproved, Note: "lgtm",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RecordParticipantDecision: %v", err)
|
|
}
|
|
if result.DecisionID == "" {
|
|
t.Fatalf("expected a generated decision id")
|
|
}
|
|
if result.DecidedAt.IsZero() {
|
|
t.Fatalf("expected DecidedAt to be stamped")
|
|
}
|
|
if !result.Transitioned || result.TransitionAbandoned {
|
|
t.Fatalf("expected the sole reviewer's approval to satisfy all_approve and transition: %#v", result)
|
|
}
|
|
if result.FromStepID != "review" || result.ToStepID != "approval" {
|
|
t.Fatalf("unexpected transition endpoints: %#v", result)
|
|
}
|
|
if len(result.Guards) != 1 || !result.Guards[0].Satisfied || result.Guards[0].TargetStepID != "approval" {
|
|
t.Fatalf("expected the validated guard snapshot on transition, got %#v", result.Guards)
|
|
}
|
|
if store.state.CurrentStepID != "approval" {
|
|
t.Fatalf("expected store to have moved to 'approval', got %q", store.state.CurrentStepID)
|
|
}
|
|
|
|
got, _ := decisions.ListStepDecisions(context.Background(), "task-1", "review")
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 recorded decision, got %d", len(got))
|
|
}
|
|
if got[0].Decision != DecisionApproved || got[0].Note != "lgtm" {
|
|
t.Fatalf("unexpected decision shape: %#v", got[0])
|
|
}
|
|
if got[0].ID != result.DecisionID || !got[0].DecidedAt.Equal(result.DecidedAt) {
|
|
t.Fatalf("persisted decision does not carry the id/DecidedAt returned to the caller: %#v", got[0])
|
|
}
|
|
}
|
|
|
|
func TestEngine_RecordParticipantDecision_RequiresStore(t *testing.T) {
|
|
store := quorumStore(nil)
|
|
eng := New(store, MapRegistry{}) // no DecisionStore wired
|
|
_, err := eng.RecordParticipantDecision(context.Background(), "sess-1", DecisionInfo{
|
|
TaskID: "task-1", StepID: "review", ParticipantID: "p1", Decision: DecisionApproved,
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("expected error when DecisionStore missing")
|
|
}
|
|
}
|
|
|
|
func TestEngine_RecordParticipantDecision_RequiresIDs(t *testing.T) {
|
|
decisions := newFakeDecisionStore()
|
|
eng := New(quorumStore(nil), MapRegistry{}, WithDecisionStore(decisions))
|
|
cases := []struct {
|
|
name string
|
|
task, step, participant string
|
|
decision string
|
|
expectErr bool
|
|
}{
|
|
{"missing task", "", "step", "p", "approved", true},
|
|
{"missing step", "task", "", "p", "approved", true},
|
|
{"missing participant", "task", "step", "", "approved", true},
|
|
{"missing decision", "task", "step", "p", "", true},
|
|
{"valid (no session => skip reeval)", "task", "step", "p", "approved", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result, err := eng.RecordParticipantDecision(context.Background(), "", DecisionInfo{
|
|
TaskID: tc.task, StepID: tc.step, ParticipantID: tc.participant, Decision: tc.decision,
|
|
})
|
|
if (err != nil) != tc.expectErr {
|
|
t.Fatalf("err=%v, expectErr=%v", err, tc.expectErr)
|
|
}
|
|
if err == nil && !result.ReevaluationSkipped {
|
|
t.Fatalf("expected ReevaluationSkipped with a blank sessionID: %#v", result)
|
|
}
|
|
if err == nil && result.SkipReason != ReasonSessionUnresolvable {
|
|
t.Fatalf("expected SkipReason=%s, got %q", ReasonSessionUnresolvable, result.SkipReason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfigTransitionGuard_ParsesWaitForQuorum(t *testing.T) {
|
|
cfg := map[string]any{
|
|
"if": map[string]any{
|
|
"wait_for_quorum": map[string]any{
|
|
"role": "reviewer",
|
|
"threshold": "all_approve",
|
|
},
|
|
},
|
|
}
|
|
g := ConfigTransitionGuard(cfg)
|
|
if g == nil || g.WaitForQuorum == nil {
|
|
t.Fatalf("expected wait_for_quorum guard")
|
|
}
|
|
if g.WaitForQuorum.Role != "reviewer" || g.WaitForQuorum.Threshold != "all_approve" {
|
|
t.Fatalf("unexpected guard: %+v", g.WaitForQuorum)
|
|
}
|
|
}
|
|
|
|
func TestConfigTransitionGuard_ParsesLegacyTopLevelWaitForQuorum(t *testing.T) {
|
|
cfg := map[string]any{
|
|
"wait_for_quorum": map[string]any{
|
|
"role": "reviewer",
|
|
"threshold": "all_approve",
|
|
},
|
|
}
|
|
g := ConfigTransitionGuard(cfg)
|
|
if g == nil || g.WaitForQuorum == nil {
|
|
t.Fatalf("expected guard, got %#v", g)
|
|
}
|
|
if g.WaitForQuorum.Role != "reviewer" || g.WaitForQuorum.Threshold != "all_approve" {
|
|
t.Fatalf("unexpected guard: %#v", g.WaitForQuorum)
|
|
}
|
|
if got := ConfigTransitionGuard(nil); got != nil {
|
|
t.Fatalf("expected nil for nil config")
|
|
}
|
|
if got := ConfigTransitionGuard(map[string]any{}); got != nil {
|
|
t.Fatalf("expected nil when key absent")
|
|
}
|
|
// Missing fields => no guard.
|
|
if got := ConfigTransitionGuard(map[string]any{"wait_for_quorum": map[string]any{"role": ""}}); got != nil {
|
|
t.Fatalf("expected nil for malformed guard")
|
|
}
|
|
}
|