fix(workflow): make quorum-guarded step transitions actually fire (#2864)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
nova28
2026-08-21 23:36:54 +08:00
committed by GitHub
parent c8b0b50ff8
commit 8898459c0b
77 changed files with 7923 additions and 256 deletions
@@ -16,6 +16,7 @@ Available tools:
- list_task_documents_kandev: List documents on an accessible related task. Required: task_id.
- get_task_document_kandev: Read one document on an accessible related task. Required: task_id, document_key.
- write_task_document_kandev: Create or replace a document on the current task or an ancestor. Required: task_id, document_key, content. Optional: title, type.
- record_step_decision_kandev: Record an approve/reject decision for the current workflow step, as a reviewer or approver. Required: decision ("approved" or "rejected"), reason. Not idempotent: a second call supersedes the first.
Office state changes are performed through `$KANDEV_CLI kandev ...`, subject to this agent's runtime permissions. Use the injected Office skills for exact commands and do not search for additional Kandev MCP tools. Workspace administration is outside this run.
@@ -1576,6 +1576,9 @@ func registerMCPAndDebugRoutes(
if p.services.GitLab != nil {
mcpHandlers.SetTaskMRAutomationService(p.services.GitLab)
}
if p.services.OfficeSvcs != nil && p.services.OfficeSvcs.Dashboard != nil {
mcpHandlers.SetDashboardService(p.services.OfficeSvcs.Dashboard)
}
// Reuse the cross-task handoff service constructed in registerRoutes —
// the same instance backs the MCP path and the HTTP Kanban path so
@@ -0,0 +1,96 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/office/dashboard"
"github.com/kandev/kandev/internal/office/shared"
"github.com/kandev/kandev/internal/task/models"
ws "github.com/kandev/kandev/pkg/websocket"
)
// SetDashboardService wires the office dashboard service used to back the
// record_step_decision_kandev MCP tool. Optional — when nil, the handler
// reports a configuration error instead of registering and returning a
// broken endpoint.
func (h *Handlers) SetDashboardService(svc *dashboard.DashboardService) {
h.dashboardSvc = svc
}
// handleRecordStepDecision dispatches mcp.record_step_decision
// (record_step_decision_kandev). Per the tool contract, task/step/role are
// never caller-supplied: the task comes from the calling session (msg
// carries session_id/task_id set by the MCP server from its own bound
// s.sessionID/s.taskID, matching every other Office tool), and role/seat
// are resolved server-side by DashboardService.RecordAgentDecision via the
// engine's AC-4a slate.
func (h *Handlers) handleRecordStepDecision(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
if h.dashboardSvc == nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "dashboard service not configured", nil)
}
var req struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
Decision string `json:"decision"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.TaskID == "" || req.SessionID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id and session_id are required", nil)
}
session, err := h.sessionRepo.GetTaskSession(ctx, req.SessionID)
if err != nil {
if errors.Is(err, models.ErrTaskSessionNotFound) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "session not found", nil)
}
h.logger.Error("record_step_decision: failed to load session",
zap.String("session_id", req.SessionID), zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to load session", nil)
}
if session.TaskID != req.TaskID {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session does not belong to task", nil)
}
result, err := h.dashboardSvc.RecordAgentDecision(ctx, dashboard.RecordAgentDecisionInput{
TaskID: req.TaskID,
AgentProfileID: session.AgentProfileID,
Decision: req.Decision,
Reason: req.Reason,
})
if err != nil {
if !errors.Is(err, shared.ErrForbidden) && !dashboard.IsAgentDecisionValidationError(err) {
h.logger.Error("record_step_decision: failed to record decision", zap.String("task_id", req.TaskID), zap.Error(err))
}
return mapRecordStepDecisionError(msg, err)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"decision": result.Decision,
"role": result.Role,
"step_id": result.StepID,
"decision_id": result.DecisionID,
"decided_at": result.DecidedAt,
"transition_applied": result.TransitionApplied,
"guards": result.Guards,
})
}
// mapRecordStepDecisionError classifies RecordAgentDecision failures into
// WS error codes. Only typed validation failures are safe to expose. Raw
// repository and engine failures become an opaque internal error.
func mapRecordStepDecisionError(msg *ws.Message, err error) (*ws.Message, error) {
if errors.Is(err, shared.ErrForbidden) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeForbidden, err.Error(), nil)
}
if dashboard.IsAgentDecisionValidationError(err) {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to record step decision", nil)
}
@@ -0,0 +1,157 @@
package handlers
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/office/dashboard"
"github.com/kandev/kandev/internal/office/shared"
"github.com/kandev/kandev/internal/task/models"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
)
// This file covers the record_step_decision_kandev MCP transport boundary
// (handleRecordStepDecision / mapRecordStepDecisionError). Review round 1
// flagged this file at zero coverage. RecordAgentDecision's own business
// logic (role resolution, verdict/reason validation, engine re-evaluation)
// is already covered at the dashboard-service tier in
// internal/office/dashboard/agent_decisions_test.go — these tests instead
// exercise what's unique to this transport: payload validation, session
// lookup, the session/task ownership check, and error-code mapping.
// seedRecordStepDecisionSession seeds a minimal workspace/task/session chain
// so handleRecordStepDecision's h.sessionRepo.GetTaskSession lookup and the
// session.TaskID ownership check have something real to read.
func seedRecordStepDecisionSession(t *testing.T, repo *sqliterepo.Repository, workspaceID, taskID, sessionID string) {
t.Helper()
ctx := context.Background()
now := time.Now().UTC()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{
ID: workspaceID, Name: "Decision WS", CreatedAt: now, UpdatedAt: now,
}))
require.NoError(t, repo.CreateTask(ctx, &models.Task{
ID: taskID, WorkspaceID: workspaceID, Title: "Decision Task",
State: v1.TaskStateInProgress, CreatedAt: now, UpdatedAt: now,
}))
require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{
ID: sessionID, TaskID: taskID, State: models.TaskSessionStateRunning,
StartedAt: now, UpdatedAt: now,
}))
}
// newRecordStepDecisionHandler builds a *Handlers whose dashboardSvc is a
// real, minimally-constructed *dashboard.DashboardService. Every test case
// here returns before RecordAgentDecision ever touches its repo/dispatcher
// dependencies, so a nil Repository is safe — dashboardSvc only needs to be
// non-nil to pass handleRecordStepDecision's first guard.
func newRecordStepDecisionHandler(t *testing.T, repo *sqliterepo.Repository, svc *dashboard.DashboardService) *Handlers {
t.Helper()
return &Handlers{
sessionRepo: repo,
dashboardSvc: svc,
logger: testLogger(t).WithFields(),
}
}
func TestHandleRecordStepDecision_NotConfigured(t *testing.T) {
h := &Handlers{logger: testLogger(t).WithFields()}
msg := makeWSMessage(t, ws.ActionMCPRecordStepDecision, map[string]interface{}{
"task_id": "t1", "session_id": "s1", "decision": "approved", "reason": "lgtm",
})
resp, err := h.handleRecordStepDecision(context.Background(), msg)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeInternalError)
}
func TestHandleRecordStepDecision_InvalidPayload(t *testing.T) {
svc := dashboard.NewDashboardService(nil, testLogger(t), nil, nil, nil)
h := newRecordStepDecisionHandler(t, nil, svc)
msg := &ws.Message{
ID: "test-id", Type: ws.MessageTypeRequest,
Action: ws.ActionMCPRecordStepDecision, Payload: []byte(`{"task_id":`),
}
resp, err := h.handleRecordStepDecision(context.Background(), msg)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeBadRequest)
}
func TestHandleRecordStepDecision_MissingFields(t *testing.T) {
svc := dashboard.NewDashboardService(nil, testLogger(t), nil, nil, nil)
cases := []struct {
name string
payload map[string]interface{}
}{
{
name: "missing task_id",
payload: map[string]interface{}{"session_id": "s1", "decision": "approved", "reason": "lgtm"},
},
{
name: "missing session_id",
payload: map[string]interface{}{"task_id": "t1", "decision": "approved", "reason": "lgtm"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newRecordStepDecisionHandler(t, nil, svc)
msg := makeWSMessage(t, ws.ActionMCPRecordStepDecision, tc.payload)
resp, err := h.handleRecordStepDecision(context.Background(), msg)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeValidation)
})
}
}
func TestHandleRecordStepDecision_SessionNotFound(t *testing.T) {
svc := dashboard.NewDashboardService(nil, testLogger(t), nil, nil, nil)
_, repo := newTestTaskService(t)
h := newRecordStepDecisionHandler(t, repo, svc)
msg := makeWSMessage(t, ws.ActionMCPRecordStepDecision, map[string]interface{}{
"task_id": "missing-task", "session_id": "missing-session", "decision": "approved", "reason": "lgtm",
})
resp, err := h.handleRecordStepDecision(context.Background(), msg)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeNotFound)
}
func TestHandleRecordStepDecision_SessionDoesNotBelongToTask(t *testing.T) {
svc := dashboard.NewDashboardService(nil, testLogger(t), nil, nil, nil)
_, repo := newTestTaskService(t)
seedRecordStepDecisionSession(t, repo, "ws-rd1", "task-rd1", "sess-rd1")
h := newRecordStepDecisionHandler(t, repo, svc)
msg := makeWSMessage(t, ws.ActionMCPRecordStepDecision, map[string]interface{}{
"task_id": "some-other-task", "session_id": "sess-rd1", "decision": "approved", "reason": "lgtm",
})
resp, err := h.handleRecordStepDecision(context.Background(), msg)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeValidation)
}
func TestMapRecordStepDecisionError_ForbiddenMapsTo403(t *testing.T) {
msg := makeWSMessage(t, ws.ActionMCPRecordStepDecision, map[string]interface{}{})
resp, err := mapRecordStepDecisionError(msg, shared.ErrForbidden)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeForbidden)
}
func TestMapRecordStepDecisionError_UnexpectedFailureMapsToInternal(t *testing.T) {
msg := makeWSMessage(t, ws.ActionMCPRecordStepDecision, map[string]interface{}{})
resp, err := mapRecordStepDecisionError(msg, errors.New("task has no workflow step bound"))
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeInternalError)
}
func TestMapRecordStepDecisionError_ValidationMapsToValidation(t *testing.T) {
msg := makeWSMessage(t, ws.ActionMCPRecordStepDecision, map[string]interface{}{})
validationErr := &dashboard.AgentDecisionValidationError{Err: errors.New("reason is required")}
resp, err := mapRecordStepDecisionError(msg, validationErr)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeValidation)
}
@@ -19,6 +19,7 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/office/dashboard"
"github.com/kandev/kandev/internal/orchestrator"
"github.com/kandev/kandev/internal/orchestrator/executor"
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
@@ -245,6 +246,10 @@ type Handlers struct {
// MCP tools introduced in office task handoffs phase 2.
handoffSvc *service.HandoffService
// Office dashboard service (optional, set via SetDashboardService).
// Wires the record_step_decision_kandev MCP tool.
dashboardSvc *dashboard.DashboardService
// Optional PR lister (set via SetTaskPRLister) used to enrich
// task-listing responses with associated pull requests.
taskPRLister TaskPRLister
@@ -440,6 +445,9 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) {
d.RegisterFunc(ws.ActionMCPGetTaskDocument, h.handleGetTaskDocument)
d.RegisterFunc(ws.ActionMCPWriteTaskDocument, h.handleWriteTaskDocument)
}
if h.dashboardSvc != nil {
d.RegisterFunc(ws.ActionMCPRecordStepDecision, h.handleRecordStepDecision)
}
if h.taskSvc != nil {
d.RegisterFunc(ws.ActionMCPMoveTask, h.handleMoveTask)
d.RegisterFunc(ws.ActionMCPDeleteTask, h.handleDeleteTask)
@@ -0,0 +1,53 @@
package mcp
import (
"context"
"encoding/json"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
ws "github.com/kandev/kandev/pkg/websocket"
)
// registerRecordStepDecisionTool registers record_step_decision_kandev, the
// Office quorum decision-recording tool. Per the tool contract in
// docs/specs/workflow-quorum-decision-recording/spec.md, it takes no
// task_id — task/step/role are resolved server-side from the calling
// session, matching every other Office tool.
func (s *Server) registerRecordStepDecisionTool() {
s.mcpServer.AddTool(
mcp.NewTool("record_step_decision_kandev",
mcp.WithDescription(`Record an approve/reject decision for the current workflow step, as a reviewer or approver. Call this once you have finished evaluating the step's output. The task, step, and your role are resolved from your session — you cannot record a decision on another task. Not idempotent: a second call supersedes the first.`),
mcp.WithString("decision", mcp.Required(), mcp.Enum("approved", "rejected"), mcp.Description("The verdict: \"approved\" or \"rejected\".")),
mcp.WithString("reason", mcp.Required(), mcp.Description("Non-empty explanation for the verdict. Recorded in the task timeline.")),
),
s.wrapHandler("record_step_decision_kandev", s.recordStepDecisionHandler()),
)
}
func (s *Server) recordStepDecisionHandler() server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.taskID == "" || s.sessionID == "" {
return mcp.NewToolResultError("record_step_decision_kandev requires a bound task and session"), nil
}
decision := strings.TrimSpace(req.GetString("decision", ""))
reason := strings.TrimSpace(req.GetString("reason", ""))
if reason == "" {
return mcp.NewToolResultError("reason is required"), nil
}
payload := map[string]interface{}{
"task_id": s.taskID,
"session_id": s.sessionID,
"decision": decision,
"reason": reason,
}
var result map[string]interface{}
if err := s.backend.RequestPayload(ctx, ws.ActionMCPRecordStepDecision, payload, &result); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
data, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(data)), nil
}
}
@@ -0,0 +1,88 @@
package mcp
import (
"testing"
ws "github.com/kandev/kandev/pkg/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// This file covers the record_step_decision_kandev tool-registration/dispatch
// half of the MCP transport (registerRecordStepDecisionTool /
// recordStepDecisionHandler). Review round 1 flagged this file at zero
// coverage. The WS-handler half (handleRecordStepDecision,
// mapRecordStepDecisionError) is covered separately in
// internal/mcp/handlers/agent_decision_handlers_test.go; these tests instead
// exercise what's unique to this transport boundary: the bound-session guard,
// trimming/validation before dispatch, the exact payload shape sent to
// s.backend.RequestPayload, and surfacing a backend error as a tool error.
func newOfficeModeServer(t *testing.T, backend BackendClient, sessionID, taskID string) *Server {
t.Helper()
return New(backend, sessionID, taskID, 10005, newTestLogger(t), "", false, ModeOffice)
}
func TestRecordStepDecision_RequiresBoundTaskAndSession(t *testing.T) {
backend := &testBackend{}
s := newOfficeModeServer(t, backend, "", "")
result := callTool(t, s, "record_step_decision_kandev", map[string]interface{}{
"decision": "approved",
"reason": "looks good",
})
require.True(t, result.IsError, "unbound task/session must be rejected")
assert.Empty(t, backend.lastAction, "backend must not be called without a bound session")
}
func TestRecordStepDecision_RejectsEmptyReason(t *testing.T) {
backend := &testBackend{}
s := newOfficeModeServer(t, backend, "test-session", "task-current")
result := callTool(t, s, "record_step_decision_kandev", map[string]interface{}{
"decision": "approved",
"reason": " ",
})
require.True(t, result.IsError, "whitespace-only reason must be rejected")
assert.Empty(t, backend.lastAction, "backend must not be called with an empty reason")
}
func TestRecordStepDecision_SendsExpectedPayload(t *testing.T) {
backend := &testBackend{
response: map[string]interface{}{
"decision": "approved",
"role": "reviewer",
"step_id": "step-1",
},
}
s := newOfficeModeServer(t, backend, "test-session", "task-current")
result := callTool(t, s, "record_step_decision_kandev", map[string]interface{}{
"decision": "approved",
"reason": " looks good ",
})
require.False(t, result.IsError)
assert.Equal(t, ws.ActionMCPRecordStepDecision, backend.lastAction)
payload, ok := backend.lastPayload.(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "task-current", payload["task_id"])
assert.Equal(t, "test-session", payload["session_id"])
assert.Equal(t, "approved", payload["decision"])
assert.Equal(t, "looks good", payload["reason"], "reason should be trimmed before dispatch")
}
func TestRecordStepDecision_SurfacesBackendErrorAsToolError(t *testing.T) {
backend := &testBackend{err: assert.AnError}
s := newOfficeModeServer(t, backend, "test-session", "task-current")
result := callTool(t, s, "record_step_decision_kandev", map[string]interface{}{
"decision": "rejected",
"reason": "needs work",
})
require.True(t, result.IsError, "a backend error must surface as a tool error, not panic or succeed")
}
@@ -914,6 +914,7 @@ func (s *Server) profileToolGroups() []profileToolGroup {
{name: "review", enabled: kanban, register: func(s *Server) { s.registerReviewTools() }},
{name: "related-tasks", enabled: func(ctx mcpprofile.Context) bool { return kanban(ctx) || office(ctx) }, register: func(s *Server) { s.registerRelatedTasksTool() }},
{name: "office-documents", enabled: office, register: func(s *Server) { s.registerTaskDocumentTools() }},
{name: "office-decisions", enabled: office, register: func(s *Server) { s.registerRecordStepDecisionTool() }},
{name: "task-branch-sources", enabled: kanban, register: func(s *Server) {
s.registerAddBranchToTaskTool()
s.registerAddWorkspaceSourcesTool()
@@ -996,10 +996,10 @@ func TestServerModeOffice_ToolCount(t *testing.T) {
s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeOffice)
tools := getRegisteredToolNames(s)
// 4 plan + 1 interaction + 1 related-tasks + 3 task-documents + 1 rich-output = 10
// 4 plan + 1 interaction + 1 related-tasks + 3 task-documents + 1 rich-output + 1 decisions = 11
// (delegate_task_kandev retired in favour of `agentctl kandev task create …`).
assert.NotContains(t, tools, "step_complete_kandev", "step_complete_kandev is kanban-task-only; office mode advances tasks via its own approval surface")
assert.Equal(t, 10, len(tools))
assert.Equal(t, 11, len(tools))
}
func TestServerModeOffice_DisableAskQuestion(t *testing.T) {
@@ -1016,8 +1016,9 @@ func TestServerModeOffice_DisableAskQuestion(t *testing.T) {
// delegate_task_kandev was retired from ModeOffice (now lives in
// the agentctl CLI as `agentctl kandev task create --parent …`).
assert.NotContains(t, tools, "delegate_task_kandev")
// 4 plan + 1 related-tasks + 3 task-documents + 1 rich-output = 9 (no ask_user_question, no delegate)
assert.Equal(t, 9, len(tools))
// 4 plan + 1 related-tasks + 3 task-documents + 1 rich-output + 1 decisions = 10
// (no ask_user_question, no delegate)
assert.Equal(t, 10, len(tools))
}
func TestServerModeConstants(t *testing.T) {
@@ -0,0 +1,207 @@
package dashboard
import (
"context"
"errors"
"fmt"
"strings"
"time"
officeenginedispatcher "github.com/kandev/kandev/internal/office/engine_dispatcher"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/shared"
"github.com/kandev/kandev/internal/workflow/engine"
workflowmodels "github.com/kandev/kandev/internal/workflow/models"
)
const agentDecisionReasonRequiredErr = "reason is required"
// RecordAgentDecisionInput bundles the agent decision tool's caller-supplied
// fields. Per the tool contract, task/step/role are never caller-supplied —
// they are resolved server-side from the calling session and the AC-50
// slate.
type RecordAgentDecisionInput struct {
TaskID string
AgentProfileID string
Decision string
Reason string
}
// AgentDecisionValidationError marks an error caused by a caller-provided
// decision field or a task precondition that the caller can correct. The MCP
// transport uses this type to keep unexpected repository and engine failures
// opaque to agents.
type AgentDecisionValidationError struct {
Err error
}
func (e *AgentDecisionValidationError) Error() string {
if e == nil || e.Err == nil {
return "invalid agent decision"
}
return e.Err.Error()
}
func (e *AgentDecisionValidationError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
// IsAgentDecisionValidationError reports whether err is safe to expose as a
// validation response at the MCP boundary.
func IsAgentDecisionValidationError(err error) bool {
var target *AgentDecisionValidationError
return errors.As(err, &target)
}
func agentDecisionValidation(err error) error {
return &AgentDecisionValidationError{Err: err}
}
// RecordAgentDecisionResult is the AC-64 tool-contract return shape.
type RecordAgentDecisionResult struct {
Decision string
Role string
StepID string
DecisionID string
DecidedAt time.Time
TransitionApplied bool
Guards []GuardStateDTO
}
// roleResolvingDispatcher is the AC-2/3/4/4a additive capability this
// function needs from s.engineDispatcher. Named locally and reached via a
// type assertion rather than widening shared.WorkflowEngineDispatcher,
// mirroring decisionRecordingDispatcher above.
type roleResolvingDispatcher interface {
ResolveParticipantRole(ctx context.Context, taskID, stepID, agentProfileID string) (role, participantID string, err error)
}
// quorumEvaluatingDispatcher is the AC-57d additive capability used to
// build the AC-64 guards field after a successful write.
type quorumEvaluatingDispatcher interface {
EvaluateStepQuorum(ctx context.Context, taskID string) (engine.QuorumSnapshot, error)
}
// RecordAgentDecision is the agent/MCP counterpart to ApproveTask /
// RequestTaskChanges (`record_step_decision_kandev`). Unlike the human
// path — whose resolveDeciderRole/resolveParticipantID stay unchanged and
// step-scoped per AC-57b — this path resolves role and seat over the
// AC-50 population via the engine's ResolveParticipantRole (AC-4a): a new
// capability, not a rewiring of the human path's resolution.
//
// Validates preconditions in the AC-55 order: (1) bound workflow_step_id
// (AC-7), (2) participation (AC-3), (3) verdict validity (AC-5), (4)
// non-empty reason (AC-6).
func (s *DashboardService) RecordAgentDecision(
ctx context.Context, in RecordAgentDecisionInput,
) (*RecordAgentDecisionResult, error) {
if s.decisions == nil {
return nil, fmt.Errorf("%s", decisionStoreNotWiredErr)
}
dispatcher, ok := s.engineDispatcher.(decisionRecordingDispatcher)
if !ok {
return nil, fmt.Errorf("%s", decisionStoreNotWiredErr)
}
roleDispatcher, ok := s.engineDispatcher.(roleResolvingDispatcher)
if !ok {
return nil, fmt.Errorf("%s", decisionStoreNotWiredErr)
}
// AC-55(1)/AC-7.
stepID, err := s.repo.GetTaskWorkflowStepID(ctx, in.TaskID)
if err != nil {
return nil, fmt.Errorf("resolve task workflow_step_id: %w", err)
}
if stepID == "" {
return nil, agentDecisionValidation(fmt.Errorf("task %s has no workflow step bound", in.TaskID))
}
// AC-55(2)/AC-3/AC-4/AC-4a.
role, participantID, err := roleDispatcher.ResolveParticipantRole(ctx, in.TaskID, stepID, in.AgentProfileID)
if err != nil {
if errors.Is(err, engine.ErrParticipantNotFound) {
return nil, shared.ErrForbidden
}
return nil, fmt.Errorf("resolve participant role: %w", err)
}
// AC-55(3)/AC-5.
if in.Decision != engine.DecisionApproved && in.Decision != engine.DecisionRejected {
return nil, agentDecisionValidation(fmt.Errorf("invalid decision: %q", in.Decision))
}
// AC-55(4)/AC-6.
if strings.TrimSpace(in.Reason) == "" {
return nil, agentDecisionValidation(fmt.Errorf("%s", agentDecisionReasonRequiredErr))
}
result, err := dispatcher.RecordDecision(ctx, officeenginedispatcher.RecordDecisionInput{
TaskID: in.TaskID,
StepID: stepID,
ParticipantID: participantID,
Decision: in.Decision,
DeciderType: models.DeciderTypeAgent,
DeciderID: in.AgentProfileID,
Role: role,
Comment: in.Reason,
})
if err != nil {
return nil, fmt.Errorf("record decision: %w", err)
}
rec := fromWorkflowDecision(&workflowmodels.WorkflowStepDecision{
ID: result.DecisionID,
TaskID: in.TaskID,
StepID: result.StepID,
ParticipantID: participantID,
Decision: in.Decision,
DecidedAt: result.DecidedAt,
DeciderType: models.DeciderTypeAgent,
DeciderID: in.AgentProfileID,
Role: role,
Comment: in.Reason,
})
s.publishDecisionRecorded(ctx, rec)
s.logDecisionActivity(ctx, rec)
s.runReactivityForDecision(ctx, rec)
out := &RecordAgentDecisionResult{
Decision: in.Decision,
Role: role,
StepID: result.StepID,
DecisionID: result.DecisionID,
DecidedAt: result.DecidedAt,
TransitionApplied: result.Transitioned,
Guards: s.agentDecisionGuardsSnapshot(ctx, in.TaskID, result),
}
return out, nil
}
// agentDecisionGuardsSnapshot builds the AC-64 guards field after a
// successful write. A transition result already carries the engine's
// pre-transition snapshot, so it must not issue a second read after the task
// has moved to another step. For non-transition results, the read remains a
// best-effort diagnostic and is discarded if the task moved concurrently.
func (s *DashboardService) agentDecisionGuardsSnapshot(
ctx context.Context, taskID string, result officeenginedispatcher.RecordDecisionResult,
) []GuardStateDTO {
guards := []GuardStateDTO{}
if result.Transitioned {
return guardStateDTOsFromSnapshot(result.Guards)
}
qd, ok := s.engineDispatcher.(quorumEvaluatingDispatcher)
if !ok {
return guards
}
// AC-15/AC-64: a failed snapshot read or an AC-37 step mismatch reports
// guards:[]/transition_applied:false (already the zero state here)
// rather than an error — the write already succeeded and a diagnostic
// read must not put it at risk.
snapshot, snapErr := qd.EvaluateStepQuorum(ctx, taskID)
if snapErr != nil || snapshot.StepID != result.StepID {
return guards
}
return guardStateDTOsFromSnapshot(snapshot.Guards)
}
@@ -0,0 +1,233 @@
package dashboard_test
import (
"context"
"errors"
"testing"
"github.com/kandev/kandev/internal/office/dashboard"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/shared"
"github.com/kandev/kandev/internal/workflow/engine"
)
// insertTestTaskNoStep inserts a task row with no workflow_step_id bound
// (the schema default is ” — see tasks.workflow_step_id NOT NULL DEFAULT
// ”), exercising the AC-7/AC-55(1) precondition RecordAgentDecision must
// reject before any role resolution or write is attempted.
func insertTestTaskNoStep(t *testing.T, deps *testDeps, id, wsID, title string) {
t.Helper()
_, err := deps.db.Exec(`
INSERT INTO tasks (id, workspace_id, title, state, priority, identifier, created_at, updated_at)
VALUES (?, ?, ?, 'in_review', 'medium', ?, datetime('now'), datetime('now'))
`, id, wsID, title, id)
if err != nil {
t.Fatalf("insert task %s: %v", id, err)
}
}
// TestRecordAgentDecision_ApproverHappyPath is AC-1/2/6/7/8: an agent
// occupying an approver seat can record a decision, and the result mirrors
// the engine's write plus the AC-64 default no-transition/no-guards shape
// this test fixture's session-less dispatcher always produces (per
// engine_test_helpers_test.go: fakeSessionResolver reports no active
// session, so RecordParticipantDecision's re-evaluation subpath — and thus
// any transition — never runs).
func TestRecordAgentDecision_ApproverHappyPath(t *testing.T) {
deps := newTestDeps(t)
insertTestTask(t, deps.db, "ad1", "ws-d", "AD", "in_review", 2)
mustAddParticipant(t, deps, "ad1", "agent-1", models.ParticipantRoleApprover)
res, err := deps.svc.RecordAgentDecision(context.Background(), dashboard.RecordAgentDecisionInput{
TaskID: "ad1",
AgentProfileID: "agent-1",
Decision: engine.DecisionApproved,
Reason: "lgtm",
})
if err != nil {
t.Fatalf("RecordAgentDecision: %v", err)
}
if res.Role != models.ParticipantRoleApprover {
t.Errorf("Role = %q, want %q", res.Role, models.ParticipantRoleApprover)
}
if res.Decision != engine.DecisionApproved {
t.Errorf("Decision = %q, want %q", res.Decision, engine.DecisionApproved)
}
if res.StepID != "step-ad1" {
t.Errorf("StepID = %q, want step-ad1", res.StepID)
}
if res.DecisionID == "" {
t.Error("DecisionID is empty")
}
if res.TransitionApplied {
t.Error("TransitionApplied = true, want false (no active session in this fixture)")
}
if len(res.Guards) != 0 {
t.Errorf("Guards = %+v, want empty", res.Guards)
}
rows, err := deps.svc.ListTaskDecisions(context.Background(), "ad1")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rows) != 1 {
t.Fatalf("decisions = %d, want 1", len(rows))
}
if rows[0].DeciderType != models.DeciderTypeAgent || rows[0].DeciderID != "agent-1" {
t.Errorf("decider = %s/%s, want agent/agent-1", rows[0].DeciderType, rows[0].DeciderID)
}
}
// TestRecordAgentDecision_ApproverWinsOverReviewer is AC-4: when an agent
// occupies both seats at the same step, the decision is recorded under the
// approver role.
func TestRecordAgentDecision_ApproverWinsOverReviewer(t *testing.T) {
deps := newTestDeps(t)
insertTestTask(t, deps.db, "ad2", "ws-d", "AD2", "in_review", 2)
mustAddParticipant(t, deps, "ad2", "agent-X", models.ParticipantRoleReviewer)
mustAddParticipant(t, deps, "ad2", "agent-X", models.ParticipantRoleApprover)
res, err := deps.svc.RecordAgentDecision(context.Background(), dashboard.RecordAgentDecisionInput{
TaskID: "ad2",
AgentProfileID: "agent-X",
Decision: engine.DecisionApproved,
Reason: "lgtm",
})
if err != nil {
t.Fatalf("RecordAgentDecision: %v", err)
}
if res.Role != models.ParticipantRoleApprover {
t.Errorf("Role = %q, want %q (approver-wins)", res.Role, models.ParticipantRoleApprover)
}
}
// TestRecordAgentDecision_ReviewerSeat is AC-2/3: an agent occupying only
// a reviewer seat records under the reviewer role.
func TestRecordAgentDecision_ReviewerSeat(t *testing.T) {
deps := newTestDeps(t)
insertTestTask(t, deps.db, "ad3", "ws-d", "AD3", "in_review", 2)
mustAddParticipant(t, deps, "ad3", "agent-2", models.ParticipantRoleReviewer)
res, err := deps.svc.RecordAgentDecision(context.Background(), dashboard.RecordAgentDecisionInput{
TaskID: "ad3",
AgentProfileID: "agent-2",
Decision: engine.DecisionRejected,
Reason: "needs work",
})
if err != nil {
t.Fatalf("RecordAgentDecision: %v", err)
}
if res.Role != models.ParticipantRoleReviewer {
t.Errorf("Role = %q, want %q", res.Role, models.ParticipantRoleReviewer)
}
}
// TestRecordAgentDecision_ForbiddenWhenNotParticipant is AC-3: an agent
// occupying neither seat is rejected with shared.ErrForbidden, and no row
// is written.
func TestRecordAgentDecision_ForbiddenWhenNotParticipant(t *testing.T) {
deps := newTestDeps(t)
insertTestTask(t, deps.db, "ad4", "ws-d", "AD4", "in_review", 2)
_, err := deps.svc.RecordAgentDecision(context.Background(), dashboard.RecordAgentDecisionInput{
TaskID: "ad4",
AgentProfileID: "agent-zzz",
Decision: engine.DecisionApproved,
Reason: "lgtm",
})
if !errors.Is(err, shared.ErrForbidden) {
t.Fatalf("err = %v, want ErrForbidden", err)
}
rows, err := deps.svc.ListTaskDecisions(context.Background(), "ad4")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rows) != 0 {
t.Fatalf("decisions = %d, want 0", len(rows))
}
}
// TestRecordAgentDecision_NoWorkflowStepBoundRejected is AC-7/AC-55(1):
// a task with no workflow_step_id bound is rejected before any role
// resolution is attempted.
func TestRecordAgentDecision_NoWorkflowStepBoundRejected(t *testing.T) {
deps := newTestDeps(t)
insertTestTaskNoStep(t, deps, "ad5", "ws-d", "AD5")
_, err := deps.svc.RecordAgentDecision(context.Background(), dashboard.RecordAgentDecisionInput{
TaskID: "ad5",
AgentProfileID: "agent-1",
Decision: engine.DecisionApproved,
Reason: "lgtm",
})
if err == nil {
t.Fatal("expected error for task with no workflow step bound")
}
}
// TestRecordAgentDecision_InvalidVerdictRejected is AC-5/AC-55(3): a
// decision must resolve to a validated seat before its verdict is checked,
// but an unrecognized verdict is still rejected and no row is written.
func TestRecordAgentDecision_InvalidVerdictRejected(t *testing.T) {
deps := newTestDeps(t)
insertTestTask(t, deps.db, "ad6", "ws-d", "AD6", "in_review", 2)
mustAddParticipant(t, deps, "ad6", "agent-1", models.ParticipantRoleApprover)
_, err := deps.svc.RecordAgentDecision(context.Background(), dashboard.RecordAgentDecisionInput{
TaskID: "ad6",
AgentProfileID: "agent-1",
Decision: "bogus",
Reason: "lgtm",
})
if err == nil {
t.Fatal("expected error for invalid decision verdict")
}
rows, err := deps.svc.ListTaskDecisions(context.Background(), "ad6")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rows) != 0 {
t.Fatalf("decisions = %d, want 0", len(rows))
}
}
// TestRecordAgentDecision_EmptyReasonRejected is AC-6/AC-55(4): a blank
// reason is rejected regardless of verdict validity.
func TestRecordAgentDecision_EmptyReasonRejected(t *testing.T) {
deps := newTestDeps(t)
insertTestTask(t, deps.db, "ad7", "ws-d", "AD7", "in_review", 2)
mustAddParticipant(t, deps, "ad7", "agent-1", models.ParticipantRoleApprover)
_, err := deps.svc.RecordAgentDecision(context.Background(), dashboard.RecordAgentDecisionInput{
TaskID: "ad7",
AgentProfileID: "agent-1",
Decision: engine.DecisionApproved,
Reason: " ",
})
if err == nil {
t.Fatal("expected error for blank reason")
}
}
// TestRecordAgentDecision_RejectsWhenEngineDispatcherNotWired is AC-57c's
// agent-path counterpart to TestApproveTask_RejectsWhenEngineDispatcherNotWired:
// with no engine dispatcher wired, the call is rejected and no row is
// written — never a fallback to a second, office-side write.
func TestRecordAgentDecision_RejectsWhenEngineDispatcherNotWired(t *testing.T) {
deps := newTestDeps(t)
deps.svc.SetWorkflowEngineDispatcher(nil)
insertTestTask(t, deps.db, "ad8", "ws-d", "AD8", "in_review", 2)
mustAddParticipant(t, deps, "ad8", "agent-1", models.ParticipantRoleApprover)
_, err := deps.svc.RecordAgentDecision(context.Background(), dashboard.RecordAgentDecisionInput{
TaskID: "ad8",
AgentProfileID: "agent-1",
Decision: engine.DecisionApproved,
Reason: "lgtm",
})
if err == nil {
t.Fatal("expected error when engine dispatcher is not wired")
}
}
@@ -7,11 +7,11 @@ import (
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
officeenginedispatcher "github.com/kandev/kandev/internal/office/engine_dispatcher"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/repository/sqlite"
"github.com/kandev/kandev/internal/office/shared"
@@ -220,10 +220,30 @@ type decisionInput struct {
decision string
}
// decisionRecordingDispatcher is the AC-57a additive capability this
// function needs from s.engineDispatcher. Named locally and reached via a
// type assertion rather than widening shared.WorkflowEngineDispatcher,
// mirroring the handledWorkflowEngineDispatcher precedent
// (service_tasks.go) — see AC-57d's implementation note.
type decisionRecordingDispatcher interface {
RecordDecision(
ctx context.Context, in officeenginedispatcher.RecordDecisionInput,
) (officeenginedispatcher.RecordDecisionResult, error)
}
// recordTaskDecision performs the shared body of ApproveTask /
// RequestTaskChanges: validates inputs, resolves the caller's role,
// persists the decision, fires events + activity, and queues
// persists the decision and re-evaluates the guarded transition through
// the engine's AC-57a entry point, fires events + activity, and queues
// reactivity runs via the configured queuer.
//
// AC-57b confines this repoint to the resolve/persist/re-evaluate core:
// resolveDeciderRole and resolveParticipantID are unchanged, and every
// downstream side-effect (publishDecisionRecorded, logDecisionActivity,
// runReactivityForDecision, the returned DecisionRecord shape) is
// preserved. AC-57c: when the engine entry point isn't wired, this SHALL
// reject the call rather than falling back to a second, office-side
// implementation of the write.
func (s *DashboardService) recordTaskDecision(
ctx context.Context, in decisionInput,
) (*DecisionRecord, error) {
@@ -233,6 +253,10 @@ func (s *DashboardService) recordTaskDecision(
if s.decisions == nil {
return nil, fmt.Errorf("%s", decisionStoreNotWiredErr)
}
dispatcher, ok := s.engineDispatcher.(decisionRecordingDispatcher)
if !ok {
return nil, fmt.Errorf("%s", decisionStoreNotWiredErr)
}
role, err := s.resolveDeciderRole(ctx, in.callerType, in.callerID, in.taskID)
if err != nil {
return nil, err
@@ -246,22 +270,37 @@ func (s *DashboardService) recordTaskDecision(
}
participantID := s.resolveParticipantID(ctx, stepID, in.taskID, role, in.callerType, in.callerID)
row := &workflowmodels.WorkflowStepDecision{
ID: uuid.New().String(),
result, err := dispatcher.RecordDecision(ctx, officeenginedispatcher.RecordDecisionInput{
TaskID: in.taskID,
StepID: stepID,
ParticipantID: participantID,
Decision: in.decision,
DecidedAt: time.Now().UTC(),
DeciderType: in.callerType,
DeciderID: in.callerID,
Role: role,
Comment: in.comment,
}
if err := s.decisions.RecordStepDecision(ctx, row); err != nil {
})
if err != nil {
return nil, fmt.Errorf("record decision: %w", err)
}
// AC-57b-i: the engine stamped decision_id/decided_at once at write
// time; they are echoed back here, never re-derived. result.StepID is
// the AC-37 validated step, which is stepID unless the task moved
// between validation and write.
row := &workflowmodels.WorkflowStepDecision{
ID: result.DecisionID,
TaskID: in.taskID,
StepID: result.StepID,
ParticipantID: participantID,
Decision: in.decision,
DecidedAt: result.DecidedAt,
DeciderType: in.callerType,
DeciderID: in.callerID,
Role: role,
Comment: in.comment,
}
rec := fromWorkflowDecision(row)
s.publishDecisionRecorded(ctx, rec)
s.logDecisionActivity(ctx, rec)
@@ -585,3 +585,51 @@ func readTaskState(t *testing.T, deps *testDeps, taskID string) string {
}
return state
}
// TestApproveTask_RejectsWhenEngineDispatcherNotWired is AC-57c: when the
// engine decision entry point isn't wired, recordTaskDecision must reject
// the call and write no row — never fall back to a second, office-side
// implementation of the decision write.
func TestApproveTask_RejectsWhenEngineDispatcherNotWired(t *testing.T) {
deps := newTestDeps(t)
deps.svc.SetWorkflowEngineDispatcher(nil)
insertTestTask(t, deps.db, "nw1", "ws-d", "NW", "in_review", 2)
mustAddParticipant(t, deps, "nw1", "agent-1", models.ParticipantRoleApprover)
_, err := deps.svc.ApproveTask(context.Background(),
models.DeciderTypeAgent, "agent-1", "nw1", "lgtm")
if err == nil {
t.Fatal("expected error when engine dispatcher is not wired")
}
rows, err := deps.svc.ListTaskDecisions(context.Background(), "nw1")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rows) != 0 {
t.Fatalf("active decisions = %d, want 0 (no row written)", len(rows))
}
}
// TestRequestTaskChanges_RejectsWhenEngineDispatcherNotWired is the
// RequestTaskChanges half of AC-57c, mirroring the ApproveTask case above.
func TestRequestTaskChanges_RejectsWhenEngineDispatcherNotWired(t *testing.T) {
deps := newTestDeps(t)
deps.svc.SetWorkflowEngineDispatcher(nil)
insertTestTask(t, deps.db, "nw2", "ws-d", "NW2", "in_review", 2)
mustAddParticipant(t, deps, "nw2", "agent-1", models.ParticipantRoleApprover)
_, err := deps.svc.RequestTaskChanges(context.Background(),
models.DeciderTypeAgent, "agent-1", "nw2", "fix it")
if err == nil {
t.Fatal("expected error when engine dispatcher is not wired")
}
rows, err := deps.svc.ListTaskDecisions(context.Background(), "nw2")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rows) != 0 {
t.Fatalf("active decisions = %d, want 0 (no row written)", len(rows))
}
}
@@ -0,0 +1,266 @@
package dashboard_test
import (
"context"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/common/logger"
officeenginedispatcher "github.com/kandev/kandev/internal/office/engine_dispatcher"
taskmodels "github.com/kandev/kandev/internal/task/models"
workflowadapters "github.com/kandev/kandev/internal/workflow/adapters"
"github.com/kandev/kandev/internal/workflow/engine"
workflowrepo "github.com/kandev/kandev/internal/workflow/repository"
)
// newTestEngineDispatcher wires a real workflow engine (AC-57: the slate
// construction/decision-write logic lives only in the engine, never
// reimplemented at the office tier, not even in tests) backed by the same
// workflow repository the dashboard tests already use for SetDecisionStore.
//
// fakeSessionResolver always reports "no active session" (AC-16a), since
// none of these tests insert a task_sessions row. That means
// RecordParticipantDecision still stamps and writes the decision through
// the real engine/store, but skips the AC-13-17 re-evaluation subpath — the
// same outcome a genuinely session-less task gets in production. The
// TransitionStore that subpath would need is therefore never invoked; the
// no-op below exists only to satisfy engine.New's signature.
func newTestEngineDispatcher(wfRepo *workflowrepo.Repository, log *logger.Logger) *officeenginedispatcher.Dispatcher {
eng := engine.New(
noopTransitionStore{},
noopCallbackRegistry{},
engine.WithParticipantStore(workflowadapters.NewParticipantAdapter(wfRepo)),
engine.WithDecisionStore(workflowadapters.NewDecisionAdapter(wfRepo)),
)
return officeenginedispatcher.New(eng, fakeSessionResolver{}, log)
}
// fakeSessionResolver satisfies officeenginedispatcher.SessionResolver.
// Always reports no session, matching every dashboard test's task_sessions
// table, which is empty by default.
type fakeSessionResolver struct{}
func (fakeSessionResolver) GetActiveTaskSessionByTaskID(
_ context.Context, _ string,
) (*taskmodels.TaskSession, error) {
return nil, taskmodels.ErrTaskSessionNotFound
}
func (fakeSessionResolver) GetTaskSessionByTaskID(
_ context.Context, _ string,
) (*taskmodels.TaskSession, error) {
return nil, taskmodels.ErrTaskSessionNotFound
}
// noopCallbackRegistry satisfies engine.CallbackRegistry. Never consulted by
// RecordParticipantDecision/EvaluateStepQuorum (only HandleTrigger's action
// evaluation path uses it), so no test wires a real callback here.
type noopCallbackRegistry struct{}
func (noopCallbackRegistry) Get(_ engine.ActionKind) (engine.ActionCallback, bool) {
return nil, false
}
// noopTransitionStore satisfies engine.TransitionStore. With
// fakeSessionResolver always reporting no active session,
// RecordParticipantDecision never reaches the re-evaluation subpath that
// would call this store, so every method here is unreachable in practice —
// it exists only to satisfy engine.New's required TransitionStore argument.
type noopTransitionStore struct{}
func (noopTransitionStore) LoadState(
_ context.Context, _, _ string,
) (engine.MachineState, error) {
return engine.MachineState{}, nil
}
func (noopTransitionStore) LoadStep(
_ context.Context, _, _ string,
) (engine.StepSpec, error) {
return engine.StepSpec{}, nil
}
func (noopTransitionStore) LoadNextStep(
_ context.Context, _ string, _ int,
) (engine.StepSpec, error) {
return engine.StepSpec{}, nil
}
func (noopTransitionStore) LoadPreviousStep(
_ context.Context, _ string, _ int,
) (engine.StepSpec, error) {
return engine.StepSpec{}, nil
}
func (noopTransitionStore) ApplyTransition(
_ context.Context, _, _, _, _ string, _ engine.Trigger,
) error {
return nil
}
func (noopTransitionStore) ApplyTransitionIfAtStep(
_ context.Context, _, _, _, _ string, _ engine.Trigger,
) (bool, error) {
return false, nil
}
func (noopTransitionStore) PersistData(
_ context.Context, _ string, _ map[string]any,
) error {
return nil
}
func (noopTransitionStore) IsOperationApplied(
_ context.Context, _ string,
) (bool, error) {
return false, nil
}
func (noopTransitionStore) MarkOperationApplied(_ context.Context, _ string) error {
return nil
}
// dashboardTransitionStore is a real (non-noop) engine.TransitionStore
// backed by the same "tasks" table newTestDeps seeds. Unlike
// noopTransitionStore, ApplyTransitionIfAtStep here genuinely performs the
// AC-46/48 compare-and-swap against the tasks.workflow_step_id column, so a
// test using this store can observe a guarded transition actually moving a
// task off its current step through the same column the rest of the office
// tier (GetTaskWorkflowStepID, the quorum badge, task detail rendering)
// reads. Exists to unlock the AC-63/10 re-evaluation subpath that
// fakeSessionResolver/noopTransitionStore deliberately never reach.
type dashboardTransitionStore struct {
db *sqlx.DB
workflowID string
steps map[string]engine.StepSpec
applied map[string]bool
}
func newDashboardTransitionStore(db *sqlx.DB) *dashboardTransitionStore {
return &dashboardTransitionStore{
db: db,
workflowID: "wf-test",
steps: map[string]engine.StepSpec{},
applied: map[string]bool{},
}
}
func (s *dashboardTransitionStore) LoadState(
ctx context.Context, taskID, sessionID string,
) (engine.MachineState, error) {
var stepID string
if err := s.db.QueryRowContext(ctx,
`SELECT workflow_step_id FROM tasks WHERE id = ?`, taskID,
).Scan(&stepID); err != nil {
return engine.MachineState{}, err
}
return engine.MachineState{
TaskID: taskID, SessionID: sessionID,
WorkflowID: s.workflowID, CurrentStepID: stepID,
}, nil
}
func (s *dashboardTransitionStore) LoadStep(
_ context.Context, _, stepID string,
) (engine.StepSpec, error) {
return s.steps[stepID], nil
}
func (s *dashboardTransitionStore) LoadNextStep(
_ context.Context, _ string, _ int,
) (engine.StepSpec, error) {
return engine.StepSpec{}, nil
}
func (s *dashboardTransitionStore) LoadPreviousStep(
_ context.Context, _ string, _ int,
) (engine.StepSpec, error) {
return engine.StepSpec{}, nil
}
func (s *dashboardTransitionStore) ApplyTransition(
ctx context.Context, taskID, _, _, toStepID string, _ engine.Trigger,
) error {
_, err := s.db.ExecContext(ctx,
`UPDATE tasks SET workflow_step_id = ? WHERE id = ?`, toStepID, taskID)
return err
}
// ApplyTransitionIfAtStep is the genuine CAS this store exists to provide:
// the UPDATE only lands when the task is still at expectedStepID, mirroring
// the production repository's guard against a concurrent mover.
func (s *dashboardTransitionStore) ApplyTransitionIfAtStep(
ctx context.Context, taskID, _, expectedStepID, toStepID string, _ engine.Trigger,
) (bool, error) {
res, err := s.db.ExecContext(ctx,
`UPDATE tasks SET workflow_step_id = ? WHERE id = ? AND workflow_step_id = ?`,
toStepID, taskID, expectedStepID)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
if err != nil {
return false, err
}
return n > 0, nil
}
func (s *dashboardTransitionStore) PersistData(
_ context.Context, _ string, _ map[string]any,
) error {
return nil
}
func (s *dashboardTransitionStore) IsOperationApplied(
_ context.Context, op string,
) (bool, error) {
return s.applied[op], nil
}
func (s *dashboardTransitionStore) MarkOperationApplied(_ context.Context, op string) error {
s.applied[op] = true
return nil
}
// singleTaskSessionResolver reports an active session for exactly one
// configured task id and "no session" for every other task, letting a test
// drive RecordParticipantDecision's re-evaluation subpath for that one task
// without disturbing every other dashboard test's session-less assumption.
type singleTaskSessionResolver struct {
taskID string
session *taskmodels.TaskSession
}
func (r singleTaskSessionResolver) GetActiveTaskSessionByTaskID(
_ context.Context, taskID string,
) (*taskmodels.TaskSession, error) {
if taskID == r.taskID {
return r.session, nil
}
return nil, taskmodels.ErrTaskSessionNotFound
}
func (r singleTaskSessionResolver) GetTaskSessionByTaskID(
ctx context.Context, taskID string,
) (*taskmodels.TaskSession, error) {
return r.GetActiveTaskSessionByTaskID(ctx, taskID)
}
// newTestEngineDispatcherWithReevaluation is like newTestEngineDispatcher but
// wires a real TransitionStore and a session resolver that reports an
// active session for taskID, unlocking the AC-13-17/AC-65 re-evaluation
// subpath — the AC-63/10 "human veto actually unsticks a reviewer-guarded
// step" path newTestEngineDispatcher's noop/no-session combination cannot
// reach.
func newTestEngineDispatcherWithReevaluation(
wfRepo *workflowrepo.Repository, log *logger.Logger,
store *dashboardTransitionStore, taskID string, session *taskmodels.TaskSession,
) *officeenginedispatcher.Dispatcher {
eng := engine.New(
store,
noopCallbackRegistry{},
engine.WithParticipantStore(workflowadapters.NewParticipantAdapter(wfRepo)),
engine.WithDecisionStore(workflowadapters.NewDecisionAdapter(wfRepo)),
)
return officeenginedispatcher.New(eng, singleTaskSessionResolver{taskID: taskID, session: session}, log)
}
@@ -91,6 +91,7 @@ func RegisterRoutes(api *gin.RouterGroup, svc *DashboardService, labelRepo label
api.POST("/tasks/:id/approve", h.approveTask)
api.POST("/tasks/:id/request-changes", h.requestTaskChanges)
api.GET("/tasks/:id/decisions", h.listTaskDecisions)
api.GET("/workspaces/:wsId/tasks/:taskId/quorum", h.getTaskQuorum)
api.POST("/workspaces/:wsId/git/clone", h.gitClone)
api.POST("/workspaces/:wsId/git/pull", h.gitPull)
api.POST("/workspaces/:wsId/git/push", h.gitPush)
@@ -93,6 +93,12 @@ type testDeps struct {
svc *dashboard.DashboardService
router *gin.Engine
agents *stubAgentReader
// wfRepo is the same workflow repository newTestDeps wires via
// svc.SetDecisionStore, exposed so tests that need to build their own
// engine (e.g. the AC-63/10 re-evaluation test) reuse the identical
// participant/decision adapters rather than opening a second, divergent
// repository instance against the same tables.
wfRepo *workflowrepo.Repository
}
func newTestDeps(t *testing.T) *testDeps {
@@ -228,12 +234,13 @@ func newTestDeps(t *testing.T) *testDeps {
costSvc := &stubCostChecker{}
svc := dashboard.NewDashboardService(repo, log, activity, agentSvc, costSvc)
svc.SetDecisionStore(wfRepo)
svc.SetWorkflowEngineDispatcher(newTestEngineDispatcher(wfRepo, log))
router := gin.New()
group := router.Group("/api/v1/office")
dashboard.RegisterRoutes(group, svc, repo, nil, log)
return &testDeps{db: db, repo: repo, svc: svc, router: router, agents: agentSvc}
return &testDeps{db: db, repo: repo, svc: svc, router: router, agents: agentSvc, wfRepo: wfRepo}
}
// stubAgentReader returns nil/nil by default; tests that need agent
@@ -0,0 +1,50 @@
package dashboard
import "github.com/kandev/kandev/internal/workflow/engine"
// GuardStateDTO is the direct JSON projection of a single AC-57d guard
// entry (`engine.QuorumGuardState`), shared verbatim by the AC-24b HTTP
// quorum endpoint and the AC-64 agent decision tool response so the two
// surfaces can never report different arithmetic for the same step.
type GuardStateDTO struct {
TargetStepID string `json:"target_step_id"`
Role string `json:"role"`
Threshold string `json:"threshold"`
RequiredCount int `json:"required_count"`
ReceivedCount int `json:"received_count"`
Satisfied bool `json:"satisfied"`
// Reason is present if and only if Satisfied is false (AC-24b).
Reason string `json:"reason,omitempty"`
// Error is populated only for the evaluation_error reason.
Error string `json:"error,omitempty"`
}
// QuorumResponseDTO is the AC-24b diagnostic response body for
// GET /workspaces/:wsId/tasks/:taskId/quorum.
type QuorumResponseDTO struct {
Guards []GuardStateDTO `json:"guards"`
ReevaluationBlocked bool `json:"reevaluation_blocked"`
}
// guardStateDTOsFromSnapshot projects an AC-57d snapshot's Guards into the
// AC-24b/AC-64 wire shape, preserving order (AC-57d/AC-61/AC-17). Always
// returns a non-nil slice so callers marshal an empty list, never null.
func guardStateDTOsFromSnapshot(guards []engine.QuorumGuardState) []GuardStateDTO {
out := make([]GuardStateDTO, 0, len(guards))
for _, g := range guards {
dto := GuardStateDTO{
TargetStepID: g.TargetStepID,
Role: g.Role,
Threshold: g.Threshold,
RequiredCount: g.RequiredCount,
ReceivedCount: g.ReceivedCount,
Satisfied: g.Satisfied,
Reason: g.Reason,
}
if g.Error != nil {
dto.Error = g.Error.Error()
}
out = append(out, dto)
}
return out
}
@@ -0,0 +1,39 @@
package dashboard
import (
"net/http"
"github.com/gin-gonic/gin"
)
// getTaskQuorum handles GET /workspaces/:wsId/tasks/:taskId/quorum (AC-24b), a sibling
// of listTaskDecisions carrying the same authorization as that route. Uses the
// :taskId param name (not :id) to match the sibling /workspaces/:wsId/tasks/:taskId/labels
// routes — gin's router requires the same wildcard name at a shared tree position.
// Task-existence is resolved here, before GetTaskQuorum is ever called
// (F37, mirroring getTask) — the dispatcher/engine layer is never asked to
// distinguish "task doesn't exist" from any other case.
func (h *Handler) getTaskQuorum(c *gin.Context) {
ctx := c.Request.Context()
workspaceID := c.Param("wsId")
taskID := c.Param("taskId")
task, err := h.svc.GetTask(ctx, taskID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if task == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if task.WorkspaceID != workspaceID {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
resp, err := h.svc.GetTaskQuorum(ctx, taskID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
@@ -0,0 +1,69 @@
package dashboard_test
import (
"context"
"testing"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/office/models"
taskmodels "github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/workflow/engine"
)
// TestRequestTaskChanges_HumanVetoUnsticksReviewerGuardedStep is the AC-63/10
// end-to-end proof this feature exists for: a human's request-changes veto
// must actually move a reviewer-guarded task off its current step, not just
// record a decision row. Review round 1 flagged that no office-tier test
// could drive this — engine_test_helpers_test.go's fakeSessionResolver
// always reports "no active session," so RecordParticipantDecision's
// re-evaluation subpath (and the TransitionStore it would call) was never
// reached by anything in this package. This test swaps in
// newTestEngineDispatcherWithReevaluation, which wires a real session and a
// real (DB-backed) TransitionStore so the guarded transition can genuinely
// fire.
func TestRequestTaskChanges_HumanVetoUnsticksReviewerGuardedStep(t *testing.T) {
deps := newTestDeps(t)
insertTestTask(t, deps.db, "vt1", "ws-v", "V", "in_review", 2)
const fromStep = "step-vt1"
const toStep = "step-vt1-rework"
store := newDashboardTransitionStore(deps.db)
store.steps[fromStep] = engine.StepSpec{
ID: fromStep, WorkflowID: store.workflowID, Position: 1,
Events: map[engine.Trigger][]engine.Action{
engine.TriggerOnTurnComplete: {
{
Kind: engine.ActionMoveToStep,
Guard: &engine.TransitionGuard{
WaitForQuorum: &engine.WaitForQuorumGuard{
Role: models.ParticipantRoleApprover,
Threshold: engine.QuorumAnyReject,
},
},
MoveToStep: &engine.MoveToStepAction{StepID: toStep},
},
},
},
}
session := &taskmodels.TaskSession{ID: "sess-vt1"}
deps.svc.SetWorkflowEngineDispatcher(
newTestEngineDispatcherWithReevaluation(deps.wfRepo, logger.Default(), store, "vt1", session),
)
_, err := deps.svc.RequestTaskChanges(context.Background(),
models.DeciderTypeUser, "human-1", "vt1", "please fix the approach")
if err != nil {
t.Fatalf("RequestTaskChanges: %v", err)
}
var gotStepID string
if err := deps.db.QueryRow(
`SELECT workflow_step_id FROM tasks WHERE id = ?`, "vt1",
).Scan(&gotStepID); err != nil {
t.Fatalf("read task step: %v", err)
}
if gotStepID != toStep {
t.Fatalf("workflow_step_id = %q, want %q (human veto should have unstuck the reviewer-guarded step)",
gotStepID, toStep)
}
}
@@ -0,0 +1,29 @@
package dashboard
import (
"context"
"fmt"
)
// GetTaskQuorum is the AC-24b/24c/54 read-only diagnostic entry point
// backing GET /workspaces/:wsId/tasks/:taskId/quorum. Per AC-24b, a step with no guarded
// transition returns an empty list, never an error; per AC-24c, so does a
// task with no bound workflow_step_id. Per AC-57c/57d, an engine dispatcher
// that hasn't been wired with quorum-evaluation support is a different case
// entirely: the read side carries the same no-fallback rule as the write
// side (decisionRecordingDispatcher below), and errors rather than
// reporting a healthy empty result.
func (s *DashboardService) GetTaskQuorum(ctx context.Context, taskID string) (QuorumResponseDTO, error) {
qd, ok := s.engineDispatcher.(quorumEvaluatingDispatcher)
if !ok {
return QuorumResponseDTO{}, fmt.Errorf("%s", decisionStoreNotWiredErr)
}
snapshot, err := qd.EvaluateStepQuorum(ctx, taskID)
if err != nil {
return QuorumResponseDTO{}, err
}
return QuorumResponseDTO{
Guards: guardStateDTOsFromSnapshot(snapshot.Guards),
ReevaluationBlocked: snapshot.ReevaluationBlocked,
}, nil
}
@@ -0,0 +1,185 @@
package dashboard_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/kandev/kandev/internal/office/dashboard"
"github.com/kandev/kandev/internal/workflow/engine"
)
// stubQuorumDispatcher satisfies both shared.WorkflowEngineDispatcher
// (HandleTrigger, unused here) and the package-local quorumEvaluatingDispatcher
// capability, letting these tests control EvaluateStepQuorum's return value
// directly instead of routing through a real engine/session setup.
type stubQuorumDispatcher struct {
snapshot engine.QuorumSnapshot
err error
}
func (s *stubQuorumDispatcher) HandleTrigger(
_ context.Context, _ string, _ engine.Trigger, _ any, _ string,
) error {
return nil
}
func (s *stubQuorumDispatcher) EvaluateStepQuorum(
_ context.Context, _ string,
) (engine.QuorumSnapshot, error) {
return s.snapshot, s.err
}
// TestGetTaskQuorum_DispatcherNotWired is AC-57c/57d's "no office-side
// fallback" contract: when the engine dispatcher isn't wired, the AC-24b
// read side must fail closed exactly like the write side
// (decisionRecordingDispatcher in decisions.go), never report a healthy
// empty result. newTestDeps' fixture wires a real dispatcher, so this test
// swaps in one that satisfies only the base HandleTrigger capability, not
// quorumEvaluatingDispatcher.
type baseOnlyDispatcher struct{}
func (baseOnlyDispatcher) HandleTrigger(
_ context.Context, _ string, _ engine.Trigger, _ any, _ string,
) error {
return nil
}
func TestGetTaskQuorum_DispatcherNotWired(t *testing.T) {
deps := newTestDeps(t)
deps.svc.SetWorkflowEngineDispatcher(baseOnlyDispatcher{})
_, err := deps.svc.GetTaskQuorum(context.Background(), "any-task")
if err == nil {
t.Fatalf("GetTaskQuorum: got nil error, want an error (AC-57c: no office-side fallback)")
}
}
func TestGetTaskQuorum_PropagatesEvaluationError(t *testing.T) {
deps := newTestDeps(t)
wantErr := errors.New("boom")
deps.svc.SetWorkflowEngineDispatcher(&stubQuorumDispatcher{err: wantErr})
_, err := deps.svc.GetTaskQuorum(context.Background(), "any-task")
if !errors.Is(err, wantErr) {
t.Fatalf("err = %v, want %v", err, wantErr)
}
}
func TestGetTaskQuorum_ProjectsSnapshotGuards(t *testing.T) {
deps := newTestDeps(t)
deps.svc.SetWorkflowEngineDispatcher(&stubQuorumDispatcher{
snapshot: engine.QuorumSnapshot{
StepID: "step-1",
ReevaluationBlocked: true,
Guards: []engine.QuorumGuardState{
{
TargetStepID: "step-2",
Role: "approver",
Threshold: "all_approve",
RequiredCount: 2,
ReceivedCount: 1,
Satisfied: false,
Reason: "threshold_not_met",
},
},
},
})
resp, err := deps.svc.GetTaskQuorum(context.Background(), "any-task")
if err != nil {
t.Fatalf("GetTaskQuorum: %v", err)
}
if !resp.ReevaluationBlocked {
t.Fatalf("ReevaluationBlocked = false, want true")
}
if len(resp.Guards) != 1 {
t.Fatalf("Guards = %d, want 1", len(resp.Guards))
}
g := resp.Guards[0]
if g.TargetStepID != "step-2" || g.Role != "approver" || g.Threshold != "all_approve" ||
g.RequiredCount != 2 || g.ReceivedCount != 1 || g.Satisfied || g.Reason != "threshold_not_met" {
t.Fatalf("Guards[0] = %#v, unexpected projection", g)
}
}
// TestGetTaskQuorumEndpoint_NotFound is AC-24b/F37: task existence is
// resolved at the handler layer (mirroring getTask), 404ing before
// EvaluateStepQuorum is ever reached.
func TestGetTaskQuorumEndpoint_NotFound(t *testing.T) {
deps := newTestDeps(t)
req := httptest.NewRequest(http.MethodGet,
"/api/v1/office/workspaces/ws-q/tasks/does-not-exist/quorum", nil)
w := httptest.NewRecorder()
deps.router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}
// TestGetTaskQuorumEndpoint_EmptyListForNoGuardedTransition is AC-24b's
// baseline: a real task with a bound step and no active session in this
// fixture (fakeSessionResolver) returns 200 with an empty guard list rather
// than an error.
func TestGetTaskQuorumEndpoint_EmptyListForNoGuardedTransition(t *testing.T) {
deps := newTestDeps(t)
insertTestTask(t, deps.db, "q1", "ws-q", "Q", "in_review", 2)
req := httptest.NewRequest(http.MethodGet,
"/api/v1/office/workspaces/ws-q/tasks/q1/quorum", nil)
w := httptest.NewRecorder()
deps.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var resp dashboard.QuorumResponseDTO
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Guards) != 0 {
t.Fatalf("Guards = %d, want 0", len(resp.Guards))
}
if resp.ReevaluationBlocked {
t.Fatalf("ReevaluationBlocked = true, want false")
}
}
// TestGetTaskQuorumEndpoint_EmptyListForUnboundStep is AC-24c: a task with
// no workflow_step_id bound at all returns 200 with an empty list, not an
// error.
func TestGetTaskQuorumEndpoint_EmptyListForUnboundStep(t *testing.T) {
deps := newTestDeps(t)
insertTestTaskNoStep(t, deps, "q2", "ws-q", "Q2")
req := httptest.NewRequest(http.MethodGet,
"/api/v1/office/workspaces/ws-q/tasks/q2/quorum", nil)
w := httptest.NewRecorder()
deps.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var resp dashboard.QuorumResponseDTO
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Guards) != 0 {
t.Fatalf("Guards = %d, want 0", len(resp.Guards))
}
}
func TestGetTaskQuorumEndpoint_RejectsWrongWorkspace(t *testing.T) {
deps := newTestDeps(t)
insertTestTask(t, deps.db, "q3", "ws-q", "Q3", "in_review", 2)
req := httptest.NewRequest(http.MethodGet,
"/api/v1/office/workspaces/ws-other/tasks/q3/quorum", nil)
w := httptest.NewRecorder()
deps.router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}
@@ -12,6 +12,7 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/office/shared"
@@ -37,6 +38,50 @@ type SessionResolver interface {
// minimal interface so tests can pass a fake.
type EngineHandle interface {
HandleTrigger(ctx context.Context, in engine.HandleInput) (engine.HandleResult, error)
// RecordParticipantDecision and EvaluateStepQuorum are the AC-57a/57d
// engine decision entry points. Widening this dispatcher-local
// interface (rather than shared.WorkflowEngineDispatcher) is the
// documented AC-57d implementation note: callers reach the new
// capability through Dispatcher.RecordDecision /
// Dispatcher.EvaluateStepQuorum plus a type assertion against a
// narrow caller-side interface, mirroring handledWorkflowEngineDispatcher.
RecordParticipantDecision(ctx context.Context, sessionID string, in engine.DecisionInfo) (engine.RecordDecisionResult, error)
EvaluateStepQuorum(ctx context.Context, taskID, sessionID string) (engine.QuorumSnapshot, error)
// ResolveParticipantRole is the AC-2/3/4/4a role-and-seat resolution
// entry point the agent decision tool needs. Reached the same way as
// the two methods above: via Dispatcher.ResolveParticipantRole plus a
// narrow caller-side type assertion.
ResolveParticipantRole(ctx context.Context, taskID, stepID, agentProfileID string) (role, participantID string, err error)
}
// RecordDecisionInput is what a transport must resolve before calling
// Dispatcher.RecordDecision: the task/step/participant identity and the
// verdict itself. Session resolution (AC-16/16a) happens inside
// RecordDecision, not here.
type RecordDecisionInput struct {
TaskID string
StepID string
ParticipantID string
Decision string
DeciderType string
DeciderID string
Role string
Comment string
}
// RecordDecisionResult mirrors engine.RecordDecisionResult plus the
// validated StepID (AC-37) the decision was recorded against, which the
// AC-64 tool contract and AC-57b-i both need.
type RecordDecisionResult struct {
StepID string
DecisionID string
DecidedAt time.Time
Transitioned bool
TransitionAbandoned bool
FromStepID string
ToStepID string
// Guards is the engine's pre-transition quorum snapshot.
Guards []engine.QuorumGuardState
}
// Dispatcher resolves a task's active session and invokes the workflow
@@ -146,3 +191,142 @@ func (d *Dispatcher) resolveSession(
func isReusableCommentSession(state taskmodels.TaskSessionState) bool {
return state == taskmodels.TaskSessionStateCompleted || state == taskmodels.TaskSessionStateIdle
}
// RecordDecision is the AC-57a write-side engine decision entry point:
// it resolves the task's active session per AC-16/16a, then delegates the
// write-and-reevaluate to Engine.RecordParticipantDecision. AC-16a's
// "no session resolvable" case is not an error here — an empty sessionID
// tells the engine to record the decision and skip re-evaluation
// (reported under AC-23/F39 by the engine itself), matching the existing
// blank-session behavior RecordParticipantDecision already implements.
func (d *Dispatcher) RecordDecision(ctx context.Context, in RecordDecisionInput) (RecordDecisionResult, error) {
if in.TaskID == "" {
return RecordDecisionResult{}, fmt.Errorf("task_id is required")
}
sessionID, err := d.resolveActiveSessionID(ctx, in.TaskID)
if err != nil {
return RecordDecisionResult{}, fmt.Errorf("resolve active session: %w", err)
}
result, err := d.engine.RecordParticipantDecision(ctx, sessionID, engine.DecisionInfo{
TaskID: in.TaskID,
StepID: in.StepID,
ParticipantID: in.ParticipantID,
Decision: in.Decision,
DeciderType: in.DeciderType,
DeciderID: in.DeciderID,
Role: in.Role,
Comment: in.Comment,
})
// AC-15: the engine returns a populated result alongside a non-nil err
// exactly when the write succeeded but the post-write re-evaluation
// failed. That case must be reported as success — with the recorded
// decision's identity kept — never collapsed into a write failure. An
// empty DecisionID means the write itself never happened, which is a
// genuine failure. This is the single funnel recordTaskDecision and
// RecordAgentDecision both call through, so fixing it here covers both.
if err != nil {
if result.DecisionID == "" {
return RecordDecisionResult{}, fmt.Errorf("record participant decision: %w", err)
}
d.logger.Warn("quorum re-evaluation failed after decision recorded",
zap.String("task_id", in.TaskID),
zap.String("step_id", in.StepID),
zap.String("decision_id", result.DecisionID),
zap.Error(err))
}
return RecordDecisionResult{
StepID: in.StepID,
DecisionID: result.DecisionID,
DecidedAt: result.DecidedAt,
Transitioned: result.Transitioned,
TransitionAbandoned: result.TransitionAbandoned,
FromStepID: result.FromStepID,
ToStepID: result.ToStepID,
Guards: result.Guards,
}, nil
}
// EvaluateStepQuorum is the AC-57d read-only engine entry point. Per F38,
// the state read that satisfies TransitionStore.LoadState's signature uses
// ANY resolvable session for the task (GetTaskSessionByTaskID, latest,
// any state) since MachineState.CurrentStepID is derived from the task
// row, not the session; a task with no session at all yields a
// successful empty snapshot rather than an error. ReevaluationBlocked's
// second conjunct — "no active session" (AC-16's query, distinct from
// F38's any-session read) — is not something the engine can compute
// itself, so it is ANDed in here.
func (d *Dispatcher) EvaluateStepQuorum(ctx context.Context, taskID string) (engine.QuorumSnapshot, error) {
sessionID, err := d.resolveLatestSessionID(ctx, taskID)
if err != nil {
return engine.QuorumSnapshot{}, fmt.Errorf("resolve latest session: %w", err)
}
snapshot, err := d.engine.EvaluateStepQuorum(ctx, taskID, sessionID)
if err != nil {
return engine.QuorumSnapshot{}, err
}
if snapshot.ReevaluationBlocked {
noActiveSession, err := d.hasNoActiveSession(ctx, taskID)
if err != nil {
return engine.QuorumSnapshot{}, fmt.Errorf("resolve active session: %w", err)
}
snapshot.ReevaluationBlocked = noActiveSession
}
return snapshot, nil
}
// ResolveParticipantRole is the AC-2/3/4/4a engine entry point: resolves
// the calling agent's role and seat id at the given step by reusing the
// AC-50 slate construction (approver before reviewer, per AC-4). Unlike
// RecordDecision/EvaluateStepQuorum this needs no session resolution — the
// slate read has no session dependency.
func (d *Dispatcher) ResolveParticipantRole(
ctx context.Context, taskID, stepID, agentProfileID string,
) (role, participantID string, err error) {
return d.engine.ResolveParticipantRole(ctx, taskID, stepID, agentProfileID)
}
// resolveActiveSessionID returns AC-16's active-session id, or "" when no
// such session is resolvable (AC-16a) — never an error for that case.
func (d *Dispatcher) resolveActiveSessionID(ctx context.Context, taskID string) (string, error) {
session, err := d.sessions.GetActiveTaskSessionByTaskID(ctx, taskID)
if err != nil {
if errors.Is(err, taskmodels.ErrTaskSessionNotFound) {
return "", nil
}
return "", err
}
if session == nil {
return "", nil
}
return session.ID, nil
}
// resolveLatestSessionID returns the F38 "any session" id (the task's
// most recent session regardless of state), or "" when the task has never
// had one.
func (d *Dispatcher) resolveLatestSessionID(ctx context.Context, taskID string) (string, error) {
session, err := d.sessions.GetTaskSessionByTaskID(ctx, taskID)
if err != nil {
if errors.Is(err, taskmodels.ErrTaskSessionNotFound) {
return "", nil
}
return "", err
}
if session == nil {
return "", nil
}
return session.ID, nil
}
// hasNoActiveSession reports whether AC-16's active-session query finds no
// row, the second conjunct of QuorumSnapshot.ReevaluationBlocked (AC-62).
func (d *Dispatcher) hasNoActiveSession(ctx context.Context, taskID string) (bool, error) {
session, err := d.sessions.GetActiveTaskSessionByTaskID(ctx, taskID)
if err != nil {
if errors.Is(err, taskmodels.ErrTaskSessionNotFound) {
return true, nil
}
return false, err
}
return session == nil, nil
}
@@ -0,0 +1,304 @@
package engine_dispatcher
import (
"context"
"errors"
"testing"
"time"
"github.com/kandev/kandev/internal/common/logger"
taskmodels "github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/workflow/engine"
)
// TestDispatcher_RecordDecision_ResolvesActiveSessionAndForwards covers the
// AC-57a happy path: the active session (AC-16) is resolved and threaded
// into Engine.RecordParticipantDecision, and the engine's result maps onto
// RecordDecisionResult including the AC-37 validated StepID.
func TestDispatcher_RecordDecision_ResolvesActiveSessionAndForwards(t *testing.T) {
decidedAt := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)
eng := &fakeEngine{decisionResult: engine.RecordDecisionResult{
DecisionID: "decision-1",
DecidedAt: decidedAt,
Transitioned: true,
FromStepID: "review",
ToStepID: "approval",
}}
sessions := &fakeSessions{activeSession: &taskmodels.TaskSession{ID: "sess-1"}}
d := New(eng, sessions, logger.Default())
result, err := d.RecordDecision(context.Background(), RecordDecisionInput{
TaskID: "task-1",
StepID: "review",
ParticipantID: "participant-1",
Decision: "approved",
DeciderType: "agent",
DeciderID: "agent-1",
Role: "reviewer",
Comment: "looks good",
})
if err != nil {
t.Fatalf("RecordDecision: %v", err)
}
if !eng.decisionCalled {
t.Fatal("engine RecordParticipantDecision not invoked")
}
if eng.decisionSession != "sess-1" {
t.Errorf("session id = %q, want sess-1", eng.decisionSession)
}
if eng.decisionIn.TaskID != "task-1" || eng.decisionIn.StepID != "review" ||
eng.decisionIn.ParticipantID != "participant-1" || eng.decisionIn.Decision != "approved" ||
eng.decisionIn.DeciderType != "agent" || eng.decisionIn.DeciderID != "agent-1" ||
eng.decisionIn.Role != "reviewer" || eng.decisionIn.Comment != "looks good" {
t.Errorf("decision input not forwarded verbatim: %+v", eng.decisionIn)
}
if result.StepID != "review" {
t.Errorf("result.StepID = %q, want review (AC-37 validated step)", result.StepID)
}
if result.DecisionID != "decision-1" || !result.DecidedAt.Equal(decidedAt) {
t.Errorf("result decision identity not carried through: %+v", result)
}
if !result.Transitioned || result.FromStepID != "review" || result.ToStepID != "approval" {
t.Errorf("result transition fields not carried through: %+v", result)
}
}
// TestDispatcher_RecordDecision_SkipsReevaluationWhenNoActiveSession is the
// AC-16a case: no active session is resolvable, so the engine is still
// called (it records the decision) but with a blank session id, which is
// the engine's own signal to skip re-evaluation.
func TestDispatcher_RecordDecision_SkipsReevaluationWhenNoActiveSession(t *testing.T) {
eng := &fakeEngine{decisionResult: engine.RecordDecisionResult{DecisionID: "decision-1"}}
sessions := &fakeSessions{activeErr: taskmodels.ErrTaskSessionNotFound}
d := New(eng, sessions, logger.Default())
if _, err := d.RecordDecision(context.Background(), RecordDecisionInput{
TaskID: "task-1", StepID: "review", ParticipantID: "participant-1", Decision: "approved",
}); err != nil {
t.Fatalf("RecordDecision: %v", err)
}
if !eng.decisionCalled {
t.Fatal("engine RecordParticipantDecision not invoked")
}
if eng.decisionSession != "" {
t.Errorf("session id = %q, want blank so the engine skips re-evaluation (AC-16a)", eng.decisionSession)
}
}
// TestDispatcher_RecordDecision_PropagatesActiveSessionLookupError proves a
// genuine session-store failure is not silently treated as AC-16a's
// "no session resolvable" case.
func TestDispatcher_RecordDecision_PropagatesActiveSessionLookupError(t *testing.T) {
dbErr := errors.New("db down")
eng := &fakeEngine{}
sessions := &fakeSessions{activeErr: dbErr}
d := New(eng, sessions, logger.Default())
_, err := d.RecordDecision(context.Background(), RecordDecisionInput{
TaskID: "task-1", StepID: "review", ParticipantID: "participant-1", Decision: "approved",
})
if !errors.Is(err, dbErr) {
t.Fatalf("err = %v, want wrapped db error", err)
}
if eng.decisionCalled {
t.Error("engine should not be called when active session lookup fails")
}
}
// TestDispatcher_RecordDecision_PropagatesEngineError proves a decision
// write/re-evaluation failure surfaces to the caller unwrapped of context.
func TestDispatcher_RecordDecision_PropagatesEngineError(t *testing.T) {
engineErr := errors.New("decision store not wired")
eng := &fakeEngine{decisionErr: engineErr}
sessions := &fakeSessions{activeSession: &taskmodels.TaskSession{ID: "sess-1"}}
d := New(eng, sessions, logger.Default())
_, err := d.RecordDecision(context.Background(), RecordDecisionInput{
TaskID: "task-1", StepID: "review", ParticipantID: "participant-1", Decision: "approved",
})
if !errors.Is(err, engineErr) {
t.Fatalf("err = %v, want wrapped engine error", err)
}
}
// TestDispatcher_RecordDecision_KeepsResultWhenReevaluationFails is AC-15:
// when the engine's write succeeded but its post-write re-evaluation
// failed, RecordParticipantDecision returns a *populated* result alongside
// a non-nil error (see quorum.go's RecordParticipantDecision, which does
// `return result, err` rather than `return RecordDecisionResult{}, err`).
// The dispatcher — the single funnel both recordTaskDecision and
// RecordAgentDecision call through — must keep that result and report
// success rather than collapsing a re-evaluation failure into a write
// failure and losing decision_id/decided_at.
func TestDispatcher_RecordDecision_KeepsResultWhenReevaluationFails(t *testing.T) {
decidedAt := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)
reevalErr := errors.New("load step: db down")
eng := &fakeEngine{
decisionResult: engine.RecordDecisionResult{DecisionID: "decision-1", DecidedAt: decidedAt},
decisionErr: reevalErr,
}
sessions := &fakeSessions{activeSession: &taskmodels.TaskSession{ID: "sess-1"}}
d := New(eng, sessions, logger.Default())
result, err := d.RecordDecision(context.Background(), RecordDecisionInput{
TaskID: "task-1", StepID: "review", ParticipantID: "participant-1", Decision: "approved",
})
if err != nil {
t.Fatalf("RecordDecision: %v, want success per AC-15 (write succeeded, re-eval failed)", err)
}
if result.DecisionID != "decision-1" || !result.DecidedAt.Equal(decidedAt) {
t.Errorf("result decision identity lost: %+v", result)
}
if result.StepID != "review" {
t.Errorf("result.StepID = %q, want review", result.StepID)
}
if result.Transitioned {
t.Error("Transitioned = true, want false: re-evaluation errored, no transition could have applied")
}
}
// TestDispatcher_RecordDecision_RejectsEmptyTaskID mirrors HandleTrigger's
// existing input validation for the new write-side entry point.
func TestDispatcher_RecordDecision_RejectsEmptyTaskID(t *testing.T) {
d := New(&fakeEngine{}, &fakeSessions{}, logger.Default())
if _, err := d.RecordDecision(context.Background(), RecordDecisionInput{Decision: "approved"}); err == nil {
t.Fatal("expected error for empty task id")
}
}
// TestDispatcher_EvaluateStepQuorum_UsesLatestSessionForStateRead is F38:
// the state read uses GetTaskSessionByTaskID (latest, any state) even when
// that session is not "reusable" in the comment-trigger sense, because
// EvaluateStepQuorum only needs a session id to satisfy LoadState's
// signature, not a session eligible to run a turn.
func TestDispatcher_EvaluateStepQuorum_UsesLatestSessionForStateRead(t *testing.T) {
eng := &fakeEngine{quorumResult: engine.QuorumSnapshot{StepID: "review"}}
sessions := &fakeSessions{
activeErr: taskmodels.ErrTaskSessionNotFound,
latestSession: &taskmodels.TaskSession{
ID: "sess-failed",
State: taskmodels.TaskSessionStateFailed,
},
}
d := New(eng, sessions, logger.Default())
if _, err := d.EvaluateStepQuorum(context.Background(), "task-1"); err != nil {
t.Fatalf("EvaluateStepQuorum: %v", err)
}
if !eng.quorumCalled {
t.Fatal("engine EvaluateStepQuorum not invoked")
}
if eng.quorumTaskID != "task-1" {
t.Errorf("task id = %q, want task-1", eng.quorumTaskID)
}
if eng.quorumSession != "sess-failed" {
t.Errorf("session id = %q, want sess-failed (F38: any session, any state)", eng.quorumSession)
}
}
// TestDispatcher_EvaluateStepQuorum_NoSessionAtAllStillCallsEngineWithBlankID
// covers F38's boundary: a task that has never had any session returns the
// engine's own successful empty snapshot rather than erroring.
func TestDispatcher_EvaluateStepQuorum_NoSessionAtAllStillCallsEngineWithBlankID(t *testing.T) {
eng := &fakeEngine{}
sessions := &fakeSessions{
activeErr: taskmodels.ErrTaskSessionNotFound,
latestErr: taskmodels.ErrTaskSessionNotFound,
}
d := New(eng, sessions, logger.Default())
snapshot, err := d.EvaluateStepQuorum(context.Background(), "task-1")
if err != nil {
t.Fatalf("EvaluateStepQuorum: %v", err)
}
if eng.quorumSession != "" {
t.Errorf("session id = %q, want blank", eng.quorumSession)
}
if len(snapshot.Guards) != 0 || snapshot.ReevaluationBlocked {
t.Errorf("snapshot = %+v, want empty/unblocked", snapshot)
}
}
// TestDispatcher_EvaluateStepQuorum_BlockedOnlyWhenNoActiveSession proves
// the AC-62 conjunction: the engine's ReevaluationBlocked (decisions
// non-empty at current step) is ANDed with "no active session" here, since
// the engine cannot compute that second conjunct itself.
func TestDispatcher_EvaluateStepQuorum_BlockedOnlyWhenNoActiveSession(t *testing.T) {
tests := []struct {
name string
activeSession *taskmodels.TaskSession
activeErr error
wantBlocked bool
}{
{
name: "no active session: blocked",
activeErr: taskmodels.ErrTaskSessionNotFound,
wantBlocked: true,
},
{
name: "active session present: not blocked",
activeSession: &taskmodels.TaskSession{ID: "sess-active"},
wantBlocked: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
eng := &fakeEngine{quorumResult: engine.QuorumSnapshot{
StepID: "review",
ReevaluationBlocked: true, // engine's own decisions-non-empty conjunct
}}
sessions := &fakeSessions{
activeSession: tt.activeSession,
activeErr: tt.activeErr,
latestSession: &taskmodels.TaskSession{ID: "sess-latest"},
}
d := New(eng, sessions, logger.Default())
snapshot, err := d.EvaluateStepQuorum(context.Background(), "task-1")
if err != nil {
t.Fatalf("EvaluateStepQuorum: %v", err)
}
if snapshot.ReevaluationBlocked != tt.wantBlocked {
t.Errorf("ReevaluationBlocked = %v, want %v", snapshot.ReevaluationBlocked, tt.wantBlocked)
}
})
}
}
// TestDispatcher_EvaluateStepQuorum_SkipsActiveSessionLookupWhenNotBlocked
// proves the AC-62 conjunction short-circuits: when the engine's own
// conjunct is already false, the second conjunct's active-session lookup
// never runs, so a failing session store cannot spuriously error a
// not-blocked read.
func TestDispatcher_EvaluateStepQuorum_SkipsActiveSessionLookupWhenNotBlocked(t *testing.T) {
eng := &fakeEngine{quorumResult: engine.QuorumSnapshot{StepID: "review", ReevaluationBlocked: false}}
sessions := &fakeSessions{
activeErr: errors.New("db down"),
latestSession: &taskmodels.TaskSession{ID: "sess-latest"},
}
d := New(eng, sessions, logger.Default())
snapshot, err := d.EvaluateStepQuorum(context.Background(), "task-1")
if err != nil {
t.Fatalf("EvaluateStepQuorum: %v", err)
}
if snapshot.ReevaluationBlocked {
t.Error("ReevaluationBlocked = true, want false")
}
}
// TestDispatcher_EvaluateStepQuorum_PropagatesEngineError proves a store
// error from the engine's slate read surfaces to the caller (AC-57d
// reserves method-level errors for the not-wired/unresolvable-task cases,
// both handled above this method).
func TestDispatcher_EvaluateStepQuorum_PropagatesEngineError(t *testing.T) {
engineErr := errors.New("boom")
eng := &fakeEngine{quorumErr: engineErr}
sessions := &fakeSessions{activeSession: &taskmodels.TaskSession{ID: "sess-1"}}
d := New(eng, sessions, logger.Default())
_, err := d.EvaluateStepQuorum(context.Background(), "task-1")
if !errors.Is(err, engineErr) {
t.Fatalf("err = %v, want wrapped engine error", err)
}
}
@@ -0,0 +1,61 @@
package engine_dispatcher
import (
"context"
"errors"
"testing"
"github.com/kandev/kandev/internal/common/logger"
)
// TestDispatcher_ResolveParticipantRole_ForwardsVerbatim proves the
// dispatcher passes taskID/stepID/agentProfileID straight through to the
// engine and returns its role/participantID/error verbatim — this entry
// point needs no session resolution, unlike RecordDecision and
// EvaluateStepQuorum.
func TestDispatcher_ResolveParticipantRole_ForwardsVerbatim(t *testing.T) {
eng := &fakeEngine{roleResult: "approver", roleParticipantID: "seat-1"}
d := New(eng, &fakeSessions{}, logger.Default())
role, participantID, err := d.ResolveParticipantRole(context.Background(), "task-1", "review", "agent-a")
if err != nil {
t.Fatalf("ResolveParticipantRole: %v", err)
}
if !eng.roleCalled {
t.Fatal("engine ResolveParticipantRole not invoked")
}
if eng.roleTaskID != "task-1" || eng.roleStepID != "review" || eng.roleAgentProfileID != "agent-a" {
t.Errorf("engine called with (%q, %q, %q), want (task-1, review, agent-a)",
eng.roleTaskID, eng.roleStepID, eng.roleAgentProfileID)
}
if role != "approver" || participantID != "seat-1" {
t.Errorf("role/participantID = %q/%q, want approver/seat-1", role, participantID)
}
}
// TestDispatcher_ResolveParticipantRole_PropagatesEngineError proves a
// permission/store error from the engine surfaces to the caller unwrapped
// of context, including sentinel errors such as engine.ErrParticipantNotFound.
func TestDispatcher_ResolveParticipantRole_PropagatesEngineError(t *testing.T) {
notFound := errors.New("not a participant")
eng := &fakeEngine{roleErr: notFound}
d := New(eng, &fakeSessions{}, logger.Default())
_, _, err := d.ResolveParticipantRole(context.Background(), "task-1", "review", "agent-a")
if !errors.Is(err, notFound) {
t.Fatalf("err = %v, want %v", err, notFound)
}
}
// TestDispatcher_ResolveParticipantRole_NoSessionLookup proves this entry
// point never touches the session resolver — a nil-prone SessionResolver
// still succeeds, since role resolution has no session dependency.
func TestDispatcher_ResolveParticipantRole_NoSessionLookup(t *testing.T) {
eng := &fakeEngine{roleResult: "reviewer", roleParticipantID: "seat-2"}
sessions := &fakeSessions{activeErr: errors.New("session store must not be consulted")}
d := New(eng, sessions, logger.Default())
if _, _, err := d.ResolveParticipantRole(context.Background(), "task-1", "review", "agent-a"); err != nil {
t.Fatalf("ResolveParticipantRole: %v", err)
}
}
@@ -39,6 +39,26 @@ type fakeEngine struct {
called bool
err error
result engine.HandleResult
decisionCalled bool
decisionSession string
decisionIn engine.DecisionInfo
decisionResult engine.RecordDecisionResult
decisionErr error
quorumCalled bool
quorumTaskID string
quorumSession string
quorumResult engine.QuorumSnapshot
quorumErr error
roleCalled bool
roleTaskID string
roleStepID string
roleAgentProfileID string
roleResult string
roleParticipantID string
roleErr error
}
func (f *fakeEngine) HandleTrigger(_ context.Context, in engine.HandleInput) (engine.HandleResult, error) {
@@ -47,6 +67,34 @@ func (f *fakeEngine) HandleTrigger(_ context.Context, in engine.HandleInput) (en
return f.result, f.err
}
func (f *fakeEngine) RecordParticipantDecision(
_ context.Context, sessionID string, in engine.DecisionInfo,
) (engine.RecordDecisionResult, error) {
f.decisionCalled = true
f.decisionSession = sessionID
f.decisionIn = in
return f.decisionResult, f.decisionErr
}
func (f *fakeEngine) EvaluateStepQuorum(
_ context.Context, taskID, sessionID string,
) (engine.QuorumSnapshot, error) {
f.quorumCalled = true
f.quorumTaskID = taskID
f.quorumSession = sessionID
return f.quorumResult, f.quorumErr
}
func (f *fakeEngine) ResolveParticipantRole(
_ context.Context, taskID, stepID, agentProfileID string,
) (string, string, error) {
f.roleCalled = true
f.roleTaskID = taskID
f.roleStepID = stepID
f.roleAgentProfileID = agentProfileID
return f.roleResult, f.roleParticipantID, f.roleErr
}
type realRunsAdapter struct {
svc *runsservice.Service
}
@@ -112,6 +160,12 @@ func (commentWorkflowStore) ApplyTransition(context.Context, string, string, str
return errors.New("unexpected transition")
}
func (commentWorkflowStore) ApplyTransitionIfAtStep(
context.Context, string, string, string, string, engine.Trigger,
) (bool, error) {
return false, errors.New("unexpected transition")
}
func (commentWorkflowStore) PersistData(context.Context, string, map[string]any) error {
return nil
}
@@ -175,6 +229,17 @@ func (s *transitionWorkflowStore) ApplyTransition(
return nil
}
func (s *transitionWorkflowStore) ApplyTransitionIfAtStep(
_ context.Context, taskID, sessionID, expectedStepID, toStepID string, trigger engine.Trigger,
) (bool, error) {
s.appliedTaskID = taskID
s.appliedSessionID = sessionID
s.appliedFrom = expectedStepID
s.appliedTo = toStepID
s.appliedTrigger = trigger
return true, nil
}
func (s *transitionWorkflowStore) PersistData(context.Context, string, map[string]any) error {
return nil
}
@@ -788,6 +788,40 @@ func seedSession(t *testing.T, repo *sqliterepo.Repository, taskID, sessionID, w
}
}
// seedTaskWithoutSession creates a task, workspace, and workflow in the repo
// but deliberately no task session — the F38/dispatcher case for a task with
// zero task_sessions rows.
func seedTaskWithoutSession(t *testing.T, repo *sqliterepo.Repository, taskID, workflowStepID string) {
t.Helper()
ctx := context.Background()
now := time.Now().UTC()
ws := &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now}
if err := repo.CreateWorkspace(ctx, ws); err != nil {
t.Fatalf("failed to create workspace: %v", err)
}
wf := &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "Test Workflow", CreatedAt: now, UpdatedAt: now}
if err := repo.CreateWorkflow(ctx, wf); err != nil {
_ = err
}
task := &models.Task{
ID: taskID,
WorkspaceID: "ws1",
WorkflowID: "wf1",
WorkflowStepID: workflowStepID,
Title: "Test Task",
Description: "Test",
State: v1.TaskStateInProgress,
CreatedAt: now,
UpdatedAt: now,
}
if err := repo.CreateTask(ctx, task); err != nil {
t.Fatalf("failed to create task: %v", err)
}
}
type ownershipOverrideRepo struct {
sessionExecutorStore
tasks map[string]*models.Task
@@ -3868,24 +3868,31 @@ func (s *Service) buildMachineState(ctx context.Context, task *models.Task, sess
// assembleMachineState creates an engine.MachineState from pre-loaded models.
// Shared by Service.buildMachineState and workflowStore.LoadState to avoid duplication.
// assembleMachineState accepts a nil session for the AC-62/F38 case of a
// task with zero task_sessions rows (sessionID == ""): CurrentStepID is
// always derived from the task row, never the session, so SessionID and
// SessionState are left at their zero values and no workflow_data is
// available in that case.
func assembleMachineState(task *models.Task, session *models.TaskSession, isPassthrough bool) engine.MachineState {
currentStepID := task.WorkflowStepID
var data map[string]any
if session.Metadata != nil {
if wd, ok := session.Metadata["workflow_data"].(map[string]any); ok {
data = wd
}
}
return engine.MachineState{
state := engine.MachineState{
TaskID: task.ID,
SessionID: session.ID,
WorkflowID: task.WorkflowID,
CurrentStepID: currentStepID,
SessionState: string(session.State),
TaskDescription: task.Description,
IsPassthrough: isPassthrough,
Data: data,
}
if session == nil {
return state
}
state.SessionID = session.ID
state.SessionState = string(session.State)
if session.Metadata != nil {
if wd, ok := session.Metadata["workflow_data"].(map[string]any); ok {
state.Data = wd
}
}
return state
}
// processOnTurnCompleteViaEngine uses the workflow engine to evaluate on_turn_complete
@@ -4050,12 +4057,94 @@ func (s *Service) allowEngineSignalCompletion(
return true
}
// applyGuardedTransitionLifecycle is the service-owned lifecycle bridge for
// quorum re-evaluation. The engine still selects the target and owns the CAS,
// but credential checks, on_exit, history, signal cleanup, terminal handling,
// session/profile state, and on_enter stay in the orchestrator path.
func (s *Service) applyGuardedTransitionLifecycle(
ctx context.Context, taskID, sessionID, fromStepID, toStepID string, trigger engine.Trigger,
) (bool, error) {
if s.workflowStore == nil {
return false, errors.New("workflow store is not initialized")
}
task, err := s.repo.GetTask(ctx, taskID)
if err != nil {
return false, fmt.Errorf("load task for guarded transition lifecycle: %w", err)
}
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
return false, fmt.Errorf("load session for guarded transition lifecycle: %w", err)
}
casAttempted := false
casApplied := false
lifecycleApplied := s.applyEngineTransitionWithCommit(
ctx,
taskID,
session,
engine.HandleResult{Transitioned: true, FromStepID: fromStepID, ToStepID: toStepID},
trigger,
task.Description,
true,
func(commitCtx context.Context) (bool, error) {
casAttempted = true
transitionCtx := commitCtx
if !steptelemetry.HasTrigger(transitionCtx) {
transitionCtx = engineTransitionAttribution(transitionCtx, sessionID, trigger)
}
committedTask, oldWorkflowID, applied, commitErr := s.workflowStore.applyTransitionIfAtStepRaw(
transitionCtx, taskID, fromStepID, toStepID,
)
if commitErr != nil {
return false, commitErr
}
if !applied {
return false, nil
}
casApplied = true
// The raw CAS commit intentionally has no side effects. The shared
// lifecycle helper performs session state and on_enter work below.
s.publishTaskUpdated(ctx, committedTask, oldWorkflowID)
s.workflowStore.pullNextTaskOnVacate(ctx, fromStepID, taskID)
return true, nil
},
)
if lifecycleApplied || casApplied {
return true, nil
}
if casAttempted {
// A false CAS is an expected concurrent re-evaluation outcome.
return false, nil
}
return false, errors.New("guarded transition lifecycle did not apply")
}
// applyEngineTransition applies an engine-evaluated transition: on_exit, DB transition,
// data patches, and optionally on_enter processing. Returns true if the transition was applied.
func (s *Service) applyEngineTransition(
ctx context.Context, taskID string, session *models.TaskSession,
result engine.HandleResult, trigger engine.Trigger, taskDescription string,
triggerOnEnter bool,
) bool {
return s.applyEngineTransitionWithCommit(ctx, taskID, session, result, trigger, taskDescription, triggerOnEnter,
func(commitCtx context.Context) (bool, error) {
if err := s.workflowStore.ApplyTransition(commitCtx, taskID, session.ID, result.FromStepID, result.ToStepID, trigger); err != nil {
return false, err
}
return true, nil
})
}
// applyEngineTransitionWithCommit applies the shared lifecycle around a
// transition commit. The default path commits through ApplyTransition. The
// quorum decision path supplies a CAS commit so lifecycle hooks cannot be
// bypassed by the engine's guarded-transition re-evaluation.
//
//nolint:cyclop,funlen // this coordinates independent transition lifecycle stages.
func (s *Service) applyEngineTransitionWithCommit(
ctx context.Context, taskID string, session *models.TaskSession,
result engine.HandleResult, trigger engine.Trigger, taskDescription string,
triggerOnEnter bool, commit func(context.Context) (bool, error),
) bool {
// Validate the target step exists BEFORE persisting the transition.
// This prevents the task from being moved to an invalid step_id
@@ -4102,7 +4191,8 @@ func (s *Service) applyEngineTransition(
s.processOnExit(ctx, taskID, session, fromStep)
}
if err := s.workflowStore.ApplyTransition(ctx, taskID, session.ID, result.FromStepID, result.ToStepID, trigger); err != nil {
applied, err := commit(ctx)
if err != nil {
s.logger.Error("failed to apply engine transition",
zap.String("task_id", taskID),
zap.String("session_id", session.ID),
@@ -4110,6 +4200,9 @@ func (s *Service) applyEngineTransition(
s.setSessionWaitingForInput(ctx, taskID, session.ID, session)
return false
}
if !applied {
return false
}
// ADR 0015 — record the audit row before the pending signal (if any) is
// cleared. Only an on_turn_complete transition can have consumed a
@@ -1497,9 +1497,16 @@ func (s *Service) initWorkflowEngine() {
return
}
store := newWorkflowStore(s.repo, s.workflowStepGetter, s.agentManager, s.publishTaskUpdated, s.logger, s.publishTaskMoved, s.publishTaskQueuePromoted, s.publishTaskStateChanged, s.stepHistoryRecorder)
store.setGuardedTransitionLifecycle(s.applyGuardedTransitionLifecycle)
callbacks := buildWorkflowCallbacks(s)
s.workflowStore = store
s.workflowEngine = engine.New(store, callbacks, s.engineOptions...)
// AC-24/24a: the engine's own structured "guard did not fire" log needs
// the service's logger. Passed here (rather than folded into
// s.engineOptions via a Set* method) because s.logger is a stable
// constructor-time field already in scope, unlike the optional
// dependencies those methods wire in after Service creation.
options := append([]engine.Option{engine.WithLogger(s.logger)}, s.engineOptions...)
s.workflowEngine = engine.New(store, callbacks, options...)
}
// SetEngineRunQueue wires the engine's RunQueueAdapter dependency. Used
@@ -27,6 +27,8 @@ type taskMovedPublisher func(ctx context.Context, task *models.Task, fromWorkflo
type taskQueuePromotedPublisher func(ctx context.Context, task *models.Task)
type taskStateChangedPublisher func(ctx context.Context, task *models.Task, oldState v1.TaskState)
type guardedTransitionLifecycle func(ctx context.Context, taskID, sessionID, fromStepID, toStepID string, trigger engine.Trigger) (bool, error)
type workflowMoveLimitsRepository interface {
CountTasksByWorkflowStepExcludingTask(ctx context.Context, stepID, excludeTaskID string) (int, error)
}
@@ -43,6 +45,17 @@ type workflowMoveAdmissionRepository interface {
UpdateTaskWithWorkflowStepAdmission(ctx context.Context, task *models.Task, targetStepID string, limit int) (bool, error)
}
// workflowMoveAdmissionCASRepository is the AC-46/48 compare-and-swap
// variant of workflowMoveAdmissionRepository. It is a separate interface
// (rather than a new method on workflowMoveAdmissionRepository) so fakes in
// tests that only exercise the unconditional move path do not need to grow a
// method they never call.
type workflowMoveAdmissionCASRepository interface {
UpdateTaskWithWorkflowStepAdmissionIfAtStep(
ctx context.Context, task *models.Task, expectedStepID, targetStepID string, limit int,
) (applied bool, err error)
}
type workflowQueuedTaskPromoter interface {
PromoteQueuedTaskIfWorkflowStepHasCapacity(ctx context.Context, task *models.Task, fromStepID, destinationStepID string, limit int) (bool, error)
}
@@ -86,6 +99,7 @@ type workflowStore struct {
publishStateChanged taskStateChangedPublisher
logger *logger.Logger
stepHistoryRecorder StepHistoryRecorder
guardedLifecycle guardedTransitionLifecycle
appliedOps sync.Map
}
@@ -134,12 +148,24 @@ func newWorkflowStore(
}
}
func (s *workflowStore) setGuardedTransitionLifecycle(fn guardedTransitionLifecycle) {
s.guardedLifecycle = fn
}
// LoadState tolerates a blank sessionID (the AC-62/F38 case of a task with
// zero task_sessions rows) by skipping the session lookup entirely rather
// than erroring: CurrentStepID always comes from the task row, matching the
// sessionID == "" sentinel convention already used elsewhere (AC-16a).
func (s *workflowStore) LoadState(ctx context.Context, taskID, sessionID string) (engine.MachineState, error) {
task, err := s.repo.GetTask(ctx, taskID)
if err != nil {
return engine.MachineState{}, fmt.Errorf("load task %s: %w", taskID, err)
}
if sessionID == "" {
return assembleMachineState(task, nil, false), nil
}
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
return engine.MachineState{}, fmt.Errorf("load session %s: %w", sessionID, err)
@@ -276,6 +302,107 @@ func (s *workflowStore) applyTransition(ctx context.Context, taskID, sessionID,
return nil
}
// ApplyTransitionIfAtStep is the AC-46/48 compare-and-swap variant of
// ApplyTransition used by the workflow engine's guarded-transition
// re-evaluation apply path (Engine.reevaluateGuardedTransitions). The service
// can install a lifecycle bridge so this CAS goes through the same
// orchestrator hooks as other engine transitions. applied=false means the
// task's persisted step no longer equals expectedStepID by the time the
// underlying repository checked — a concurrent transition already moved it —
// which is not an error.
func (s *workflowStore) ApplyTransitionIfAtStep(
ctx context.Context, taskID, sessionID, expectedStepID, toStepID string, trigger engine.Trigger,
) (bool, error) {
if s.guardedLifecycle != nil {
return s.guardedLifecycle(ctx, taskID, sessionID, expectedStepID, toStepID, trigger)
}
return s.applyTransitionIfAtStep(ctx, taskID, sessionID, expectedStepID, toStepID, trigger)
}
func (s *workflowStore) applyTransitionIfAtStep(
ctx context.Context, taskID, sessionID, expectedStepID, toStepID string, trigger engine.Trigger,
) (bool, error) {
transitionCtx := ctx
if !steptelemetry.HasTrigger(transitionCtx) {
transitionCtx = engineTransitionAttribution(transitionCtx, sessionID, trigger)
}
task, oldWorkflowID, applied, err := s.applyTransitionIfAtStepRaw(transitionCtx, taskID, expectedStepID, toStepID)
if err != nil {
return false, err
}
if !applied {
return false, nil
}
s.publishTaskUpdated(ctx, task, oldWorkflowID)
if task.QueuedForStepID == "" {
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil {
s.logger.Warn("failed to clear session review status",
zap.String("session_id", sessionID),
zap.Error(err))
}
}
s.logger.Info("workflow transition applied (compare-and-swap)",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("from_step_id", expectedStepID),
zap.String("to_step_id", toStepID))
s.pullNextTaskOnVacate(ctx, expectedStepID, taskID)
return true, nil
}
// applyTransitionIfAtStepRaw commits the guarded move without publishing
// events or changing session state. The service lifecycle wrapper uses this
// commit point after credential preflight and on_exit, then performs the
// remaining transition lifecycle after the CAS succeeds.
func (s *workflowStore) applyTransitionIfAtStepRaw(
ctx context.Context, taskID, expectedStepID, toStepID string,
) (*models.Task, string, bool, error) {
task, err := s.repo.GetTask(ctx, taskID)
if err != nil {
return nil, "", false, fmt.Errorf("load task for CAS transition: %w", err)
}
targetStep, err := s.workflowStepGetter.GetStep(ctx, toStepID)
if err != nil {
return nil, "", false, fmt.Errorf("load target step for CAS transition: %w", err)
}
if targetStep == nil {
return nil, "", false, fmt.Errorf("target step %s not found for CAS transition", toStepID)
}
casRepo, ok := s.repo.(workflowMoveAdmissionCASRepository)
if !ok {
return nil, "", false, fmt.Errorf("workflow step CAS admission repository unavailable for step %s", toStepID)
}
oldWorkflowID := task.WorkflowID
task.WorkflowID = targetStep.WorkflowID
task.WorkflowStepID = toStepID
task.WIPAdmitted = true
task.QueuedForStepID = ""
task.QueuedAt = nil
if task.Metadata != nil {
delete(task.Metadata, models.MetaKeyQueuedMoveExitPending)
delete(task.Metadata, models.MetaKeyQueuedMoveExitCompleted)
delete(task.Metadata, models.MetaKeyQueuePromotionPending)
}
task.UpdatedAt = time.Now().UTC()
applied, err := casRepo.UpdateTaskWithWorkflowStepAdmissionIfAtStep(
ctx, task, expectedStepID, toStepID, targetStep.WIPLimit,
)
if err != nil {
return nil, "", false, fmt.Errorf("update task workflow step (CAS): %w", err)
}
if !applied {
return nil, oldWorkflowID, false, nil
}
return task, oldWorkflowID, true, nil
}
func markDeferredMoveApplied(task *models.Task, moveID string) error {
if moveID == "" {
return nil
@@ -61,6 +61,37 @@ func TestWorkflowStore_LoadState(t *testing.T) {
}
}
// TestWorkflowStore_LoadState_BlankSessionIDResolvesFromTaskRow is AC-62's
// production-side companion: a task that has never had a session (the F38/
// dispatcher case for zero task_sessions rows, sessionID == "") must still
// resolve via LoadState, because CurrentStepID is derived from the task row,
// not the session. Skipping GetTaskSession/IsPassthroughSession for a blank
// sessionID mirrors the existing sessionID == "" sentinel convention used
// elsewhere in this codebase (AC-16a).
func TestWorkflowStore_LoadState_BlankSessionIDResolvesFromTaskRow(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskWithoutSession(t, repo, "t1", "step1")
agentMgr := &mockAgentManager{isPassthrough: true}
store := newWorkflowStore(repo, newMockStepGetter(), agentMgr, noopPublisher, testLogger())
state, err := store.LoadState(ctx, "t1", "")
if err != nil {
t.Fatalf("LoadState failed: %v", err)
}
if state.TaskID != "t1" {
t.Errorf("expected TaskID %q, got %q", "t1", state.TaskID)
}
if state.WorkflowID != "wf1" {
t.Errorf("expected WorkflowID %q, got %q", "wf1", state.WorkflowID)
}
if state.CurrentStepID != "step1" {
t.Errorf("expected CurrentStepID %q, got %q", "step1", state.CurrentStepID)
}
}
func TestWorkflowStore_LoadStep(t *testing.T) {
ctx := context.Background()
@@ -333,6 +364,105 @@ func TestWorkflowStore_ApplyTransitionPullsNextFeederTaskOnVacate(t *testing.T)
}
}
// TestWorkflowStore_ApplyTransitionIfAtStep_AppliesWhenStepMatches is the
// AC-46/48 orchestrator-level happy path: the CAS precondition holds, so the
// move lands and behaves like ApplyTransition (task moved, review status
// cleared, task.updated published).
func TestWorkflowStore_ApplyTransitionIfAtStep_AppliesWhenStepMatches(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
stepGetter := newMockStepGetter()
stepGetter.steps["step2"] = &wfmodels.WorkflowStep{
ID: "step2", WorkflowID: "wf1", Name: "Step 2", Position: 1,
}
pub := &capturingPublisher{}
store := newWorkflowStore(repo, stepGetter, nil, pub.publish, testLogger())
applied, err := store.ApplyTransitionIfAtStep(ctx, "t1", "s1", "step1", "step2", "on_turn_complete")
if err != nil {
t.Fatalf("ApplyTransitionIfAtStep failed: %v", err)
}
if !applied {
t.Fatal("expected applied=true when expectedStepID matches the task's persisted step")
}
task, err := repo.GetTask(ctx, "t1")
if err != nil {
t.Fatalf("failed to get task: %v", err)
}
if task.WorkflowStepID != "step2" {
t.Errorf("expected task WorkflowStepID %q, got %q", "step2", task.WorkflowStepID)
}
session, err := repo.GetTaskSession(ctx, "s1")
if err != nil {
t.Fatalf("failed to get session: %v", err)
}
if session.ReviewStatus != models.ReviewStatusNone {
t.Errorf("expected review status to be cleared, got %q", session.ReviewStatus)
}
if len(pub.calls) != 1 {
t.Fatalf("expected exactly 1 publishTaskUpdated call, got %d", len(pub.calls))
}
}
// TestWorkflowStore_ApplyTransitionIfAtStep_LostRaceReturnsFalseWithoutSideEffects
// covers the AC-46/48 abandonment path: another transition already moved the
// task off expectedStepID by the time the CAS write ran, so no event is
// published, no review-status clear runs, and the task row is untouched.
func TestWorkflowStore_ApplyTransitionIfAtStep_LostRaceReturnsFalseWithoutSideEffects(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
stepGetter := newMockStepGetter()
stepGetter.steps["step2"] = &wfmodels.WorkflowStep{
ID: "step2", WorkflowID: "wf1", Name: "Step 2", Position: 1,
}
pub := &capturingPublisher{}
store := newWorkflowStore(repo, stepGetter, nil, pub.publish, testLogger())
applied, err := store.ApplyTransitionIfAtStep(ctx, "t1", "s1", "not-the-current-step", "step2", "on_turn_complete")
if err != nil {
t.Fatalf("ApplyTransitionIfAtStep failed: %v", err)
}
if applied {
t.Fatal("expected applied=false when expectedStepID does not match the task's persisted step")
}
task, err := repo.GetTask(ctx, "t1")
if err != nil {
t.Fatalf("failed to get task: %v", err)
}
if task.WorkflowStepID != "step1" {
t.Errorf("expected task to remain at step1 after a lost CAS race, got %q", task.WorkflowStepID)
}
if len(pub.calls) != 0 {
t.Fatalf("expected no publishTaskUpdated call on a lost race, got %d", len(pub.calls))
}
}
// TestWorkflowStore_ApplyTransitionIfAtStep_ErrorsWhenTargetStepMissing
// mirrors the same guard ApplyTransition relies on for its target step
// lookup: a nil step (unknown ID) must fail loudly rather than silently
// moving the task nowhere.
func TestWorkflowStore_ApplyTransitionIfAtStep_ErrorsWhenTargetStepMissing(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
store := newWorkflowStore(repo, newMockStepGetter(), nil, noopPublisher, testLogger())
_, err := store.ApplyTransitionIfAtStep(ctx, "t1", "s1", "step1", "missing-step", "on_turn_complete")
if err == nil {
t.Fatal("expected an error when the target step does not exist")
}
}
func TestWorkflowStore_PersistData(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
@@ -586,7 +586,8 @@ func (r *Repository) UpdateTaskWithWorkflowStepAdmission(
targetStepID string,
limit int,
) (bool, error) {
return r.updateTaskWithWorkflowStepAdmission(ctx, task, targetStepID, limit, nil, false)
admitted, _, err := r.updateTaskWithWorkflowStepAdmission(ctx, task, targetStepID, limit, nil, false, "")
return admitted, err
}
// UpdateTaskWithWorkflowStepAdmissionAndState is the manual-move variant of
@@ -601,7 +602,116 @@ func (r *Repository) UpdateTaskWithWorkflowStepAdmissionAndState(
admittedState *v1.TaskState,
queueExitPending bool,
) (bool, error) {
return r.updateTaskWithWorkflowStepAdmission(ctx, task, targetStepID, limit, admittedState, queueExitPending)
admitted, _, err := r.updateTaskWithWorkflowStepAdmission(
ctx, task, targetStepID, limit, admittedState, queueExitPending, "",
)
return admitted, err
}
// UpdateTaskWithWorkflowStepAdmissionIfAtStep is the AC-46/48 compare-and-swap
// variant used by the workflow engine's guarded-transition re-evaluation
// apply path (see engine.TransitionStore.ApplyTransitionIfAtStep). The move
// is applied only if the task's persisted workflow_step_id still equals
// expectedStepID when read inside this transaction, after the workspace row
// lock — so on PostgreSQL a concurrent admission for the same workspace
// cannot race between the check and the write. applied=false means the
// precondition failed (the task already left expectedStepID by the time this
// ran) and is not an error; the task row is left untouched.
func (r *Repository) UpdateTaskWithWorkflowStepAdmissionIfAtStep(
ctx context.Context,
task *models.Task,
expectedStepID string,
targetStepID string,
limit int,
) (applied bool, err error) {
_, applied, err = r.updateTaskWithWorkflowStepAdmission(ctx, task, targetStepID, limit, nil, false, expectedStepID)
return applied, err
}
// rebaseTaskForStepAdmissionCAS applies the AC-46/48 compare-and-swap
// precondition for the CAS-guarded admission path: it reads the task's
// current step inside the transaction, after the workspace lock, via
// readTaskStepInTx so the read also takes its row-level FOR UPDATE lock on
// PostgreSQL. The workspace lock alone only serializes this method against
// other callers that take it (other admission calls); a plain writer like
// UpdateTask never takes the workspace lock and instead relies on that same
// FOR UPDATE lock via readTaskStepInTx, so a bare (lockless) SELECT here
// could read a stale expectedStepID, let UpdateTask commit a concurrent
// move, and then have this transaction's own write below overwrite it — a
// lost update. Taking the same row lock here blocks that writer until this
// transaction commits or rolls back, closing the race.
//
// This CAS path also reloads and rebases the full row (instead of writing
// the caller's snapshot) into task. The CAS caller (the workflow engine's
// guarded re-evaluation) can hold a task snapshot loaded well before this
// write — long enough for an unrelated concurrent edit (title, other
// metadata keys) to land in between. Writing that stale snapshot would
// silently clobber the concurrent edit. Only the fields this operation owns
// (workflow id, the deferred-moves metadata sub-map) are carried forward
// from the caller's request; everything else comes from the fresh row.
//
// The unconditional (expectedStepID == "") callers — manual/bulk/feeder
// moves — must NOT call this: their caller-built `task` already carries
// this operation's own field changes (Position, move-lifecycle metadata,
// …) that have no other source of truth, so rebasing from a fresh row would
// drop them instead of protecting them.
//
// Returns applied=false, err=nil when the CAS precondition fails (the task
// already left expectedStepID) — that is a lost race, not an error.
func (r *Repository) rebaseTaskForStepAdmissionCAS(
ctx context.Context,
tx *sql.Tx,
task *models.Task,
expectedStepID string,
now time.Time,
) (applied bool, err error) {
requestedWorkflowID := task.WorkflowID
requestedAppliedMoves := map[string]interface{}{}
if appliedMoves, ok := task.Metadata[models.MetaKeyAppliedDeferredMoves].(map[string]interface{}); ok {
for moveID, value := range appliedMoves {
requestedAppliedMoves[moveID] = value
}
}
_, currentStepID, found, err := r.readTaskStepInTx(ctx, tx, task.ID)
if err != nil {
return false, fmt.Errorf("read task step for admission CAS check: %w", err)
}
if !found {
return false, fmt.Errorf("%w: %s", ErrTaskNotFound, task.ID)
}
if currentStepID != expectedStepID {
return false, nil
}
currentTask, err := r.scanSingleTask(tx.QueryRowContext(ctx, r.db.Rebind(
`SELECT `+taskSelectColumns("t")+` FROM tasks t WHERE t.id = ?`), task.ID))
if err != nil {
return false, fmt.Errorf("reload task for admission CAS rebase: %w", err)
}
if requestedWorkflowID == "" {
requestedWorkflowID = currentTask.WorkflowID
}
*task = *currentTask
if requestedWorkflowID != "" {
task.WorkflowID = requestedWorkflowID
}
if task.Metadata == nil {
task.Metadata = map[string]interface{}{}
}
if len(requestedAppliedMoves) > 0 {
currentAppliedMoves := map[string]interface{}{}
if existing, ok := task.Metadata[models.MetaKeyAppliedDeferredMoves].(map[string]interface{}); ok {
for moveID, value := range existing {
currentAppliedMoves[moveID] = value
}
}
for moveID, value := range requestedAppliedMoves {
currentAppliedMoves[moveID] = value
}
task.Metadata[models.MetaKeyAppliedDeferredMoves] = currentAppliedMoves
}
task.UpdatedAt = now
return true, nil
}
func (r *Repository) updateTaskWithWorkflowStepAdmission(
@@ -611,7 +721,8 @@ func (r *Repository) updateTaskWithWorkflowStepAdmission(
limit int,
admittedState *v1.TaskState,
queueExitPending bool,
) (bool, error) {
expectedStepID string,
) (admitted bool, applied bool, err error) {
now := time.Now().UTC()
task.UpdatedAt = now
if task.Metadata == nil {
@@ -620,7 +731,7 @@ func (r *Repository) updateTaskWithWorkflowStepAdmission(
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return false, err
return false, false, err
}
defer func() { _ = tx.Rollback() }()
@@ -635,21 +746,37 @@ func (r *Repository) updateTaskWithWorkflowStepAdmission(
// Preserve the ErrTaskNotFound sentinel callers relied on before
// the workspace read was introduced (a task deleted concurrently
// with a move is reachable).
return false, fmt.Errorf("%w: %s", ErrTaskNotFound, task.ID)
return false, false, fmt.Errorf("%w: %s", ErrTaskNotFound, task.ID)
}
return false, fmt.Errorf("read task workspace for admission: %w", err)
return false, false, fmt.Errorf("read task workspace for admission: %w", err)
}
if err := r.lockWorkspaceRowStdTx(ctx, tx, workspaceID); err != nil {
return false, err
return false, false, err
}
// AC-46/48 compare-and-swap precondition, only for CAS callers (see
// rebaseTaskForStepAdmissionCAS for the full rationale). A mismatch means
// the task already left expectedStepID (lost the race) — that is
// reported as applied=false, not an error, and the transaction is rolled
// back untouched.
if expectedStepID != "" {
casApplied, err := r.rebaseTaskForStepAdmissionCAS(ctx, tx, task, expectedStepID, now)
if err != nil {
return false, false, err
}
if !casApplied {
return false, false, nil
}
}
if err := lockWorkflowStepForCapacity(ctx, tx, r.db.DriverName(), r.db.Rebind, targetStepID); err != nil {
return false, err
return false, false, err
}
occupants, err := r.countAdmittedInTx(ctx, tx, targetStepID, task.ID)
if err != nil {
return false, err
return false, false, err
}
admitted := task.IsEphemeral || limit <= 0 || occupants < limit
admitted = task.IsEphemeral || limit <= 0 || occupants < limit
task.WorkflowStepID = targetStepID
if admitted {
task.WIPAdmitted = !task.IsEphemeral
@@ -677,12 +804,12 @@ func (r *Repository) updateTaskWithWorkflowStepAdmission(
metadata = []byte("{}")
}
if err := r.updateTaskTx(ctx, tx, task, metadata); err != nil {
return false, err
return false, false, err
}
if err := tx.Commit(); err != nil {
return false, err
return false, false, err
}
return admitted, nil
return admitted, true, nil
}
// RemoveTaskMetadataKey removes one metadata key without replacing concurrent
@@ -0,0 +1,196 @@
package sqlite
import (
"context"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/testutil"
)
// TestPostgresUpdateTaskWithWorkflowStepAdmissionIfAtStep_ConcurrentRace is
// the AC-46/48 PostgreSQL counterpart to
// TestPostgresUpdateTaskWithWorkflowStepAdmission_ConcurrentLastSlot: it
// exercises the same workspace-row lock the CAS variant reuses, proving that
// with several real connections racing the same expectedStepID
// precondition, exactly one attempt observes a match and applies.
// Skips unless KANDEV_TEST_POSTGRES_DSN is set.
func TestPostgresUpdateTaskWithWorkflowStepAdmissionIfAtStep_ConcurrentRace(t *testing.T) {
const (
concurrency = 8
sourceStep = "postgres-cas-source"
targetStep = "postgres-cas-target"
)
db := openIsolatedPostgresMultiConn(t, testutil.PostgresDSNFromEnv(t), concurrency)
repo, err := NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("init postgres schema: %v", err)
}
ctx := context.Background()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "postgres-cas-workspace", Name: "Postgres CAS workspace"}); err != nil {
t.Fatalf("seed workspace: %v", err)
}
for i, stepID := range []string{sourceStep, targetStep} {
if _, err := db.Exec(db.Rebind(`
INSERT INTO workflow_steps (id, workflow_id, name, position)
VALUES (?, ?, ?, ?)
`), stepID, "postgres-cas-workflow", stepID, i); err != nil {
t.Fatalf("seed workflow step %s: %v", stepID, err)
}
}
if err := repo.CreateTask(ctx, &models.Task{
ID: "postgres-cas-task", WorkspaceID: "postgres-cas-workspace", WorkflowID: "postgres-cas-workflow",
WorkflowStepID: sourceStep, Title: "CAS race candidate", WIPAdmitted: true,
}); err != nil {
t.Fatalf("seed task: %v", err)
}
start := make(chan struct{})
results := make(chan struct {
applied bool
err error
}, concurrency)
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
own, err := repo.GetTask(ctx, "postgres-cas-task")
if err != nil {
results <- struct {
applied bool
err error
}{false, err}
return
}
applied, err := repo.UpdateTaskWithWorkflowStepAdmissionIfAtStep(ctx, own, sourceStep, targetStep, 0)
results <- struct {
applied bool
err error
}{applied, err}
}()
}
close(start)
wg.Wait()
close(results)
appliedCount := 0
for result := range results {
if result.err != nil {
t.Fatalf("concurrent CAS attempt: %v", result.err)
}
if result.applied {
appliedCount++
}
}
if appliedCount != 1 {
t.Fatalf("expected exactly 1 concurrent CAS attempt to apply, got %d", appliedCount)
}
final, err := repo.GetTask(ctx, "postgres-cas-task")
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if final.WorkflowStepID != targetStep {
t.Fatalf("expected the task to end at %s, got %q", targetStep, final.WorkflowStepID)
}
}
// TestPostgresUpdateTaskWithWorkflowStepAdmissionIfAtStep_PreconditionReadLocksRow
// is the regression coverage for the lost-update race the CAS precondition
// check used to be exposed to: before the fix, the expectedStepID check in
// updateTaskWithWorkflowStepAdmission read the task's current step with a
// bare, lock-free SELECT, so a concurrent plain UpdateTask could commit a
// step change in the window between that read and the CAS transaction's own
// write, and the CAS write would silently clobber it. The fix makes the
// precondition read go through readTaskStepInTx, which takes `FOR UPDATE` on
// Postgres — the same lock every other task-row writer in this file already
// takes before mutating workflow_step_id.
//
// TestPostgresUpdateTaskWithWorkflowStepAdmissionIfAtStep_ConcurrentRace above
// only proves two CAS attempts serialize against each other; it does not
// exercise CAS-vs-plain-UpdateTask, which is the exact interleaving this test
// targets. It does so directly: hold the row's FOR UPDATE lock via the same
// query readTaskStepInTx issues, prove a concurrent UpdateTask call blocks
// until the lock is released, then prove it observes the up-to-date step once
// it acquires the lock. Skips unless KANDEV_TEST_POSTGRES_DSN is set.
func TestPostgresUpdateTaskWithWorkflowStepAdmissionIfAtStep_PreconditionReadLocksRow(t *testing.T) {
const (
sourceStep = "postgres-cas-lock-source"
otherStep = "postgres-cas-lock-other"
)
db := openIsolatedPostgresMultiConn(t, testutil.PostgresDSNFromEnv(t), 2)
repo, err := NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("init postgres schema: %v", err)
}
ctx := context.Background()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "postgres-cas-lock-workspace", Name: "Postgres CAS lock workspace"}); err != nil {
t.Fatalf("seed workspace: %v", err)
}
for i, stepID := range []string{sourceStep, otherStep} {
if _, err := db.Exec(db.Rebind(`
INSERT INTO workflow_steps (id, workflow_id, name, position)
VALUES (?, ?, ?, ?)
`), stepID, "postgres-cas-lock-workflow", stepID, i); err != nil {
t.Fatalf("seed workflow step %s: %v", stepID, err)
}
}
task := &models.Task{
ID: "postgres-cas-lock-task", WorkspaceID: "postgres-cas-lock-workspace", WorkflowID: "postgres-cas-lock-workflow",
WorkflowStepID: sourceStep, Title: "CAS lock candidate", WIPAdmitted: true,
}
if err := repo.CreateTask(ctx, task); err != nil {
t.Fatalf("seed task: %v", err)
}
// Hold the same row lock readTaskStepInTx takes, simulating a CAS
// attempt paused between its precondition read and its commit.
holder, err := db.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin holder tx: %v", err)
}
var wf, step string
if err := holder.QueryRowContext(ctx, db.Rebind(
`SELECT workflow_id, workflow_step_id FROM tasks WHERE id = ? FOR UPDATE`), task.ID,
).Scan(&wf, &step); err != nil {
t.Fatalf("holder lock read: %v", err)
}
updateDone := make(chan error, 1)
go func() {
moved := &models.Task{ID: task.ID, WorkspaceID: task.WorkspaceID, WorkflowID: task.WorkflowID,
WorkflowStepID: otherStep, Title: task.Title, Priority: task.Priority, WIPAdmitted: true}
updateDone <- repo.UpdateTask(ctx, moved)
}()
select {
case err := <-updateDone:
t.Fatalf("expected concurrent UpdateTask to block while the CAS-style FOR UPDATE lock is held, but it completed (err=%v)", err)
case <-time.After(200 * time.Millisecond):
// Still blocked, as expected.
}
if err := holder.Commit(); err != nil {
t.Fatalf("commit holder tx: %v", err)
}
select {
case err := <-updateDone:
if err != nil {
t.Fatalf("UpdateTask after lock release: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("expected the blocked UpdateTask to complete once the holder lock was released")
}
final, err := repo.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if final.WorkflowStepID != otherStep {
t.Fatalf("expected the task to end at %s once UpdateTask acquired the lock, got %q", otherStep, final.WorkflowStepID)
}
}
@@ -0,0 +1,276 @@
package sqlite
import (
"context"
"errors"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
)
// seedCASWorkflowStep inserts a bare workflow step row for the compare-and-
// swap admission tests below, which only need CreateTask's WorkflowStepID
// foreign key to resolve, not a full workflow.
func seedCASWorkflowStep(t *testing.T, repo *Repository, workflowID, stepID string, position int) {
t.Helper()
now := time.Now().UTC()
if _, err := repo.db.Exec(repo.db.Rebind(`INSERT INTO workflow_steps
(id, workflow_id, name, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`),
stepID, workflowID, stepID, position, now, now); err != nil {
t.Fatalf("seed workflow step %s: %v", stepID, err)
}
}
// TestUpdateTaskWithWorkflowStepAdmissionIfAtStep_AppliesOnlyWhenExpectedStepMatches
// is the AC-46/48 functional (non-concurrent) coverage for the new CAS
// repository method: applied=true and the admission/queue semantics of the
// unconditional UpdateTaskWithWorkflowStepAdmission are preserved when the
// precondition holds, and applied=false with the task row left untouched
// when it does not.
func TestUpdateTaskWithWorkflowStepAdmissionIfAtStep_AppliesOnlyWhenExpectedStepMatches(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "workspace-cas")
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "workflow-cas", WorkspaceID: "workspace-cas", Name: "Workflow"}); err != nil {
t.Fatal(err)
}
seedCASWorkflowStep(t, repo, "workflow-cas", "step-source", 0)
seedCASWorkflowStep(t, repo, "workflow-cas", "step-target", 1)
task := &models.Task{
ID: "task-cas-1", WorkspaceID: "workspace-cas", WorkflowID: "workflow-cas",
WorkflowStepID: "step-source", Title: "CAS candidate", WIPAdmitted: true,
}
if err := repo.CreateTask(ctx, task); err != nil {
t.Fatalf("CreateTask: %v", err)
}
// Mismatched expectedStepID: the task has already left "step-source" from
// this call's point of view (it's still there, but we assert a different
// precondition) — applied must be false, no error, and the row untouched.
applied, err := repo.UpdateTaskWithWorkflowStepAdmissionIfAtStep(ctx, task, "not-the-current-step", "step-target", 0)
if err != nil {
t.Fatalf("UpdateTaskWithWorkflowStepAdmissionIfAtStep (mismatch): %v", err)
}
if applied {
t.Fatalf("expected applied=false when expectedStepID does not match the persisted step")
}
reloaded, err := repo.GetTask(ctx, "task-cas-1")
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if reloaded.WorkflowStepID != "step-source" {
t.Fatalf("expected task to remain at step-source after a lost CAS, got %q", reloaded.WorkflowStepID)
}
// Matching expectedStepID: applied=true and the move lands, same as the
// unconditional variant.
applied, err = repo.UpdateTaskWithWorkflowStepAdmissionIfAtStep(ctx, task, "step-source", "step-target", 0)
if err != nil {
t.Fatalf("UpdateTaskWithWorkflowStepAdmissionIfAtStep (match): %v", err)
}
if !applied {
t.Fatalf("expected applied=true when expectedStepID matches the persisted step")
}
reloaded, err = repo.GetTask(ctx, "task-cas-1")
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if reloaded.WorkflowStepID != "step-target" || !reloaded.WIPAdmitted {
t.Fatalf("expected task admitted at step-target, got %+v", reloaded)
}
}
func TestUpdateTaskWithWorkflowStepAdmissionIfAtStep_PreservesConcurrentTaskEdits(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "workspace-cas-preserve")
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "workflow-cas-preserve", WorkspaceID: "workspace-cas-preserve", Name: "Workflow"}); err != nil {
t.Fatal(err)
}
seedCASWorkflowStep(t, repo, "workflow-cas-preserve", "step-source", 0)
seedCASWorkflowStep(t, repo, "workflow-cas-preserve", "step-target", 1)
task := &models.Task{
ID: "task-cas-preserve", WorkspaceID: "workspace-cas-preserve", WorkflowID: "workflow-cas-preserve",
WorkflowStepID: "step-source", Title: "original", WIPAdmitted: true,
}
if err := repo.CreateTask(ctx, task); err != nil {
t.Fatalf("CreateTask: %v", err)
}
stale, err := repo.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("GetTask: %v", err)
}
current, err := repo.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("GetTask current: %v", err)
}
current.Title = "edited concurrently"
if err := repo.UpdateTask(ctx, current); err != nil {
t.Fatalf("UpdateTask: %v", err)
}
applied, err := repo.UpdateTaskWithWorkflowStepAdmissionIfAtStep(ctx, stale, "step-source", "step-target", 0)
if err != nil {
t.Fatalf("UpdateTaskWithWorkflowStepAdmissionIfAtStep: %v", err)
}
if !applied {
t.Fatal("expected the step compare-and-swap to apply")
}
reloaded, err := repo.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("GetTask after CAS: %v", err)
}
if reloaded.Title != "edited concurrently" {
t.Fatalf("title = %q, want concurrent edit preserved", reloaded.Title)
}
}
// TestUpdateTaskWithWorkflowStepAdmissionIfAtStep_QueuesWhenStepIsFull mirrors
// the unconditional admission method's "limited full target queues instead
// of rejecting" behavior (it never rejects for WIP capacity, only for the
// CAS precondition), proving the CAS variant did not regress that contract.
func TestUpdateTaskWithWorkflowStepAdmissionIfAtStep_QueuesWhenStepIsFull(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "workspace-cas-full")
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "workflow-cas-full", WorkspaceID: "workspace-cas-full", Name: "Workflow"}); err != nil {
t.Fatal(err)
}
seedCASWorkflowStep(t, repo, "workflow-cas-full", "step-source", 0)
seedCASWorkflowStep(t, repo, "workflow-cas-full", "step-target", 1)
occupant := &models.Task{
ID: "task-cas-occupant", WorkspaceID: "workspace-cas-full", WorkflowID: "workflow-cas-full",
WorkflowStepID: "step-target", Title: "Occupant", WIPAdmitted: true,
}
if err := repo.CreateTask(ctx, occupant); err != nil {
t.Fatalf("CreateTask(occupant): %v", err)
}
candidate := &models.Task{
ID: "task-cas-candidate", WorkspaceID: "workspace-cas-full", WorkflowID: "workflow-cas-full",
WorkflowStepID: "step-source", Title: "Candidate", WIPAdmitted: true,
}
if err := repo.CreateTask(ctx, candidate); err != nil {
t.Fatalf("CreateTask(candidate): %v", err)
}
applied, err := repo.UpdateTaskWithWorkflowStepAdmissionIfAtStep(ctx, candidate, "step-source", "step-target", 1)
if err != nil {
t.Fatalf("UpdateTaskWithWorkflowStepAdmissionIfAtStep: %v", err)
}
if !applied {
t.Fatalf("expected applied=true: the CAS precondition held even though the step is at capacity")
}
reloaded, err := repo.GetTask(ctx, "task-cas-candidate")
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if reloaded.WorkflowStepID != "step-target" {
t.Fatalf("expected the task to move into step-target (queued, not rejected), got %q", reloaded.WorkflowStepID)
}
if reloaded.WIPAdmitted {
t.Fatalf("expected WIPAdmitted=false for a queued task at a full step")
}
if reloaded.QueuedForStepID != "step-target" {
t.Fatalf("expected QueuedForStepID=step-target, got %q", reloaded.QueuedForStepID)
}
}
// TestUpdateTaskWithWorkflowStepAdmissionIfAtStep_TaskNotFound preserves the
// ErrTaskNotFound sentinel the unconditional variant guarantees, for a task
// deleted concurrently with the CAS attempt.
func TestUpdateTaskWithWorkflowStepAdmissionIfAtStep_TaskNotFound(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
missing := &models.Task{ID: "task-cas-missing", WorkspaceID: "does-not-exist"}
_, err := repo.UpdateTaskWithWorkflowStepAdmissionIfAtStep(ctx, missing, "step-source", "step-target", 0)
if !errors.Is(err, ErrTaskNotFound) {
t.Fatalf("expected ErrTaskNotFound, got %v", err)
}
}
// TestUpdateTaskWithWorkflowStepAdmissionIfAtStep_ConcurrentRace proves two
// concurrent CAS attempts against the same expected-step never both apply on
// SQLite. Unlike the PostgreSQL row-lock test
// (TestPostgresUpdateTaskWithWorkflowStepAdmission_ConcurrentLastSlot), this
// does not exercise a dialect-specific row lock (SQLite's admission path
// takes no explicit lock — see updateTaskWithWorkflowStepAdmission) but
// SQLite's single-writer semantics still serialize the two write
// transactions, so at most one CAS precondition can observe "step-source"
// and win.
func TestUpdateTaskWithWorkflowStepAdmissionIfAtStep_ConcurrentRace(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "workspace-cas-race")
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "workflow-cas-race", WorkspaceID: "workspace-cas-race", Name: "Workflow"}); err != nil {
t.Fatal(err)
}
seedCASWorkflowStep(t, repo, "workflow-cas-race", "step-source", 0)
seedCASWorkflowStep(t, repo, "workflow-cas-race", "step-target", 1)
task := &models.Task{
ID: "task-cas-race", WorkspaceID: "workspace-cas-race", WorkflowID: "workflow-cas-race",
WorkflowStepID: "step-source", Title: "Race candidate", WIPAdmitted: true,
}
if err := repo.CreateTask(ctx, task); err != nil {
t.Fatalf("CreateTask: %v", err)
}
const attempts = 8
start := make(chan struct{})
results := make(chan struct {
applied bool
err error
}, attempts)
done := make(chan struct{})
for i := 0; i < attempts; i++ {
go func() {
<-start
// Each goroutine loads its own copy so concurrent in-memory
// mutation of a shared *models.Task is not itself the source of
// any observed serialization.
own, err := repo.GetTask(ctx, "task-cas-race")
if err != nil {
results <- struct {
applied bool
err error
}{false, err}
return
}
applied, err := repo.UpdateTaskWithWorkflowStepAdmissionIfAtStep(ctx, own, "step-source", "step-target", 0)
results <- struct {
applied bool
err error
}{applied, err}
}()
}
go func() {
close(start)
close(done)
}()
<-done
appliedCount := 0
for i := 0; i < attempts; i++ {
r := <-results
if r.err != nil {
t.Fatalf("concurrent CAS attempt: %v", r.err)
}
if r.applied {
appliedCount++
}
}
if appliedCount != 1 {
t.Fatalf("expected exactly one concurrent CAS attempt to apply, got %d", appliedCount)
}
reloaded, err := repo.GetTask(ctx, "task-cas-race")
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if reloaded.WorkflowStepID != "step-target" {
t.Fatalf("expected the task to have moved to step-target exactly once, got %q", reloaded.WorkflowStepID)
}
}
@@ -21,6 +21,7 @@ import (
// adapters need. Defined as an interface so tests can swap in fakes.
type WorkflowRepo interface {
ListStepParticipantsForTask(ctx context.Context, stepID, taskID string) ([]*models.WorkflowStepParticipant, error)
ListParticipantsForTaskAnyStep(ctx context.Context, taskID string) ([]*models.WorkflowStepParticipant, error)
ListStepDecisions(ctx context.Context, taskID, stepID string) ([]*models.WorkflowStepDecision, error)
RecordStepDecision(ctx context.Context, d *models.WorkflowStepDecision) error
ClearStepDecisions(ctx context.Context, taskID, stepID string) (int64, error)
@@ -28,6 +29,10 @@ type WorkflowRepo interface {
GetTaskWorkflowStepID(ctx context.Context, taskID string) (string, error)
}
type workflowScopedParticipantRepo interface {
ListParticipantsForTaskWorkflow(ctx context.Context, taskID, workflowID string) ([]*models.WorkflowStepParticipant, error)
}
// Compile-time check that *repository.Repository satisfies WorkflowRepo.
var _ WorkflowRepo = (*repository.Repository)(nil)
@@ -66,6 +71,60 @@ func (a *ParticipantAdapter) ListStepParticipants(
return out, nil
}
// ListTaskParticipants satisfies engine.ParticipantStore. It returns every
// per-task participant row for the task regardless of step_id, unmerged
// with template-level rows — the AC-49 port the quorum engine's
// requiredSeats uses to count participation that persists across steps.
func (a *ParticipantAdapter) ListTaskParticipants(
ctx context.Context, taskID string,
) ([]engine.ParticipantInfo, error) {
rows, err := a.Repo.ListParticipantsForTaskAnyStep(ctx, taskID)
if err != nil {
return nil, fmt.Errorf("list task participants: %w", err)
}
out := make([]engine.ParticipantInfo, 0, len(rows))
for _, p := range rows {
out = append(out, engine.ParticipantInfo{
ID: p.ID,
StepID: p.StepID,
TaskID: p.TaskID,
Role: string(p.Role),
AgentProfileID: p.AgentProfileID,
DecisionRequired: p.DecisionRequired,
Position: p.Position,
})
}
return out, nil
}
// ListTaskParticipantsForWorkflow satisfies engine.WorkflowScopedParticipantStore
// when the repository can filter durable task overrides by active workflow.
func (a *ParticipantAdapter) ListTaskParticipantsForWorkflow(
ctx context.Context, taskID, workflowID string,
) ([]engine.ParticipantInfo, error) {
repo, ok := a.Repo.(workflowScopedParticipantRepo)
if !ok {
return a.ListTaskParticipants(ctx, taskID)
}
rows, err := repo.ListParticipantsForTaskWorkflow(ctx, taskID, workflowID)
if err != nil {
return nil, fmt.Errorf("list task participants for workflow: %w", err)
}
out := make([]engine.ParticipantInfo, 0, len(rows))
for _, p := range rows {
out = append(out, engine.ParticipantInfo{
ID: p.ID,
StepID: p.StepID,
TaskID: p.TaskID,
Role: string(p.Role),
AgentProfileID: p.AgentProfileID,
DecisionRequired: p.DecisionRequired,
Position: p.Position,
})
}
return out, nil
}
// WorkflowStepIDForTask satisfies engine.TargetTaskStepResolver.
func (a *ParticipantAdapter) WorkflowStepIDForTask(ctx context.Context, taskID string) (string, error) {
return a.Repo.GetTaskWorkflowStepID(ctx, taskID)
@@ -98,6 +157,11 @@ func (a *DecisionAdapter) ListStepDecisions(
ParticipantID: d.ParticipantID,
Decision: d.Decision,
Note: d.Note,
DecidedAt: d.DecidedAt,
DeciderType: d.DeciderType,
DeciderID: d.DeciderID,
Role: d.Role,
Comment: d.Comment,
})
}
return out, nil
@@ -114,6 +178,11 @@ func (a *DecisionAdapter) RecordStepDecision(
ParticipantID: d.ParticipantID,
Decision: d.Decision,
Note: d.Note,
DecidedAt: d.DecidedAt,
DeciderType: d.DeciderType,
DeciderID: d.DeciderID,
Role: d.Role,
Comment: d.Comment,
}
return a.Repo.RecordStepDecision(ctx, row)
}
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"testing"
"time"
"github.com/kandev/kandev/internal/workflow/engine"
"github.com/kandev/kandev/internal/workflow/models"
@@ -13,10 +14,11 @@ import (
// straight to the repo so we only need stub responses to verify the
// type translation between models / engine packages.
type fakeRepo struct {
participants []*models.WorkflowStepParticipant
decisions []*models.WorkflowStepDecision
runner string
taskStepID string
participants []*models.WorkflowStepParticipant
taskParticipants []*models.WorkflowStepParticipant
decisions []*models.WorkflowStepDecision
runner string
taskStepID string
listErr error
recordErr error
@@ -34,6 +36,9 @@ type fakeRepo struct {
func (f *fakeRepo) ListStepParticipantsForTask(_ context.Context, _, _ string) ([]*models.WorkflowStepParticipant, error) {
return f.participants, f.listErr
}
func (f *fakeRepo) ListParticipantsForTaskAnyStep(_ context.Context, _ string) ([]*models.WorkflowStepParticipant, error) {
return f.taskParticipants, f.listErr
}
func (f *fakeRepo) ListStepDecisions(_ context.Context, _, _ string) ([]*models.WorkflowStepDecision, error) {
return f.decisions, f.listErr
}
@@ -95,6 +100,37 @@ func TestParticipantAdapter_PropagatesError(t *testing.T) {
}
}
func TestParticipantAdapter_ListTaskParticipants_TranslatesModelToEngineInfo(t *testing.T) {
repo := &fakeRepo{
taskParticipants: []*models.WorkflowStepParticipant{
{
ID: "p-1", StepID: "s-other", TaskID: "task-1",
Role: models.ParticipantRoleReviewer, AgentProfileID: "rev-A",
DecisionRequired: true, Position: 1,
},
},
}
a := NewParticipantAdapter(repo)
got, err := a.ListTaskParticipants(context.Background(), "task-1")
if err != nil {
t.Fatalf("ListTaskParticipants: %v", err)
}
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if got[0].StepID != "s-other" || got[0].TaskID != "task-1" || got[0].Role != "reviewer" {
t.Fatalf("unexpected translation: %+v", got[0])
}
}
func TestParticipantAdapter_ListTaskParticipants_PropagatesError(t *testing.T) {
repo := &fakeRepo{listErr: errors.New("boom")}
a := NewParticipantAdapter(repo)
if _, err := a.ListTaskParticipants(context.Background(), "task-1"); err == nil {
t.Fatal("expected error")
}
}
func TestDecisionAdapter_RecordTranslatesEngineInfoToModel(t *testing.T) {
repo := &fakeRepo{}
a := NewDecisionAdapter(repo)
@@ -113,6 +149,23 @@ func TestDecisionAdapter_RecordTranslatesEngineInfoToModel(t *testing.T) {
}
}
func TestDecisionAdapter_RecordCarriesDeciderIdentityAndComment(t *testing.T) {
repo := &fakeRepo{}
a := NewDecisionAdapter(repo)
err := a.RecordStepDecision(context.Background(), engine.DecisionInfo{
ID: "d-1", TaskID: "t-1", StepID: "s-1",
ParticipantID: "p-1", Decision: "approved",
DeciderType: "agent", DeciderID: "rev-A", Role: "reviewer", Comment: "looks good",
})
if err != nil {
t.Fatalf("record: %v", err)
}
if repo.recorded.DeciderType != "agent" || repo.recorded.DeciderID != "rev-A" ||
repo.recorded.Role != "reviewer" || repo.recorded.Comment != "looks good" {
t.Fatalf("decider identity/comment not carried through: %+v", repo.recorded)
}
}
func TestDecisionAdapter_List_TranslatesAllRows(t *testing.T) {
repo := &fakeRepo{
decisions: []*models.WorkflowStepDecision{
@@ -130,6 +183,58 @@ func TestDecisionAdapter_List_TranslatesAllRows(t *testing.T) {
}
}
func TestDecisionAdapter_Record_CarriesDecidedAt(t *testing.T) {
repo := &fakeRepo{}
a := NewDecisionAdapter(repo)
decidedAt := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)
err := a.RecordStepDecision(context.Background(), engine.DecisionInfo{
ID: "d-1", TaskID: "t-1", StepID: "s-1",
ParticipantID: "p-1", Decision: "approved", DecidedAt: decidedAt,
})
if err != nil {
t.Fatalf("record: %v", err)
}
if !repo.recorded.DecidedAt.Equal(decidedAt) {
t.Fatalf("decided_at not carried through: got %v, want %v", repo.recorded.DecidedAt, decidedAt)
}
}
func TestDecisionAdapter_List_CarriesDecidedAt(t *testing.T) {
decidedAt := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)
repo := &fakeRepo{
decisions: []*models.WorkflowStepDecision{
{ID: "d-1", Decision: "approved", DecidedAt: decidedAt},
},
}
a := NewDecisionAdapter(repo)
got, err := a.ListStepDecisions(context.Background(), "t", "s")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 1 || !got[0].DecidedAt.Equal(decidedAt) {
t.Fatalf("decided_at not carried through: %+v", got)
}
}
func TestDecisionAdapter_List_CarriesDeciderIdentityAndComment(t *testing.T) {
repo := &fakeRepo{
decisions: []*models.WorkflowStepDecision{
{ID: "d-1", Decision: "rejected", DeciderType: "user", DeciderID: "user-1", Role: "reviewer", Comment: "needs work"},
},
}
a := NewDecisionAdapter(repo)
got, err := a.ListStepDecisions(context.Background(), "t", "s")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if got[0].DeciderType != "user" || got[0].DeciderID != "user-1" || got[0].Role != "reviewer" || got[0].Comment != "needs work" {
t.Fatalf("decider identity/comment not carried through: %+v", got[0])
}
}
func TestDecisionAdapter_ClearForwards(t *testing.T) {
repo := &fakeRepo{decisions: []*models.WorkflowStepDecision{{ID: "d-1"}}}
a := NewDecisionAdapter(repo)
@@ -1,6 +1,9 @@
package engine
import "context"
import (
"context"
"time"
)
// RunQueueAdapter is the engine's contract with the runs queue. The Phase 2
// final integration emits QueueRun requests through this interface; Phase 3
@@ -45,6 +48,17 @@ type ParticipantInfo struct {
}
// DecisionInfo is a lightweight projection of a workflow_step_decisions row.
//
// DeciderType, DeciderID, Role and Comment carry the decider's identity and
// human-facing reason. Role is the role the decision was recorded under
// (which may differ from a guard's role — AC-42/58). Comment is the
// human-facing reason column; Note is a separate, unrelated field no Office
// surface reads.
//
// ID and DecidedAt are stamped by Engine.RecordParticipantDecision (AC-66,
// AC-57b-i) before the write — DecisionStore.RecordStepDecision receives
// DecisionInfo by value and never generates or echoes back either field, so
// a caller cannot read them off the row after the call.
type DecisionInfo struct {
ID string
TaskID string
@@ -52,6 +66,12 @@ type DecisionInfo struct {
ParticipantID string
Decision string
Note string
DecidedAt time.Time
DeciderType string
DeciderID string
Role string
Comment string
}
// ParticipantStore reads the workflow_step_participants table for an engine
@@ -64,8 +84,25 @@ type DecisionInfo struct {
// template-level rows with per-task rows for that task and return the
// resolved set. Returning nil/empty list is valid and signals a
// single-agent step for that task.
//
// ListTaskParticipants returns every per-task row (task_id = taskID)
// regardless of step_id — the AC-49 port that makes AC-18's cross-step
// counting expressible. Template rows are never returned by this method;
// callers combine it with ListStepParticipants(stepID, "") to gather
// template rows scoped to one step (AC-50 step 1). An empty result is
// valid, not an error.
type ParticipantStore interface {
ListStepParticipants(ctx context.Context, stepID, taskID string) ([]ParticipantInfo, error)
ListTaskParticipants(ctx context.Context, taskID string) ([]ParticipantInfo, error)
}
// WorkflowScopedParticipantStore is an optional refinement of
// ParticipantStore. It limits per-task overrides to the task's active
// workflow, so rows left by a previous workflow cannot contribute to a new
// workflow's quorum. The base interface remains small for plugins and test
// doubles that do not have workflow-aware storage.
type WorkflowScopedParticipantStore interface {
ListTaskParticipantsForWorkflow(ctx context.Context, taskID, workflowID string) ([]ParticipantInfo, error)
}
// DecisionStore reads and writes workflow_step_decisions rows. The engine
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"maps"
"github.com/kandev/kandev/internal/common/logger"
)
// MachineState captures runtime workflow state for a task session.
@@ -57,6 +59,18 @@ type TransitionStore interface {
LoadNextStep(ctx context.Context, workflowID string, currentPosition int) (StepSpec, error)
LoadPreviousStep(ctx context.Context, workflowID string, currentPosition int) (StepSpec, error)
ApplyTransition(ctx context.Context, taskID, sessionID, fromStepID, toStepID string, trigger Trigger) error
// ApplyTransitionIfAtStep is the AC-46/48 compare-and-swap transition
// commit used only by the AC-46 quorum re-evaluation apply path. It
// applies the transition only when the task is still at expectedStepID,
// reading and writing inside the same transaction so a concurrent mover
// cannot race past the check. applied=false ("abandoned") means the task
// had already left expectedStepID by the time this call ran — not an
// error. applied=true means the task left expectedStepID (not
// necessarily WIP-admitted at toStepID — a full step still counts as a
// completed transition, queuing there). ApplyTransition (and every
// existing caller of it) is unaffected: this is an additive method, not
// a replacement.
ApplyTransitionIfAtStep(ctx context.Context, taskID, sessionID, expectedStepID, toStepID string, trigger Trigger) (applied bool, err error)
PersistData(ctx context.Context, sessionID string, data map[string]any) error
IsOperationApplied(ctx context.Context, operationID string) (bool, error)
MarkOperationApplied(ctx context.Context, operationID string) error
@@ -83,9 +97,21 @@ type HandleResult struct {
Transitioned bool
FromStepID string
ToStepID string
DataPatch map[string]any
Idempotent bool
ActionCount int
// Guards is the read-only quorum state captured while evaluating a
// guarded transition. Decision recording returns this snapshot even when
// the successful evaluation moves the task to the next step.
Guards []QuorumGuardState
DataPatch map[string]any
Idempotent bool
ActionCount int
// TransitionAbandoned is true when a guarded transition's outcome was
// satisfied but ApplyTransitionIfAtStep (AC-46/48) lost the race — the
// task had already left FromStepID by the time the CAS ran. Distinct
// from both a successful transition (Transitioned=true) and an error:
// abandoning is an expected outcome of concurrent re-evaluation, not a
// failure.
TransitionAbandoned bool
}
// Option configures an Engine at construction time. Use With* helpers below.
@@ -127,6 +153,14 @@ func WithWorkflowSwitcher(switcher WorkflowSwitcher) Option {
return func(e *Engine) { e.workflowSwitcher = switcher }
}
// WithLogger wires the AC-24/24a structured log emitted when a guarded
// transition is evaluated and does not fire. The engine holds no logger
// today, so wiring one is part of this feature — without it, the engine
// still increments the quorummetrics counter, it just doesn't log.
func WithLogger(log *logger.Logger) Option {
return func(e *Engine) { e.logger = log }
}
// Engine evaluates step actions and applies transitions.
type Engine struct {
store TransitionStore
@@ -141,6 +175,9 @@ type Engine struct {
// Phase 8 (ADR-0004) dependencies — also nil-safe.
taskCreator TaskCreator
workflowSwitcher WorkflowSwitcher
// logger is nil-safe (AC-24): *logger.Logger methods are not nil-safe
// themselves, so every use is guarded by an explicit nil check.
logger *logger.Logger
}
// TaskCreatorAdapter exposes the wired TaskCreator (or nil if unset).
@@ -272,11 +309,9 @@ func (e *Engine) evaluateActions(
dataPatch := map[string]any{}
for _, action := range actions {
if targetStepID == "" && isTransitionAction(action.Kind) && !action.RequiresApproval {
permitted, err := e.evaluateTransitionGuard(ctx, state, action)
if err != nil {
return "", nil, err
}
if !permitted {
outcome := e.evaluateTransitionGuard(ctx, state, action)
if !outcome.Satisfied {
e.recordGuardNotFired(state, action, outcome)
continue
}
resolvedTarget, err := e.resolveTransitionTarget(ctx, state, step, action)
@@ -53,6 +53,18 @@ func (s *fakeStore) ApplyTransition(_ context.Context, _, _, fromStepID, toStepI
return nil
}
func (s *fakeStore) ApplyTransitionIfAtStep(
_ context.Context, _, _, expectedStepID, toStepID string, _ Trigger,
) (bool, error) {
if s.state.CurrentStepID != expectedStepID {
return false, nil
}
s.transitionFrom = expectedStepID
s.transitionTo = toStepID
s.state.CurrentStepID = toStepID
return true, nil
}
func (s *fakeStore) PersistData(_ context.Context, _ string, data map[string]any) error {
s.persistedData = data
return nil
@@ -167,10 +167,15 @@ func TestOfficeDefaultWorkflow_RejectRoutesBackToWork(t *testing.T) {
store.setCurrentStep(steps["review"].ID)
// One reviewer rejects, one approves — any_reject fires first.
// One reviewer rejects, one approves — any_reject fires first. any_reject
// is a decider-identity veto (AC-43/58), not a seat-mapped decision, so
// the rejection carries the decider's identity and the role the guard
// checks against rather than a seat id.
if err := decisions.RecordStepDecision(ctx, DecisionInfo{
TaskID: "task-1", StepID: steps["review"].ID,
ParticipantID: "reviewer-1", Decision: DecisionRejected,
ParticipantID: "reviewer-1",
DeciderType: DeciderTypeAgent, DeciderID: "rev-A", Role: "reviewer",
Decision: DecisionRejected,
}); err != nil {
t.Fatalf("record decision: %v", err)
}
@@ -283,6 +288,17 @@ func (s *smokeStore) ApplyTransition(_ context.Context, _, _, fromStepID, toStep
return nil
}
func (s *smokeStore) ApplyTransitionIfAtStep(
_ context.Context, _, _, expectedStepID, toStepID string, _ Trigger,
) (bool, error) {
if s.currentStepID != expectedStepID {
return false, nil
}
s.transitions = append(s.transitions, expectedStepID+"->"+toStepID)
s.currentStepID = toStepID
return true, nil
}
func (s *smokeStore) PersistData(_ context.Context, _ string, _ map[string]any) error { return nil }
func (s *smokeStore) IsOperationApplied(_ context.Context, op string) (bool, error) {
@@ -318,6 +334,10 @@ func (p *smokeParticipants) ListStepParticipants(_ context.Context, stepID, _ st
return p.byStep[stepID], nil
}
func (p *smokeParticipants) ListTaskParticipants(_ context.Context, _ string) ([]ParticipantInfo, error) {
return nil, nil
}
type stubPrimary struct{ id string }
func (s stubPrimary) PrimaryAgentProfileID(_ context.Context, _, _ string) (string, error) {
@@ -90,6 +90,10 @@ func (f fakeParticipants) WorkflowStepIDForTask(_ context.Context, taskID string
return f.taskStepID, f.err
}
func (f fakeParticipants) ListTaskParticipants(_ context.Context, _ string) ([]ParticipantInfo, error) {
return f.list, f.err
}
// fakeCEO returns a fixed agent profile id (or empty / err).
type fakeCEO struct {
id string
+736 -137
View File
@@ -2,12 +2,23 @@ package engine
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/workflow/quorummetrics"
"go.uber.org/zap"
)
// ErrParticipantNotFound is returned by ResolveParticipantRole when the
// caller does not occupy a reviewer or approver seat with
// decision_required = 1 at the given step (AC-3).
var ErrParticipantNotFound = errors.New("workflow engine: participant not found")
// Threshold values understood by wait_for_quorum.
const (
QuorumAllApprove = "all_approve"
@@ -19,185 +30,773 @@ const (
)
// Decision verdict strings the quorum evaluator recognises. Other free-form
// verdicts are accepted and stored, but only "approved" and "rejected" are
// counted by the canned thresholds.
// verdicts are accepted and stored, but only "approved" and "rejected" (and,
// per AC-9, "changes_requested" as a synonym for "rejected") are counted by
// the canned thresholds.
const (
DecisionApproved = "approved"
DecisionRejected = "rejected"
DecisionApproved = "approved"
DecisionRejected = "rejected"
DecisionChangesRequested = "changes_requested"
)
// evaluateTransitionGuard returns true if the action's guard permits the
// transition (or if the action carries no guard at all). When false, the
// engine skips this action and keeps looking for the next eligible
// transition — preserving the first-transition-wins rule.
//
// A guard that references quorum but has no DecisionStore wired returns
// false: better to stall a transition than fire one whose precondition the
// engine cannot evaluate.
func (e *Engine) evaluateTransitionGuard(ctx context.Context, state MachineState, action Action) (bool, error) {
if action.Guard == nil {
return true, nil
}
if action.Guard.WaitForQuorum != nil {
return e.evaluateWaitForQuorum(ctx, state, action.Guard.WaitForQuorum)
}
// Unknown guard variant — fail closed.
return false, nil
// DeciderTypeAgent / DeciderTypeUser are the recognized decider_type values.
const (
DeciderTypeAgent = "agent"
DeciderTypeUser = "user"
)
// Reason codes for a guarded transition that does not fire. This is the
// closed set defined by AC-23; AC-24a's expvar keys are exactly these plus
// ReasonSessionUnresolvable (a recording-time skip, not a guard-evaluation
// outcome — see AC-52).
const (
ReasonThresholdNotMet = "threshold_not_met"
ReasonSlateEmpty = "slate_empty"
ReasonDecisionStoreUnwired = "decision_store_unwired"
ReasonParticipantStoreUnwired = "participant_store_unwired"
ReasonGuardVariantUnrecognized = "guard_variant_unrecognized"
ReasonThresholdUnrecognized = "threshold_unrecognized"
ReasonThresholdUnsatisfiable = "threshold_unsatisfiable"
ReasonEvaluationError = "evaluation_error"
ReasonSessionUnresolvable = "session_unresolvable"
)
// GuardOutcome is the result of evaluating one wait_for_quorum guard.
// Satisfied=true means the transition may fire; Reason is populated
// whenever Satisfied is false, with exactly one AC-23 code. Err carries the
// underlying error when Reason == ReasonEvaluationError.
type GuardOutcome struct {
Satisfied bool
Reason string
Err error
RequiredCount int
ReceivedCount int
}
// evaluateWaitForQuorum reads the (task, step) decisions and the step's
// participants and applies the configured threshold.
func (e *Engine) evaluateWaitForQuorum(ctx context.Context, state MachineState, guard *WaitForQuorumGuard) (bool, error) {
if e.decisions == nil || e.participants == nil {
return false, nil
// isRejectionVerdict reports whether a stored verdict counts as a rejection.
// AC-9: "changes_requested" is a synonym for "rejected" everywhere a
// rejection is counted.
func isRejectionVerdict(decision string) bool {
return decision == DecisionRejected || decision == DecisionChangesRequested
}
// evaluateTransitionGuard returns the guard outcome for an action. A nil
// guard always permits the transition (kanban semantics, unchanged).
func (e *Engine) evaluateTransitionGuard(ctx context.Context, state MachineState, action Action) GuardOutcome {
if action.Guard == nil {
return GuardOutcome{Satisfied: true}
}
required, err := e.requiredParticipants(ctx, state.CurrentStepID, state.TaskID, guard.Role)
if action.Guard.WaitForQuorum == nil {
// Unknown guard variant — fail closed (AC-23/AC-53's sibling code).
return GuardOutcome{Reason: ReasonGuardVariantUnrecognized}
}
return e.computeGuardOutcome(ctx, state, action.Guard.WaitForQuorum)
}
// recordGuardNotFired emits the AC-24/24a observability unit for a guarded
// transition that evaluated and did not fire: the quorummetrics counter
// (unconditional) and, when a logger is wired, a structured log record.
// Called from both the ordinary HandleTrigger/evaluateActions path and the
// AC-65-scoped decision re-evaluation path — the two "engine-path"
// evaluators AC-24 covers. The AC-24b/57d read-only diagnostic snapshot
// (EvaluateStepQuorum) must never call this.
func (e *Engine) recordGuardNotFired(state MachineState, action Action, outcome GuardOutcome) {
quorummetrics.RecordGuardNotFired(outcome.Reason)
if e.logger == nil {
return
}
fields := []zap.Field{
zap.String("task_id", state.TaskID),
zap.String("step_id", state.CurrentStepID),
zap.Int("required_count", outcome.RequiredCount),
zap.Int("received_count", outcome.ReceivedCount),
zap.String("reason", outcome.Reason),
}
if action.Guard != nil && action.Guard.WaitForQuorum != nil {
fields = append(fields,
zap.String("guard_role", action.Guard.WaitForQuorum.Role),
zap.String("guard_threshold", action.Guard.WaitForQuorum.Threshold),
)
}
if outcome.Err != nil {
fields = append(fields, zap.Error(outcome.Err))
}
e.logger.Warn("workflow quorum guard did not fire", fields...)
}
// recordReevaluationSkip emits the AC-24/24a observability unit for the
// AC-16a/F39 skip: a decision was recorded but re-evaluation could not run
// because the task's active session is unresolvable. Task-level only — no
// guard was evaluated, so guard-scoped fields (role, threshold, counts) are
// omitted.
func (e *Engine) recordReevaluationSkip(taskID, stepID string) {
quorummetrics.RecordGuardNotFired(ReasonSessionUnresolvable)
if e.logger == nil {
return
}
e.logger.Warn("workflow quorum reevaluation skipped: session unresolvable",
zap.String("task_id", taskID),
zap.String("step_id", stepID),
zap.String("reason", ReasonSessionUnresolvable),
)
}
// computeGuardOutcome evaluates a single wait_for_quorum guard against the
// engine's wired stores, following the AC-52 precedence order: store-unwired
// checks first, then (for approve-style thresholds) slate construction
// errors, then an empty slate, then threshold recognition, then
// unsatisfiability, then a genuinely unmet threshold. any_reject does not
// use the seat slate at all (AC-43/AC-59) and so skips the slate_empty step
// entirely.
func (e *Engine) computeGuardOutcome(ctx context.Context, state MachineState, guard *WaitForQuorumGuard) GuardOutcome {
if e.decisions == nil {
return GuardOutcome{Reason: ReasonDecisionStoreUnwired}
}
if e.participants == nil {
return GuardOutcome{Reason: ReasonParticipantStoreUnwired}
}
if guard.Threshold == QuorumAnyReject {
decisions, err := e.decisions.ListStepDecisions(ctx, state.TaskID, state.CurrentStepID)
if err != nil {
return GuardOutcome{Reason: ReasonEvaluationError, Err: fmt.Errorf("load step decisions for quorum: %w", err)}
}
satisfied, received := evaluateAnyReject(decisions, guard.Role)
outcome := GuardOutcome{Satisfied: satisfied, RequiredCount: 1, ReceivedCount: received}
if !satisfied {
outcome.Reason = ReasonThresholdNotMet
}
return outcome
}
seats, err := e.requiredSeatsForWorkflow(ctx, state.CurrentStepID, state.TaskID, state.WorkflowID, guard.Role)
if err != nil {
return false, err
return GuardOutcome{Reason: ReasonEvaluationError, Err: fmt.Errorf("build required slate for quorum: %w", err)}
}
if len(required) == 0 {
// No required reviewers — guard cannot be satisfied. Fail closed.
return false, nil
if len(seats) == 0 {
return GuardOutcome{Reason: ReasonSlateEmpty}
}
decisions, err := e.decisions.ListStepDecisions(ctx, state.TaskID, state.CurrentStepID)
if err != nil {
return false, fmt.Errorf("load step decisions for quorum: %w", err)
return GuardOutcome{Reason: ReasonEvaluationError, Err: fmt.Errorf("load step decisions for quorum: %w", err)}
}
return applyThreshold(guard.Threshold, required, decisions), nil
return evaluateApproveStyle(guard.Threshold, seats, decisions)
}
// requiredParticipants returns the participants for the (step, task) pair
// whose role matches and whose DecisionRequired flag is true.
func (e *Engine) requiredParticipants(ctx context.Context, stepID, taskID, role string) ([]ParticipantInfo, error) {
all, err := e.participants.ListStepParticipants(ctx, stepID, taskID)
// requiredSeats builds the AC-50 required slate for a guard's role at the
// evaluating step: gather (per-task rows at any step, plus template rows at
// the evaluating step only) -> filter (role + decision_required) ->
// canonicalize (AC-44, one row per (task_id, role, agent_profile_id)) ->
// collapse (AC-20, one row per (role, agent_profile_id), per-task winning
// over template).
func (e *Engine) requiredSeats(ctx context.Context, stepID, taskID, role string) ([]ParticipantInfo, error) {
return e.requiredSeatsForWorkflow(ctx, stepID, taskID, "", role)
}
func (e *Engine) requiredSeatsForWorkflow(ctx context.Context, stepID, taskID, workflowID, role string) ([]ParticipantInfo, error) {
var perTask []ParticipantInfo
var err error
if scoped, ok := e.participants.(WorkflowScopedParticipantStore); ok && workflowID != "" {
perTask, err = scoped.ListTaskParticipantsForWorkflow(ctx, taskID, workflowID)
} else {
perTask, err = e.participants.ListTaskParticipants(ctx, taskID)
}
if err != nil {
return nil, fmt.Errorf("list task participants for quorum: %w", err)
}
template, err := e.participants.ListStepParticipants(ctx, stepID, "")
if err != nil {
return nil, fmt.Errorf("list step participants for quorum: %w", err)
}
required := make([]ParticipantInfo, 0, len(all))
for _, p := range all {
gathered := make([]ParticipantInfo, 0, len(perTask)+len(template))
gathered = append(gathered, perTask...)
gathered = append(gathered, template...)
filtered := make([]ParticipantInfo, 0, len(gathered))
for _, p := range gathered {
if p.Role != role || !p.DecisionRequired {
continue
}
required = append(required, p)
filtered = append(filtered, p)
}
return required, nil
canonical := canonicalizeByTaskRoleAgent(filtered, stepID)
return collapseByRoleAgent(canonical), nil
}
// applyThreshold counts decisions against the required participant slate
// and reports whether the threshold is met. Decisions whose participant_id
// is not in the required slate are ignored — that's how mid-flight removal
// drops the missing slot from the count.
func applyThreshold(threshold string, required []ParticipantInfo, decisions []DecisionInfo) bool {
requiredIDs := participantIDSet(required)
latest := latestDecisionsPerParticipant(decisions, requiredIDs)
// seatCanonKey identifies a row for AC-44 canonicalization: (task_id, role,
// agent_profile_id), except a row with no agent_profile_id has no identity
// to canonicalize on and is kept standalone, keyed by its own row id.
type seatCanonKey struct {
taskID, role, agentProfileID, standaloneID string
}
approveCount := 0
rejectCount := 0
decidedCount := 0
for _, d := range latest {
decidedCount++
switch d.Decision {
case DecisionApproved:
approveCount++
case DecisionRejected:
rejectCount++
func canonKeyFor(p ParticipantInfo) seatCanonKey {
if p.AgentProfileID == "" {
return seatCanonKey{taskID: p.TaskID, role: p.Role, standaloneID: p.ID}
}
return seatCanonKey{taskID: p.TaskID, role: p.Role, agentProfileID: p.AgentProfileID}
}
// canonicalizeByTaskRoleAgent implements AC-44: one row per (task_id, role,
// agent_profile_id), preferring the row at the evaluating step, else the
// row with the lowest id in ASCII order.
func canonicalizeByTaskRoleAgent(rows []ParticipantInfo, evaluatingStepID string) []ParticipantInfo {
groups := map[seatCanonKey][]ParticipantInfo{}
var order []seatCanonKey
for _, p := range rows {
k := canonKeyFor(p)
if _, ok := groups[k]; !ok {
order = append(order, k)
}
groups[k] = append(groups[k], p)
}
out := make([]ParticipantInfo, 0, len(order))
for _, k := range order {
out = append(out, pickCanonicalRow(groups[k], evaluatingStepID))
}
return out
}
func pickCanonicalRow(rows []ParticipantInfo, evaluatingStepID string) ParticipantInfo {
for _, r := range rows {
if r.StepID == evaluatingStepID {
return r
}
}
best := rows[0]
for _, r := range rows[1:] {
if r.ID < best.ID {
best = r
}
}
return best
}
// seatCollapseKey identifies a row for AC-20 collapse: (role,
// agent_profile_id), except a row with no agent_profile_id is kept
// standalone, keyed by its own row id.
type seatCollapseKey struct {
role, agentProfileID, standaloneID string
}
func collapseKeyFor(p ParticipantInfo) seatCollapseKey {
if p.AgentProfileID == "" {
return seatCollapseKey{role: p.Role, standaloneID: p.ID}
}
return seatCollapseKey{role: p.Role, agentProfileID: p.AgentProfileID}
}
// collapseByRoleAgent implements AC-50 step 4 / AC-20: collapse across
// task_id to one row per (role, agent_profile_id), the per-task row (non
// empty task_id) winning over a template row.
func collapseByRoleAgent(rows []ParticipantInfo) []ParticipantInfo {
groups := map[seatCollapseKey][]ParticipantInfo{}
var order []seatCollapseKey
for _, p := range rows {
k := collapseKeyFor(p)
if _, ok := groups[k]; !ok {
order = append(order, k)
}
groups[k] = append(groups[k], p)
}
out := make([]ParticipantInfo, 0, len(order))
for _, k := range order {
out = append(out, pickPerTaskOverTemplate(groups[k]))
}
return out
}
func pickPerTaskOverTemplate(rows []ParticipantInfo) ParticipantInfo {
for _, r := range rows {
if r.TaskID != "" {
return r
}
}
best := rows[0]
for _, r := range rows[1:] {
if r.ID < best.ID {
best = r
}
}
return best
}
// mapDecisionsToSeats implements AC-51/AC-26: maps each decision to at most
// one seat — by participant_id when it names a seat, otherwise by
// (role, decider_id) for decider_type=agent — and keeps the latest mapped
// decision per seat under the AC-26 ordering (decisions arrive oldest
// first; a later write overwrites an earlier one). A decision that maps to
// no seat is dropped, per AC-43a.
func mapDecisionsToSeats(seats []ParticipantInfo, decisions []DecisionInfo) map[string]DecisionInfo {
seatByID := make(map[string]struct{}, len(seats))
seatByRoleAgent := make(map[[2]string]string, len(seats))
for _, s := range seats {
seatByID[s.ID] = struct{}{}
if s.AgentProfileID != "" {
seatByRoleAgent[[2]string{s.Role, s.AgentProfileID}] = s.ID
}
}
totalRequired := len(required)
switch {
case threshold == QuorumAllApprove:
return approveCount == totalRequired
case threshold == QuorumAllDecide:
return decidedCount == totalRequired
case threshold == QuorumAnyReject:
return rejectCount > 0
case threshold == QuorumMajorityApprove:
return approveCount*2 > totalRequired
case strings.HasPrefix(threshold, QuorumNApprovePrefix):
n, err := strconv.Atoi(strings.TrimPrefix(threshold, QuorumNApprovePrefix))
if err != nil || n <= 0 {
return false
}
return approveCount >= n
}
return false
}
// participantIDSet returns the set of participant IDs in the slate.
func participantIDSet(participants []ParticipantInfo) map[string]struct{} {
set := make(map[string]struct{}, len(participants))
for _, p := range participants {
set[p.ID] = struct{}{}
}
return set
}
// latestDecisionsPerParticipant keeps only the most recent decision per
// participant whose ID is present in the required set. Decisions for
// participants no longer required (removed mid-flight) are dropped.
func latestDecisionsPerParticipant(decisions []DecisionInfo, required map[string]struct{}) map[string]DecisionInfo {
latest := make(map[string]DecisionInfo, len(required))
latest := make(map[string]DecisionInfo, len(seats))
for _, d := range decisions {
if _, ok := required[d.ParticipantID]; !ok {
seatID := ""
if _, ok := seatByID[d.ParticipantID]; ok && d.ParticipantID != "" {
seatID = d.ParticipantID
} else if d.DeciderType == DeciderTypeAgent {
if sid, ok := seatByRoleAgent[[2]string{d.Role, d.DeciderID}]; ok {
seatID = sid
}
}
if seatID == "" {
continue
}
// Decisions arrive oldest-first per the repo contract; later writes
// overwrite earlier ones so we keep the latest verdict per participant.
latest[d.ParticipantID] = d
latest[seatID] = d
}
return latest
}
// RecordParticipantDecision writes a new decision row and immediately re-
// evaluates pending transitions for that (task, step) so any wait_for_quorum
// guard that just became satisfied can fire.
// evaluateAnyReject implements AC-43/AC-43a/AC-43b/AC-58/AC-59: any_reject
// is a decider-identity veto, not a seated quorum contribution. The LAST
// decision per (decider_type, decider_id) under the AC-26 ordering decides
// whether that decider currently vetoes. Agent deciders are filtered to the
// guard's role (AC-42); user deciders are role-agnostic (AC-58), waiving
// resolveDeciderRole's approver-wins quirk so a human rejection counts
// regardless of the stored role. The slate is never consulted — a seatless
// veto is exactly what this threshold exists to allow.
func evaluateAnyReject(decisions []DecisionInfo, guardRole string) (satisfied bool, receivedCount int) {
type identity struct{ deciderType, deciderID string }
latest := map[identity]DecisionInfo{}
for _, d := range decisions {
if d.DeciderType == "" || d.DeciderID == "" {
continue
}
if d.DeciderType == DeciderTypeAgent && d.Role != guardRole {
continue
}
latest[identity{d.DeciderType, d.DeciderID}] = d
}
vetoCount := 0
for _, d := range latest {
if isRejectionVerdict(d.Decision) {
vetoCount++
}
}
return vetoCount > 0, vetoCount
}
// evaluateApproveStyle implements AC-21/AC-39/AC-40/AC-41/AC-43a/AC-53: the
// approve-style thresholds (all_approve, all_decide, majority_approve,
// n_approve:<N>), counting only decisions mapped to an AC-50 seat.
func evaluateApproveStyle(threshold string, seats []ParticipantInfo, decisions []DecisionInfo) GuardOutcome {
mapped := mapDecisionsToSeats(seats, decisions)
approveCount, decidedCount := 0, 0
for _, d := range mapped {
decidedCount++
if d.Decision == DecisionApproved {
approveCount++
}
}
total := len(seats)
switch {
case threshold == QuorumAllApprove:
return finishApproveStyle(approveCount == total, total, approveCount)
case threshold == QuorumAllDecide:
return finishApproveStyle(decidedCount == total, total, decidedCount)
case threshold == QuorumMajorityApprove:
return finishApproveStyle(approveCount*2 > total, total, approveCount)
case strings.HasPrefix(threshold, QuorumNApprovePrefix):
return evaluateNApprove(threshold, total, approveCount)
default:
return GuardOutcome{Reason: ReasonThresholdUnrecognized}
}
}
func evaluateNApprove(threshold string, total, approveCount int) GuardOutcome {
n, err := strconv.Atoi(strings.TrimPrefix(threshold, QuorumNApprovePrefix))
if err != nil || n <= 0 {
return GuardOutcome{Reason: ReasonThresholdUnrecognized}
}
if n > total {
return GuardOutcome{Reason: ReasonThresholdUnsatisfiable, RequiredCount: n, ReceivedCount: approveCount}
}
return finishApproveStyle(approveCount >= n, n, approveCount)
}
func finishApproveStyle(satisfied bool, required, received int) GuardOutcome {
outcome := GuardOutcome{Satisfied: satisfied, RequiredCount: required, ReceivedCount: received}
if !satisfied {
outcome.Reason = ReasonThresholdNotMet
}
return outcome
}
// RecordDecisionResult reports the outcome of RecordParticipantDecision: the
// stamped decision identity (AC-66/57b-i) plus what happened to
// re-evaluation.
type RecordDecisionResult struct {
DecisionID string
DecidedAt time.Time
// ReevaluationSkipped is true under AC-16a/F39: the decision was
// recorded but re-evaluation did not run because sessionID was blank
// (the caller could not resolve the task's active session). SkipReason
// is ReasonSessionUnresolvable in that case.
ReevaluationSkipped bool
SkipReason string
// Transitioned / TransitionAbandoned / FromStepID / ToStepID mirror
// HandleResult's fields for the transition (if any) the re-evaluation
// applied.
Transitioned bool
TransitionAbandoned bool
FromStepID string
ToStepID string
// Guards is the pre-transition quorum snapshot used by the decision
// response. It remains available after a successful CAS move.
Guards []QuorumGuardState
}
// RecordParticipantDecision is the engine's single decision-recording entry
// point (AC-57): it writes a new decision row, stamping ID and DecidedAt
// itself (AC-66/57b-i) since DecisionStore.RecordStepDecision takes
// DecisionInfo by value and never echoes generated fields back. When
// sessionID resolves, it then re-evaluates — in "committing mode", applying
// at most one satisfied transition itself (AC-46/47/65) — only the guarded
// on_turn_complete transition actions at the decision's step, never a plain
// HandleTrigger(TriggerOnTurnComplete) call, which would re-fire every
// on_turn_complete action (queue_run, clear_decisions, ...) once per
// decision instead of once per completed turn.
//
// The returned HandleResult mirrors HandleTrigger: Transitioned indicates a
// transition was applied. EvaluateOnly is honoured if set on the input.
//
// Callers that want to suppress re-evaluation (e.g. record-only flows) can
// pass an empty EvaluateInput by leaving Trigger blank — only the recording
// step runs in that case.
// in.ID and in.DecidedAt are ignored — always overwritten with a fresh
// UUID and the current time. sessionID is optional: blank means the caller
// could not resolve the task's active session (AC-16a), so the decision is
// recorded and re-evaluation is skipped without erroring, reported under
// AC-23 as ReasonSessionUnresolvable so it's distinguishable from
// "re-evaluated and found threshold unmet."
func (e *Engine) RecordParticipantDecision(
ctx context.Context,
taskID, sessionID, stepID, participantID, decision, note string,
) error {
sessionID string,
in DecisionInfo,
) (RecordDecisionResult, error) {
if e.decisions == nil {
return fmt.Errorf("workflow engine: decision store not wired")
return RecordDecisionResult{}, fmt.Errorf("workflow engine: decision store not wired")
}
if taskID == "" || stepID == "" || participantID == "" {
return fmt.Errorf("record decision requires task_id, step_id, and participant_id")
if in.TaskID == "" || in.StepID == "" || in.ParticipantID == "" {
return RecordDecisionResult{}, fmt.Errorf("record decision requires task_id, step_id, and participant_id")
}
if decision == "" {
return fmt.Errorf("record decision verdict must not be empty")
if in.Decision == "" {
return RecordDecisionResult{}, fmt.Errorf("record decision verdict must not be empty")
}
if err := e.decisions.RecordStepDecision(ctx, DecisionInfo{
TaskID: taskID,
StepID: stepID,
ParticipantID: participantID,
Decision: decision,
Note: note,
}); err != nil {
return err
in.ID = uuid.New().String()
in.DecidedAt = time.Now().UTC()
if err := e.decisions.RecordStepDecision(ctx, in); err != nil {
return RecordDecisionResult{}, err
}
// Re-evaluate transitions: fire on_turn_complete in evaluate-only mode so
// callers (the office service) receive the transition payload and decide
// how to apply it. Idempotency is keyed off a synthetic operation id.
//
// SessionID is optional here — when blank we skip re-evaluation rather
// than synthesise a fake session. Callers that want a re-eval pass the
// canonical session id for the (task, step) — typically the task's
// primary session.
result := RecordDecisionResult{DecisionID: in.ID, DecidedAt: in.DecidedAt}
if sessionID == "" {
return nil
result.ReevaluationSkipped = true
result.SkipReason = ReasonSessionUnresolvable
e.recordReevaluationSkip(in.TaskID, in.StepID)
return result, nil
}
_, err := e.HandleTrigger(ctx, HandleInput{
TaskID: taskID,
SessionID: sessionID,
Trigger: TriggerOnTurnComplete,
EvaluateOnly: true,
OperationID: fmt.Sprintf("decision:%s:%s:%s:%d", taskID, stepID, participantID, time.Now().UnixNano()),
})
return err
handled, err := e.reevaluateGuardedTransitions(ctx, in.TaskID, sessionID, in.StepID, in.ID)
if err != nil {
return result, err
}
result.Transitioned = handled.Transitioned
result.TransitionAbandoned = handled.TransitionAbandoned
result.FromStepID = handled.FromStepID
result.ToStepID = handled.ToStepID
result.Guards = handled.Guards
return result, nil
}
// reevaluateGuardedTransitions implements the AC-13-17/AC-65 re-evaluation
// scope: it loads the step the decision was recorded against directly (not
// the task's possibly-since-moved current step — the CAS apply below is
// what protects against that race) and applies at most the first satisfied
// guarded on_turn_complete transition action, in configured order. Actions
// without a wait_for_quorum guard, and every non-transition action
// (queue_run, clear_decisions, ...), are out of scope for this path — they
// already ran once for the turn that produced these decisions and must not
// re-run per decision.
//
// The AC-30 operation id is marked applied only once this function has
// fully determined the outcome (transitioned, abandoned, or nothing
// satisfied) — never before, so a crash mid-evaluation does not leave a
// decision's re-evaluation permanently skipped.
func (e *Engine) reevaluateGuardedTransitions(
ctx context.Context, taskID, sessionID, stepID, decisionID string,
) (HandleResult, error) {
opID := fmt.Sprintf("decision:%s:%s:%s", taskID, stepID, decisionID)
applied, err := e.store.IsOperationApplied(ctx, opID)
if err != nil {
return HandleResult{}, err
}
if applied {
return HandleResult{Idempotent: true}, nil
}
state, err := e.store.LoadState(ctx, taskID, sessionID)
if err != nil {
return HandleResult{}, err
}
state.CurrentStepID = stepID
step, err := e.store.LoadStep(ctx, state.WorkflowID, stepID)
if err != nil {
return HandleResult{}, err
}
result, err := e.applyFirstSatisfiedGuardedTransition(ctx, taskID, sessionID, state, step)
if err != nil {
return HandleResult{}, err
}
if err := e.markOperationApplied(ctx, opID); err != nil {
return HandleResult{}, err
}
return result, nil
}
// applyFirstSatisfiedGuardedTransition evaluates on_turn_complete's guarded
// transition actions in order and, for the first one whose wait_for_quorum
// guard is satisfied, applies it via the AC-46/48 compare-and-swap. Emits
// the AC-24/24a observability unit for every guard that is evaluated and
// does not fire, same as the ordinary HandleTrigger path.
//
//nolint:cyclop // ordered guard evaluation keeps first-transition semantics explicit.
func (e *Engine) applyFirstSatisfiedGuardedTransition(
ctx context.Context, taskID, sessionID string, state MachineState, step StepSpec,
) (HandleResult, error) {
guards := make([]QuorumGuardState, 0)
var selectedTarget string
for _, action := range step.Events[TriggerOnTurnComplete] {
if !isTransitionAction(action.Kind) || action.RequiresApproval {
continue
}
if action.Guard == nil || action.Guard.WaitForQuorum == nil {
continue
}
entry, targetStepID, targetErr, satisfied := e.evaluateGuardForTransition(ctx, state, step, action)
guards = append(guards, entry)
if targetErr != nil {
// Preserve the old write-side behavior for the first satisfied
// guard, which returned this target-resolution error. A malformed
// later guard must not prevent an earlier satisfied transition from
// firing, and an unmet guard can still report its diagnostic state.
if satisfied && selectedTarget == "" {
return HandleResult{}, targetErr
}
e.recordGuardNotFired(state, action, GuardOutcome{Reason: entry.Reason, Err: entry.Error, RequiredCount: entry.RequiredCount, ReceivedCount: entry.ReceivedCount})
continue
}
if !satisfied {
e.recordGuardNotFired(state, action, GuardOutcome{Reason: entry.Reason, Err: entry.Error, RequiredCount: entry.RequiredCount, ReceivedCount: entry.ReceivedCount})
continue
}
if targetStepID == "" || targetStepID == state.CurrentStepID {
continue
}
if selectedTarget == "" {
selectedTarget = targetStepID
}
}
if selectedTarget == "" {
return HandleResult{Guards: guards}, nil
}
applied, err := e.store.ApplyTransitionIfAtStep(
ctx, taskID, sessionID, state.CurrentStepID, selectedTarget, TriggerOnTurnComplete,
)
if err != nil {
return HandleResult{}, err
}
if !applied {
return HandleResult{Guards: guards, TransitionAbandoned: true, FromStepID: state.CurrentStepID, ToStepID: selectedTarget}, nil
}
return HandleResult{Guards: guards, Transitioned: true, FromStepID: state.CurrentStepID, ToStepID: selectedTarget}, nil
}
func (e *Engine) evaluateGuardForTransition(
ctx context.Context, state MachineState, step StepSpec, action Action,
) (QuorumGuardState, string, error, bool) {
outcome := e.evaluateTransitionGuard(ctx, state, action)
targetStepID, targetErr := e.resolveTransitionTarget(ctx, state, step, action)
if targetErr != nil {
return QuorumGuardState{
TargetStepID: targetStepID,
Role: action.Guard.WaitForQuorum.Role,
Threshold: action.Guard.WaitForQuorum.Threshold,
Satisfied: false,
Reason: ReasonEvaluationError,
Error: targetErr,
}, targetStepID, targetErr, outcome.Satisfied
}
return QuorumGuardState{
TargetStepID: targetStepID,
Role: action.Guard.WaitForQuorum.Role,
Threshold: action.Guard.WaitForQuorum.Threshold,
RequiredCount: outcome.RequiredCount,
ReceivedCount: outcome.ReceivedCount,
Satisfied: outcome.Satisfied,
Reason: outcome.Reason,
Error: outcome.Err,
}, targetStepID, nil, outcome.Satisfied
}
// QuorumGuardState is one guarded transition's read-only quorum state,
// returned by EvaluateStepQuorum (AC-54/57d). Satisfied=true entries omit
// Reason entirely (AC-60).
type QuorumGuardState struct {
TargetStepID string
Role string
Threshold string
RequiredCount int
ReceivedCount int
Satisfied bool
Reason string
Error error
}
// QuorumSnapshot is the AC-54/57d read-only diagnostic snapshot for a
// task's current step.
type QuorumSnapshot struct {
StepID string
// ReevaluationBlocked reflects only the "task has at least one decision
// at its current step" conjunct (AC-62) — the engine has no access to
// the AC-16 active-session query, so it cannot compute the full
// conjunction with "no active session" by itself. The caller
// (shared.WorkflowEngineDispatcher's EvaluateStepQuorum, which does have
// session-resolution access) ANDs in that second conjunct.
ReevaluationBlocked bool
// Guards is ordered to match the step's configured on_turn_complete
// action order (AC-57d), tying AC-61's selection rule to AC-17's
// first-transition-wins.
Guards []QuorumGuardState
}
// EvaluateStepQuorum computes the read-only AC-54/57d snapshot for the step
// the given session currently resolves to. sessionID is used only to
// satisfy TransitionStore.LoadState's signature — MachineState.CurrentStepID
// is derived from the task row, not the session, so any valid session for
// the task yields the correct step (see production TransitionStore
// implementations). Applies no transition, writes no row, executes no
// action callback, emits no AC-24 log, and increments no AC-24a counter — a
// diagnostic read never fails on, or mutates, the state it exists to
// diagnose.
//
// A store error while evaluating one guard surfaces as that guard's
// ReasonEvaluationError entry, not a method-level error, so one failing
// guard does not blank the others. A task with no bound current step
// returns an empty, no-error snapshot (AC-24c). sessionID == "" (a task
// that has never had a session) is NOT special-cased to an empty snapshot:
// TransitionStore.LoadState derives CurrentStepID from the task row, not
// the session, so a blank sessionID still yields the correct step, and
// AC-62's ReevaluationBlocked must be computed live from that step's
// decisions rather than short-circuited to false.
func (e *Engine) EvaluateStepQuorum(ctx context.Context, taskID, sessionID string) (QuorumSnapshot, error) {
state, err := e.store.LoadState(ctx, taskID, sessionID)
if err != nil {
return QuorumSnapshot{}, err
}
if state.CurrentStepID == "" {
return QuorumSnapshot{}, nil
}
step, err := e.store.LoadStep(ctx, state.WorkflowID, state.CurrentStepID)
if err != nil {
return QuorumSnapshot{}, err
}
snapshot := QuorumSnapshot{StepID: state.CurrentStepID}
for _, action := range step.Events[TriggerOnTurnComplete] {
if !isTransitionAction(action.Kind) || action.Guard == nil {
continue
}
snapshot.Guards = append(snapshot.Guards, e.evaluateGuardStateReadOnly(ctx, state, step, action))
}
if e.decisions != nil {
decisions, derr := e.decisions.ListStepDecisions(ctx, taskID, state.CurrentStepID)
if derr == nil {
snapshot.ReevaluationBlocked = len(decisions) > 0
}
}
return snapshot, nil
}
// evaluateGuardStateReadOnly computes one QuorumGuardState entry without any
// side effect (no logging, no counters, no apply).
func (e *Engine) evaluateGuardStateReadOnly(
ctx context.Context, state MachineState, step StepSpec, action Action,
) QuorumGuardState {
outcome := e.evaluateTransitionGuard(ctx, state, action)
targetStepID, err := e.resolveTransitionTarget(ctx, state, step, action)
if err != nil {
outcome = GuardOutcome{Reason: ReasonEvaluationError, Err: err}
}
entry := QuorumGuardState{
TargetStepID: targetStepID,
Satisfied: outcome.Satisfied,
Reason: outcome.Reason,
Error: outcome.Err,
RequiredCount: outcome.RequiredCount,
ReceivedCount: outcome.ReceivedCount,
}
if action.Guard.WaitForQuorum != nil {
entry.Role = action.Guard.WaitForQuorum.Role
entry.Threshold = action.Guard.WaitForQuorum.Threshold
}
return entry
}
// ResolveParticipantRole resolves the role and seat id an agent decider
// occupies at stepID, for the agent decision tool's AC-2 (seat id),
// AC-3 (permission), AC-4 (approver-wins) and AC-4a (slate-consistent
// population) requirements. It reuses requiredSeats — the exact AC-50
// slate construction the quorum evaluator itself uses — so role
// resolution and slate membership can never disagree about who
// participates, per AC-57's "exactly once, in the engine" mandate.
// Approver is checked before reviewer, matching the existing
// approver-wins precedence in office/dashboard/decisions.go's
// resolveDeciderRole (AC-4). Returns ErrParticipantNotFound when the
// agent occupies neither seat.
func (e *Engine) ResolveParticipantRole(
ctx context.Context, taskID, stepID, agentProfileID string,
) (role, participantID string, err error) {
if agentProfileID == "" {
return "", "", ErrParticipantNotFound
}
workflowID := ""
if state, stateErr := e.store.LoadState(ctx, taskID, ""); stateErr == nil {
workflowID = state.WorkflowID
}
for _, r := range []wfmodels.ParticipantRole{
wfmodels.ParticipantRoleApprover,
wfmodels.ParticipantRoleReviewer,
} {
seats, seatsErr := e.requiredSeatsForWorkflow(ctx, stepID, taskID, workflowID, string(r))
if seatsErr != nil {
return "", "", fmt.Errorf("resolve participant role: %w", seatsErr)
}
if id, ok := seatIDFor(seats, agentProfileID); ok {
return string(r), id, nil
}
}
return "", "", ErrParticipantNotFound
}
// seatIDFor scans seats for one occupied by agentProfileID, returning its
// AC-50 canonical seat id.
func seatIDFor(seats []ParticipantInfo, agentProfileID string) (string, bool) {
for _, s := range seats {
if s.AgentProfileID == agentProfileID {
return s.ID, true
}
}
return "", false
}
@@ -0,0 +1,460 @@
package engine
import (
"context"
"expvar"
"testing"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
// reevalFakeStore is a TransitionStore whose ApplyTransitionIfAtStep call
// count and outcome are test-controllable, for AC-46/47/48/65/66 coverage
// that stepStoreForQuorum (single guarded action only) cannot express.
type reevalFakeStore struct {
state MachineState
step StepSpec
applied map[string]bool
casCalls int
casApplied func(expectedStepID, toStepID string) (bool, error)
}
func (s *reevalFakeStore) LoadState(_ context.Context, _, _ string) (MachineState, error) {
return s.state, nil
}
func (s *reevalFakeStore) LoadStep(_ context.Context, _, _ string) (StepSpec, error) {
return s.step, nil
}
func (s *reevalFakeStore) LoadNextStep(_ context.Context, _ string, _ int) (StepSpec, error) {
return StepSpec{}, nil
}
func (s *reevalFakeStore) LoadPreviousStep(_ context.Context, _ string, _ int) (StepSpec, error) {
return StepSpec{}, nil
}
func (s *reevalFakeStore) ApplyTransition(_ context.Context, _, _, _, _ string, _ Trigger) error {
return nil
}
func (s *reevalFakeStore) ApplyTransitionIfAtStep(
_ context.Context, _, _, expectedStepID, toStepID string, _ Trigger,
) (bool, error) {
s.casCalls++
if s.casApplied != nil {
return s.casApplied(expectedStepID, toStepID)
}
if s.state.CurrentStepID != expectedStepID {
return false, nil
}
s.state.CurrentStepID = toStepID
return true, nil
}
func (s *reevalFakeStore) PersistData(_ context.Context, _ string, _ map[string]any) error {
return nil
}
func (s *reevalFakeStore) IsOperationApplied(_ context.Context, op string) (bool, error) {
return s.applied[op], nil
}
func (s *reevalFakeStore) MarkOperationApplied(_ context.Context, op string) error {
s.applied[op] = true
return nil
}
func approvedGuard(role string) *TransitionGuard {
return &TransitionGuard{WaitForQuorum: &WaitForQuorumGuard{Role: role, Threshold: QuorumAllApprove}}
}
// --- AC-46/48: lost CAS race is reported as abandoned, not an error ---
func TestReevaluateGuardedTransitions_AbandonsOnLostCASRace(t *testing.T) {
store := &reevalFakeStore{
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: ActionMoveToStep, Guard: approvedGuard("reviewer"), MoveToStep: &MoveToStepAction{StepID: "approval"}},
},
},
},
applied: map[string]bool{},
casApplied: func(string, string) (bool, error) { return false, nil }, // simulate a concurrent mover winning the race
}
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,
})
if err != nil {
t.Fatalf("RecordParticipantDecision: %v", err)
}
if !result.TransitionAbandoned || result.Transitioned {
t.Fatalf("expected TransitionAbandoned=true, Transitioned=false, got %#v", result)
}
if result.FromStepID != "review" || result.ToStepID != "approval" {
t.Fatalf("unexpected abandoned transition endpoints: %#v", result)
}
if store.casCalls != 1 {
t.Fatalf("expected exactly one CAS attempt, got %d", store.casCalls)
}
}
// --- AC-30/66: re-evaluation of the same decision is idempotent ---
func TestReevaluateGuardedTransitions_IdempotentOnRepeatedDecisionID(t *testing.T) {
store := &reevalFakeStore{
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: ActionMoveToStep, Guard: approvedGuard("reviewer"), MoveToStep: &MoveToStepAction{StepID: "approval"}},
},
},
},
applied: map[string]bool{},
}
decisions := newFakeDecisionStore()
parts := fakeParticipants{list: []ParticipantInfo{
{ID: "p1", Role: "reviewer", DecisionRequired: true, AgentProfileID: "rev-A"},
}}
if err := decisions.RecordStepDecision(context.Background(), DecisionInfo{
TaskID: "task-1", StepID: "review", ParticipantID: "p1", Decision: DecisionApproved,
}); err != nil {
t.Fatalf("seed decision: %v", err)
}
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(parts))
first, err := eng.reevaluateGuardedTransitions(context.Background(), "task-1", "sess-1", "review", "decision-1")
if err != nil {
t.Fatalf("first reevaluate: %v", err)
}
if !first.Transitioned {
t.Fatalf("expected first call to transition, got %#v", first)
}
if store.casCalls != 1 {
t.Fatalf("expected 1 CAS call after first reevaluate, got %d", store.casCalls)
}
second, err := eng.reevaluateGuardedTransitions(context.Background(), "task-1", "sess-1", "review", "decision-1")
if err != nil {
t.Fatalf("second reevaluate: %v", err)
}
if !second.Idempotent {
t.Fatalf("expected second call with the same decision id to be idempotent, got %#v", second)
}
if store.casCalls != 1 {
t.Fatalf("expected no additional CAS call on the idempotent replay, got %d calls", store.casCalls)
}
}
// --- AC-65: only guarded transition actions are in scope for re-evaluation ---
func TestApplyFirstSatisfiedGuardedTransition_SkipsNonTransitionAndUngatedActions(t *testing.T) {
store := &reevalFakeStore{
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: {
// Non-transition action: must never be inspected or executed
// by the re-evaluation path (it already ran once for the
// turn; MapRegistry{} below has no callback wired for it,
// so executing it would panic/error on lookup failure).
{Kind: ActionSetWorkflowData, SetWorkflowData: &SetWorkflowDataAction{Key: "k", Value: "v"}},
// Ungated transition: out of AC-65 scope, must not re-fire.
{Kind: ActionMoveToStep, MoveToStep: &MoveToStepAction{StepID: "ungated-target"}},
// The only in-scope action.
{Kind: ActionMoveToStep, Guard: approvedGuard("reviewer"), MoveToStep: &MoveToStepAction{StepID: "guarded-target"}},
},
},
},
applied: map[string]bool{},
}
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,
})
if err != nil {
t.Fatalf("RecordParticipantDecision: %v", err)
}
if !result.Transitioned || result.ToStepID != "guarded-target" {
t.Fatalf("expected transition to the guarded target only, got %#v", result)
}
if store.casCalls != 1 {
t.Fatalf("expected exactly one CAS attempt (the guarded action), got %d", store.casCalls)
}
}
// --- AC-24/24a: guard-not-fired and session-unresolvable both log and count ---
func newObservedEngine(store TransitionStore, decisions DecisionStore, participants ParticipantStore) (*Engine, *observer.ObservedLogs) {
core, logs := observer.New(zap.WarnLevel)
zapLogger := zap.New(core)
log, err := logger.NewFromZap(zapLogger)
if err != nil {
panic(err)
}
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(participants), WithLogger(log))
return eng, logs
}
func readGuardCounter(reason string) int64 {
m := expvar.Get("workflow_quorum_guard_not_fired_total")
if m == nil {
return 0
}
kv := m.(*expvar.Map).Get(reason)
if kv == nil {
return 0
}
iv, ok := kv.(*expvar.Int)
if !ok {
return 0
}
return iv.Value()
}
func TestGuardNotFired_LogsAndCounts_OnOrdinaryHandleTriggerPath(t *testing.T) {
store := quorumStore(approvedGuard("reviewer"))
decisions := newFakeDecisionStore() // no decisions recorded => threshold not met
parts := fakeParticipants{list: []ParticipantInfo{
{ID: "p1", Role: "reviewer", DecisionRequired: true, AgentProfileID: "rev-A"},
}}
eng, logs := newObservedEngine(store, decisions, parts)
before := readGuardCounter(ReasonThresholdNotMet)
_, err := eng.HandleTrigger(context.Background(), HandleInput{
TaskID: "task-1", SessionID: "sess-1", Trigger: TriggerOnTurnComplete,
})
if err != nil {
t.Fatalf("HandleTrigger: %v", err)
}
if got := readGuardCounter(ReasonThresholdNotMet); got != before+1 {
t.Fatalf("expected threshold_not_met counter to increment by 1, got %d -> %d", before, got)
}
entries := logs.FilterMessage("workflow quorum guard did not fire").All()
if len(entries) != 1 {
t.Fatalf("expected exactly one guard-not-fired log entry, got %d", len(entries))
}
}
func TestGuardNotFired_LogsAndCounts_OnReevaluationPath(t *testing.T) {
store := &reevalFakeStore{
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: {
// Requires a second reviewer role that never decides, so this
// guard evaluates and does not fire during re-evaluation.
{Kind: ActionMoveToStep, Guard: approvedGuard("approver"), MoveToStep: &MoveToStepAction{StepID: "elsewhere"}},
},
},
},
applied: map[string]bool{},
}
decisions := newFakeDecisionStore()
parts := fakeParticipants{list: []ParticipantInfo{
{ID: "p1", Role: "approver", DecisionRequired: true, AgentProfileID: "app-A"},
{ID: "p2", Role: "approver", DecisionRequired: true, AgentProfileID: "app-B"},
}}
eng, logs := newObservedEngine(store, decisions, parts)
before := readGuardCounter(ReasonThresholdNotMet)
result, err := eng.RecordParticipantDecision(context.Background(), "sess-1", DecisionInfo{
TaskID: "task-1", StepID: "review", ParticipantID: "p1", Decision: DecisionApproved,
})
if err != nil {
t.Fatalf("RecordParticipantDecision: %v", err)
}
if result.Transitioned || result.TransitionAbandoned {
t.Fatalf("expected no transition (only one of two approvers decided), got %#v", result)
}
if got := readGuardCounter(ReasonThresholdNotMet); got != before+1 {
t.Fatalf("expected threshold_not_met counter to increment by 1 on the reevaluation path, got %d -> %d", before, got)
}
entries := logs.FilterMessage("workflow quorum guard did not fire").All()
if len(entries) != 1 {
t.Fatalf("expected exactly one guard-not-fired log entry from reevaluation, got %d", len(entries))
}
}
func TestRecordReevaluationSkip_LogsAndCountsSessionUnresolvable(t *testing.T) {
store := quorumStore(nil)
decisions := newFakeDecisionStore()
eng, logs := newObservedEngine(store, decisions, fakeParticipants{})
before := readGuardCounter(ReasonSessionUnresolvable)
result, err := eng.RecordParticipantDecision(context.Background(), "", DecisionInfo{
TaskID: "task-1", StepID: "review", ParticipantID: "p1", Decision: DecisionApproved,
})
if err != nil {
t.Fatalf("RecordParticipantDecision: %v", err)
}
if !result.ReevaluationSkipped || result.SkipReason != ReasonSessionUnresolvable {
t.Fatalf("expected a session_unresolvable skip, got %#v", result)
}
if got := readGuardCounter(ReasonSessionUnresolvable); got != before+1 {
t.Fatalf("expected session_unresolvable counter to increment by 1, got %d -> %d", before, got)
}
entries := logs.FilterMessage("workflow quorum reevaluation skipped: session unresolvable").All()
if len(entries) != 1 {
t.Fatalf("expected exactly one reevaluation-skip log entry, got %d", len(entries))
}
}
// --- AC-54/57d/60-62: EvaluateStepQuorum read-only diagnostic snapshot ---
func TestEvaluateStepQuorum_NoBoundStepReturnsEmptySnapshot(t *testing.T) {
noStepStore := &reevalFakeStore{state: MachineState{WorkflowID: "wf"}, applied: map[string]bool{}}
eng := New(noStepStore, MapRegistry{}, WithDecisionStore(newFakeDecisionStore()), WithParticipantStore(fakeParticipants{}))
snap, err := eng.EvaluateStepQuorum(context.Background(), "task-1", "sess-1")
if err != nil || snap.StepID != "" || len(snap.Guards) != 0 || snap.ReevaluationBlocked {
t.Fatalf("expected empty snapshot with no error for blank CurrentStepID, got %#v, err=%v", snap, err)
}
}
// TestEvaluateStepQuorum_BlankSessionStillReflectsReevaluationBlocked is
// AC-62: a task that has never had a session (sessionID == "", the F38/
// dispatcher case for a task with zero task_sessions rows) must still
// compute ReevaluationBlocked live from ListStepDecisions, not
// short-circuit to false before ever consulting decisions. stepStoreForQuorum's
// LoadState ignores its sessionID argument and returns the task's real
// CurrentStepID regardless, mirroring production: CurrentStepID is derived
// from the task row, not the session.
func TestEvaluateStepQuorum_BlankSessionStillReflectsReevaluationBlocked(t *testing.T) {
store := quorumStore(approvedGuard("reviewer"))
parts := fakeParticipants{list: []ParticipantInfo{
{ID: "p1", Role: "reviewer", DecisionRequired: true, AgentProfileID: "rev-A"},
}}
decisions := newFakeDecisionStore()
decisions.byKey[dkey("task-1", "review")] = []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}}
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(parts))
snap, err := eng.EvaluateStepQuorum(context.Background(), "task-1", "")
if err != nil {
t.Fatalf("EvaluateStepQuorum: %v", err)
}
if snap.StepID != "review" {
t.Fatalf("StepID = %q, want review", snap.StepID)
}
if !snap.ReevaluationBlocked {
t.Fatalf("ReevaluationBlocked = false, want true: a decision is on record at the current step")
}
}
func TestEvaluateStepQuorum_PerGuardEvaluationErrorDoesNotFailTheMethod(t *testing.T) {
store := &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: {
// move_to_step with no target: resolveTransitionTarget errors,
// which must surface as this guard's evaluation_error entry,
// not a method-level error.
{Kind: ActionMoveToStep, Guard: approvedGuard("reviewer")},
},
},
},
applied: map[string]bool{},
}
parts := fakeParticipants{list: []ParticipantInfo{
{ID: "p1", Role: "reviewer", DecisionRequired: true, AgentProfileID: "rev-A"},
}}
decisions := newFakeDecisionStore()
decisions.byKey[dkey("task-1", "review")] = []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}}
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(parts))
snap, err := eng.EvaluateStepQuorum(context.Background(), "task-1", "sess-1")
if err != nil {
t.Fatalf("expected no method-level error, got %v", err)
}
if len(snap.Guards) != 1 {
t.Fatalf("expected exactly one guard entry, got %d", len(snap.Guards))
}
if snap.Guards[0].Reason != ReasonEvaluationError || snap.Guards[0].Error == nil {
t.Fatalf("expected the guard entry to carry evaluation_error, got %#v", snap.Guards[0])
}
}
func TestEvaluateStepQuorum_ReevaluationBlockedReflectsOnlyDecisionsNonEmpty(t *testing.T) {
store := quorumStore(approvedGuard("reviewer"))
parts := fakeParticipants{list: []ParticipantInfo{
{ID: "p1", Role: "reviewer", DecisionRequired: true, AgentProfileID: "rev-A"},
}}
decisions := newFakeDecisionStore()
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(parts))
snap, err := eng.EvaluateStepQuorum(context.Background(), "task-1", "sess-1")
if err != nil {
t.Fatalf("EvaluateStepQuorum: %v", err)
}
if snap.ReevaluationBlocked {
t.Fatalf("expected ReevaluationBlocked=false with no decisions recorded")
}
decisions.byKey[dkey("task-1", "review")] = []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}}
snap2, err := eng.EvaluateStepQuorum(context.Background(), "task-1", "sess-1")
if err != nil {
t.Fatalf("EvaluateStepQuorum: %v", err)
}
if !snap2.ReevaluationBlocked {
t.Fatalf("expected ReevaluationBlocked=true once a decision exists at the current step")
}
}
func TestEvaluateStepQuorum_GuardsOrderedByConfiguredActionOrderAndSatisfiedOmitsReason(t *testing.T) {
store := &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: ActionMoveToStep, Guard: approvedGuard("approver"), MoveToStep: &MoveToStepAction{StepID: "second"}},
{Kind: ActionMoveToStep, Guard: approvedGuard("reviewer"), MoveToStep: &MoveToStepAction{StepID: "first"}},
},
},
},
applied: map[string]bool{},
}
parts := fakeParticipants{list: []ParticipantInfo{
{ID: "p1", Role: "reviewer", DecisionRequired: true, AgentProfileID: "rev-A"},
{ID: "p2", Role: "approver", DecisionRequired: true, AgentProfileID: "app-A"},
}}
decisions := newFakeDecisionStore()
decisions.byKey[dkey("task-1", "review")] = []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}}
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(parts))
snap, err := eng.EvaluateStepQuorum(context.Background(), "task-1", "sess-1")
if err != nil {
t.Fatalf("EvaluateStepQuorum: %v", err)
}
if len(snap.Guards) != 2 {
t.Fatalf("expected 2 guard entries, got %d", len(snap.Guards))
}
// Configured order: approver (unsatisfied, p2 never decided) then reviewer (satisfied).
if snap.Guards[0].Role != "approver" || snap.Guards[0].Satisfied {
t.Fatalf("expected first entry to be the unsatisfied approver guard, got %#v", snap.Guards[0])
}
if snap.Guards[0].Reason != ReasonThresholdNotMet {
t.Fatalf("expected unsatisfied entry to carry threshold_not_met, got %q", snap.Guards[0].Reason)
}
if snap.Guards[1].Role != "reviewer" || !snap.Guards[1].Satisfied {
t.Fatalf("expected second entry to be the satisfied reviewer guard, got %#v", snap.Guards[1])
}
if snap.Guards[1].Reason != "" {
t.Fatalf("expected a satisfied guard entry to omit Reason (AC-60), got %q", snap.Guards[1].Reason)
}
}
@@ -0,0 +1,135 @@
package engine
import (
"context"
"errors"
"testing"
)
// TestResolveParticipantRole_ApproverSeat covers AC-2/AC-3: an agent
// occupying an approver seat at the current step resolves to role
// "approver" and the AC-50 canonical seat id.
func TestResolveParticipantRole_ApproverSeat(t *testing.T) {
participants := scopedParticipants{perTask: []ParticipantInfo{
{ID: "seat-1", TaskID: "task-1", StepID: "review", Role: "approver", AgentProfileID: "agent-a", DecisionRequired: true},
}}
eng := quorumEngine(nil, participants)
role, participantID, err := eng.ResolveParticipantRole(context.Background(), "task-1", "review", "agent-a")
if err != nil {
t.Fatalf("ResolveParticipantRole: %v", err)
}
if role != "approver" {
t.Errorf("role = %q, want approver", role)
}
if participantID != "seat-1" {
t.Errorf("participantID = %q, want seat-1", participantID)
}
}
// TestResolveParticipantRole_ReviewerSeat covers the reviewer-only case.
func TestResolveParticipantRole_ReviewerSeat(t *testing.T) {
participants := scopedParticipants{perTask: []ParticipantInfo{
{ID: "seat-1", TaskID: "task-1", StepID: "review", Role: "reviewer", AgentProfileID: "agent-a", DecisionRequired: true},
}}
eng := quorumEngine(nil, participants)
role, participantID, err := eng.ResolveParticipantRole(context.Background(), "task-1", "review", "agent-a")
if err != nil {
t.Fatalf("ResolveParticipantRole: %v", err)
}
if role != "reviewer" {
t.Errorf("role = %q, want reviewer", role)
}
if participantID != "seat-1" {
t.Errorf("participantID = %q, want seat-1", participantID)
}
}
// TestResolveParticipantRole_ApproverWinsOverReviewer is AC-4: a caller
// holding both seats resolves under approver.
func TestResolveParticipantRole_ApproverWinsOverReviewer(t *testing.T) {
participants := scopedParticipants{perTask: []ParticipantInfo{
{ID: "seat-reviewer", TaskID: "task-1", StepID: "review", Role: "reviewer", AgentProfileID: "agent-a", DecisionRequired: true},
{ID: "seat-approver", TaskID: "task-1", StepID: "review", Role: "approver", AgentProfileID: "agent-a", DecisionRequired: true},
}}
eng := quorumEngine(nil, participants)
role, participantID, err := eng.ResolveParticipantRole(context.Background(), "task-1", "review", "agent-a")
if err != nil {
t.Fatalf("ResolveParticipantRole: %v", err)
}
if role != "approver" || participantID != "seat-approver" {
t.Errorf("role/participantID = %q/%q, want approver/seat-approver", role, participantID)
}
}
// TestResolveParticipantRole_CrossStepPopulation is AC-4a: the caller's
// participant row was attached at an earlier step (a per-task row, not
// scoped to the step currently being evaluated), matching the same
// AC-50 population the quorum evaluator itself reads — not a
// step-scoped subset.
func TestResolveParticipantRole_CrossStepPopulation(t *testing.T) {
participants := scopedParticipants{perTask: []ParticipantInfo{
{ID: "seat-1", TaskID: "task-1", StepID: "earlier-step", Role: "approver", AgentProfileID: "agent-a", DecisionRequired: true},
}}
eng := quorumEngine(nil, participants)
role, participantID, err := eng.ResolveParticipantRole(context.Background(), "task-1", "review", "agent-a")
if err != nil {
t.Fatalf("ResolveParticipantRole: %v", err)
}
if role != "approver" || participantID != "seat-1" {
t.Errorf("role/participantID = %q/%q, want approver/seat-1", role, participantID)
}
}
// TestResolveParticipantRole_NotAParticipant is AC-3: a caller with no
// matching seat is rejected with ErrParticipantNotFound.
func TestResolveParticipantRole_NotAParticipant(t *testing.T) {
participants := scopedParticipants{perTask: []ParticipantInfo{
{ID: "seat-1", TaskID: "task-1", StepID: "review", Role: "approver", AgentProfileID: "agent-other", DecisionRequired: true},
}}
eng := quorumEngine(nil, participants)
if _, _, err := eng.ResolveParticipantRole(context.Background(), "task-1", "review", "agent-a"); !errors.Is(err, ErrParticipantNotFound) {
t.Fatalf("err = %v, want ErrParticipantNotFound", err)
}
}
// TestResolveParticipantRole_DecisionNotRequiredSeatIgnored proves a seat
// with decision_required = 0 does not count as participation (AC-3).
func TestResolveParticipantRole_DecisionNotRequiredSeatIgnored(t *testing.T) {
participants := scopedParticipants{perTask: []ParticipantInfo{
{ID: "seat-1", TaskID: "task-1", StepID: "review", Role: "approver", AgentProfileID: "agent-a", DecisionRequired: false},
}}
eng := quorumEngine(nil, participants)
if _, _, err := eng.ResolveParticipantRole(context.Background(), "task-1", "review", "agent-a"); !errors.Is(err, ErrParticipantNotFound) {
t.Fatalf("err = %v, want ErrParticipantNotFound", err)
}
}
// TestResolveParticipantRole_EmptyAgentProfileIDRejected guards against an
// empty caller id spuriously matching a malformed seat row with no
// agent_profile_id set.
func TestResolveParticipantRole_EmptyAgentProfileIDRejected(t *testing.T) {
eng := quorumEngine(nil, scopedParticipants{})
if _, _, err := eng.ResolveParticipantRole(context.Background(), "task-1", "review", ""); !errors.Is(err, ErrParticipantNotFound) {
t.Fatalf("err = %v, want ErrParticipantNotFound", err)
}
}
// TestResolveParticipantRole_StoreErrorWrapped proves a participant-store
// failure surfaces as a method-level error rather than being swallowed
// into ErrParticipantNotFound.
func TestResolveParticipantRole_StoreErrorWrapped(t *testing.T) {
boom := errors.New("boom")
eng := quorumEngine(nil, scopedParticipants{err: boom})
_, _, err := eng.ResolveParticipantRole(context.Background(), "task-1", "review", "agent-a")
if !errors.Is(err, boom) {
t.Fatalf("err = %v, want wrapped store error", err)
}
}
@@ -72,6 +72,15 @@ func (s *stepStoreForQuorum) LoadPreviousStep(_ context.Context, _ string, _ int
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
}
@@ -99,10 +108,53 @@ func quorumStore(guard *TransitionGuard) *stepStoreForQuorum {
}
}
func TestApplyThreshold_AllApprove(t *testing.T) {
required := []ParticipantInfo{
{ID: "p1"}, {ID: "p2"},
// 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
@@ -115,103 +167,377 @@ func TestApplyThreshold_AllApprove(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := applyThreshold(QuorumAllApprove, required, tc.decisions); got != tc.want {
t.Fatalf("applyThreshold all_approve = %v, want %v", got, tc.want)
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 TestApplyThreshold_AllDecide(t *testing.T) {
required := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}}
func TestEvaluateApproveStyle_AllDecide(t *testing.T) {
seats := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}}
d1 := []DecisionInfo{{ParticipantID: "p1", Decision: "approved"}, {ParticipantID: "p2", Decision: "rejected"}}
if !applyThreshold(QuorumAllDecide, required, d1) {
if !evaluateApproveStyle(QuorumAllDecide, seats, d1).Satisfied {
t.Fatalf("all_decide should be true when both decided")
}
d2 := []DecisionInfo{{ParticipantID: "p1", Decision: "approved"}}
if applyThreshold(QuorumAllDecide, required, d2) {
if evaluateApproveStyle(QuorumAllDecide, seats, d2).Satisfied {
t.Fatalf("all_decide should be false when only one decided")
}
}
func TestApplyThreshold_AnyReject(t *testing.T) {
required := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}}
d := []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}, {ParticipantID: "p2", Decision: DecisionRejected}}
if !applyThreshold(QuorumAnyReject, required, d) {
t.Fatalf("any_reject should be true when one rejected")
}
if applyThreshold(QuorumAnyReject, required, []DecisionInfo{{ParticipantID: "p1", Decision: DecisionApproved}}) {
t.Fatalf("any_reject should be false when no rejection")
}
}
func TestApplyThreshold_MajorityApprove(t *testing.T) {
required := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}, {ID: "p3"}}
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 !applyThreshold(QuorumMajorityApprove, required, d) {
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 applyThreshold(QuorumMajorityApprove, required, d2) {
if evaluateApproveStyle(QuorumMajorityApprove, seats, d2).Satisfied {
t.Fatalf("majority_approve false expected when not strictly more than half")
}
}
func TestApplyThreshold_NApprove(t *testing.T) {
required := []ParticipantInfo{{ID: "p1"}, {ID: "p2"}, {ID: "p3"}}
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 !applyThreshold("n_approve:2", required, d) {
if !evaluateApproveStyle("n_approve:2", seats, d).Satisfied {
t.Fatalf("n_approve:2 expected true")
}
if applyThreshold("n_approve:3", required, d) {
if evaluateApproveStyle("n_approve:3", seats, d).Satisfied {
t.Fatalf("n_approve:3 expected false")
}
if applyThreshold("n_approve:notanint", required, d) {
t.Fatalf("malformed n_approve threshold should fail closed")
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 applyThreshold("n_approve:0", required, d) {
t.Fatalf("n_approve:0 should fail closed")
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 TestApplyThreshold_RemovedParticipantDropped(t *testing.T) {
// p1 was required and approved, then removed. p2 still required and approved.
required := []ParticipantInfo{{ID: "p2"}}
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 !applyThreshold(QuorumAllApprove, required, decisions) {
t.Fatalf("all_approve should be true when removed participant's decision is ignored")
if !evaluateApproveStyle(QuorumAllApprove, seats, decisions).Satisfied {
t.Fatalf("all_approve should be true when the unmapped decision is ignored")
}
}
func TestApplyThreshold_LatestDecisionPerParticipant(t *testing.T) {
required := []ParticipantInfo{{ID: "p1"}}
// p1 approved then rejected
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 applyThreshold(QuorumAllApprove, required, decisions) {
if evaluateApproveStyle(QuorumAllApprove, seats, decisions).Satisfied {
t.Fatalf("expected latest reject to override earlier approve")
}
if !applyThreshold(QuorumAnyReject, required, decisions) {
t.Fatalf("any_reject should be true given latest rejection")
}
}
func TestApplyThreshold_NoRequiredParticipants_FailsClosedHandledByCaller(t *testing.T) {
// applyThreshold itself, with empty required and a non-N threshold,
// returns true for all_approve (vacuously true). Engine wraps this and
// fails closed when len(required) == 0 — pinned in TestWaitForQuorum_*.
if !applyThreshold(QuorumAllApprove, nil, nil) {
t.Fatalf("documented behavior: applyThreshold returns true for vacuous all_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 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},
@@ -346,9 +672,31 @@ func TestEngine_RecordParticipantDecision_PersistsAndReevaluates(t *testing.T) {
}}
eng := New(store, MapRegistry{}, WithDecisionStore(decisions), WithParticipantStore(parts))
if err := eng.RecordParticipantDecision(context.Background(), "task-1", "sess-1", "review", "p1", DecisionApproved, "lgtm"); err != nil {
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))
@@ -356,12 +704,17 @@ func TestEngine_RecordParticipantDecision_PersistsAndReevaluates(t *testing.T) {
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(), "task-1", "sess-1", "review", "p1", DecisionApproved, "")
_, 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")
}
@@ -384,11 +737,18 @@ func TestEngine_RecordParticipantDecision_RequiresIDs(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Signature: (ctx, taskID, sessionID, stepID, participantID, decision, note)
err := eng.RecordParticipantDecision(context.Background(), tc.task, "", tc.step, tc.participant, tc.decision, "")
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)
}
})
}
}
@@ -42,6 +42,11 @@ func (s *triggerStore) LoadPreviousStep(_ context.Context, _ string, _ int) (Ste
func (s *triggerStore) ApplyTransition(_ context.Context, _, _, _, _ string, _ Trigger) error {
return nil
}
func (s *triggerStore) ApplyTransitionIfAtStep(
_ context.Context, _, _, _, _ string, _ Trigger,
) (bool, error) {
return true, nil
}
func (s *triggerStore) PersistData(_ context.Context, _ string, _ map[string]any) error { return nil }
func (s *triggerStore) IsOperationApplied(_ context.Context, op string) (bool, error) {
return s.applied[op], nil
@@ -0,0 +1,21 @@
// Package quorummetrics publishes the AC-24a expvar counter for guarded
// workflow transitions that evaluated but did not fire. It is a neutral
// package so the workflow engine can record the outcome without depending on
// the office or orchestrator packages that read /debug/vars.
package quorummetrics
import "expvar"
// workflowQuorumGuardNotFired counts one AC-23 reason each time a guarded
// transition (wait_for_quorum) is evaluated and does not fire, on the
// engine's transition-evaluation path — both the ordinary HandleTrigger
// path and the AC-65-scoped decision re-evaluation path. It also counts the
// AC-16a/F39 session_unresolvable skip, which is a recording-time skip
// rather than a guard-evaluation outcome. The AC-24b/57d read-only
// diagnostic snapshot never increments this counter.
var workflowQuorumGuardNotFired = expvar.NewMap("workflow_quorum_guard_not_fired_total")
// RecordGuardNotFired counts one occurrence of the given AC-23 reason code.
func RecordGuardNotFired(reason string) {
workflowQuorumGuardNotFired.Add(reason, 1)
}
@@ -0,0 +1,46 @@
package quorummetrics
import (
"expvar"
"strconv"
"testing"
)
func readCounter(t *testing.T, m *expvar.Map, key string) int64 {
t.Helper()
v := m.Get(key)
if v == nil {
return 0
}
n, err := strconv.ParseInt(v.String(), 10, 64)
if err != nil {
t.Fatalf("counter %q value not int: %s", key, v.String())
}
return n
}
func TestRecordGuardNotFired(t *testing.T) {
before := readCounter(t, workflowQuorumGuardNotFired, "threshold_not_met")
RecordGuardNotFired("threshold_not_met")
after := readCounter(t, workflowQuorumGuardNotFired, "threshold_not_met")
if after-before != 1 {
t.Errorf("counter delta = %d, want 1", after-before)
}
}
func TestRecordGuardNotFired_SessionUnresolvableSharesTheSameCounter(t *testing.T) {
before := readCounter(t, workflowQuorumGuardNotFired, "session_unresolvable")
RecordGuardNotFired("session_unresolvable")
after := readCounter(t, workflowQuorumGuardNotFired, "session_unresolvable")
if after-before != 1 {
t.Errorf("counter delta = %d, want 1", after-before)
}
}
func TestWorkflowQuorumGuardNotFiredPublishedAtKnownName(t *testing.T) {
if expvar.Get("workflow_quorum_guard_not_fired_total") == nil {
t.Error("expvar \"workflow_quorum_guard_not_fired_total\" not published — /debug/vars consumers will miss it")
}
}
@@ -0,0 +1,157 @@
package repository
import (
"context"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/testutil"
"github.com/kandev/kandev/internal/workflow/models"
)
// openIsolatedPostgresMultiConn is like testutil.OpenIsolatedPostgres but
// supports a real multi-connection pool. OpenIsolatedPostgres scopes its
// isolated schema via a session-level `SET search_path` issued on one
// connection — fine for its single-connection pool, but a second pooled
// connection never sees that SET and falls back to the default "public"
// schema. Baking search_path into the DSN's libpq `options` param instead
// makes every new connection resolve unqualified table names against the
// isolated schema, so the pool can be sized for genuine concurrency tests.
// Mirrors internal/task/repository/sqlite's helper of the same name.
func openIsolatedPostgresMultiConn(t *testing.T, dsn string, maxConns int) *sqlx.DB {
t.Helper()
schema := "kandev_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
setup, err := sqlx.Open("pgx", dsn)
if err != nil {
t.Fatalf("open postgres (schema setup): %v", err)
}
if _, err := setup.Exec("CREATE SCHEMA " + schema); err != nil {
_ = setup.Close()
t.Fatalf("create postgres schema %s: %v", schema, err)
}
_ = setup.Close()
t.Cleanup(func() {
if cleanup, cerr := sqlx.Open("pgx", dsn); cerr == nil {
_, _ = cleanup.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
_ = cleanup.Close()
}
})
var scopedDSN string
if strings.Contains(dsn, "://") {
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
scopedDSN = dsn + sep + "options=" + url.QueryEscape("-c search_path="+schema)
} else {
scopedDSN = dsn + " options='-c search_path=" + schema + "'"
}
db, err := sqlx.Open("pgx", scopedDSN)
if err != nil {
t.Fatalf("open postgres (scoped, %d conns): %v", maxConns, err)
}
db.SetMaxOpenConns(maxConns)
db.SetMaxIdleConns(maxConns)
t.Cleanup(func() { _ = db.Close() })
return db
}
// setupPostgresDecisionTestRepo opens an isolated multi-connection Postgres
// pool, fakes the workflows/task_sessions tables normally owned by
// internal/task/repository/sqlite (as sqlite_test.go's setupTestRepoWithDB
// does for SQLite), and seeds the "wf-test" workflow row newPhase2TestStep
// expects.
func setupPostgresDecisionTestRepo(t *testing.T, dsn string, maxConns int) *Repository {
t.Helper()
db := openIsolatedPostgresMultiConn(t, dsn, maxConns)
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS workflows (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '',
workflow_template_id TEXT DEFAULT '', name TEXT NOT NULL,
description TEXT DEFAULT '', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL
)`); err != nil {
t.Fatalf("create workflows table: %v", err)
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS task_sessions (id TEXT PRIMARY KEY)`); err != nil {
t.Fatalf("create task_sessions table: %v", err)
}
now := time.Now().UTC()
if _, err := db.Exec(db.Rebind(`
INSERT INTO workflows (id, workspace_id, name, created_at, updated_at) VALUES (?, '', 'Test', ?, ?)
`), "wf-test", now, now); err != nil {
t.Fatalf("seed test workflow: %v", err)
}
repo, err := NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("init postgres schema: %v", err)
}
return repo
}
// TestPostgresRecordStepDecision_ConcurrentSameDeciderRace is the AC-27/29
// PostgreSQL counterpart proving decisionActiveDeciderIndexName actually
// closes the race: under READ COMMITTED, several concurrent
// RecordStepDecision calls for the same (task, step, decider, role) can each
// run the "supersede prior" UPDATE and see zero matching rows before any of
// them commits, then all INSERT — without the unique index this leaves more
// than one active row for the same decider identity. Uses
// openIsolatedPostgresMultiConn (not testutil.OpenIsolatedPostgres, capped
// at one connection) because genuine cross-connection concurrency is the
// whole point. Skips unless KANDEV_TEST_POSTGRES_DSN is set.
func TestPostgresRecordStepDecision_ConcurrentSameDeciderRace(t *testing.T) {
const concurrency = 8
repo := setupPostgresDecisionTestRepo(t, testutil.PostgresDSNFromEnv(t), concurrency)
ctx := context.Background()
step := newPhase2TestStep(t, repo, "Review")
const taskID = "task-decision-race"
start := make(chan struct{})
results := make(chan error, concurrency)
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
results <- repo.RecordStepDecision(ctx, &models.WorkflowStepDecision{
TaskID: taskID, StepID: step.ID, ParticipantID: "p1",
Decision: "approved", DeciderType: "agent", DeciderID: "alice", Role: "reviewer",
})
}()
}
close(start)
wg.Wait()
close(results)
for err := range results {
if err != nil {
t.Fatalf("RecordStepDecision: %v", err)
}
}
all, err := repo.ListStepDecisions(ctx, taskID, step.ID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(all) != concurrency {
t.Fatalf("expected %d total rows (every concurrent write landed), got %d", concurrency, len(all))
}
activeCount := 0
for _, d := range all {
if d.SupersededAt == nil {
activeCount++
}
}
if activeCount != 1 {
t.Fatalf("expected exactly 1 active decision for decider alice/reviewer after the race, got %d", activeCount)
}
}
@@ -5,9 +5,11 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/workflow/models"
@@ -69,9 +71,51 @@ func (r *Repository) initPhase2Schema() error {
return fmt.Errorf("failed to create workflow_step_decisions table: %w", err)
}
// Must run before decisionActiveDeciderIndexName is created below: an
// install that hit the pre-fix AC-27/29 concurrent double-insert race
// (commit 73226d29b) can already hold duplicate active rows for the same
// (task_id, step_id, decider_id, role) identity, and CREATE UNIQUE INDEX
// over them fails outright, aborting backend startup.
if err := r.dedupeActiveStepDecisionsBeforeUniqueIndex(); err != nil {
return fmt.Errorf("failed to dedupe workflow_step_decisions before unique index: %w", err)
}
if _, err := r.db.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS ` + decisionActiveDeciderIndexName + `
ON workflow_step_decisions(task_id, step_id, decider_id, role)
WHERE superseded_at IS NULL AND decider_id != '' AND role != '';
`); err != nil {
return fmt.Errorf("failed to create %s: %w", decisionActiveDeciderIndexName, err)
}
return nil
}
// dedupeActiveStepDecisionsBeforeUniqueIndex supersedes every active
// workflow_step_decisions row except the newest per (task_id, step_id,
// decider_id, role) group, using the same last-row-wins ordering
// (decided_at DESC, id DESC) the engine already uses to evaluate quorum.
// Idempotent: a database with no duplicate active rows leaves this a no-op.
// Mirrors normalizeDuplicateStartSteps below.
func (r *Repository) dedupeActiveStepDecisionsBeforeUniqueIndex() error {
_, err := r.db.Exec(`
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY task_id, step_id, decider_id, role
ORDER BY decided_at DESC, id DESC
) AS decision_rank
FROM workflow_step_decisions
WHERE superseded_at IS NULL AND decider_id != '' AND role != ''
)
UPDATE workflow_step_decisions
SET superseded_at = decided_at
WHERE id IN (SELECT id FROM ranked WHERE decision_rank > 1)
`)
return err
}
// ----------------------------------------------------------------------------
// WorkflowStepParticipant CRUD
// ----------------------------------------------------------------------------
@@ -207,6 +251,89 @@ func (r *Repository) ListStepParticipantsForTask(
return mergeParticipantRows(all), nil
}
// ListParticipantsForTaskAnyStep returns every per-task participant row for
// a task (task_id = taskID) regardless of step_id, unmerged with any
// template-level row. Used by the quorum engine's requiredSeats to build the
// AC-49/50 required slate, which combines this with template rows scoped to
// the evaluating step. An empty result is valid, not an error.
func (r *Repository) ListParticipantsForTaskAnyStep(
ctx context.Context, taskID string,
) ([]*models.WorkflowStepParticipant, error) {
if taskID == "" {
return nil, errors.New("task_id is required")
}
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT id, step_id, task_id, role, agent_profile_id, decision_required, position
FROM workflow_step_participants
WHERE task_id = ?
ORDER BY role ASC, position ASC, id ASC
`), taskID)
if err != nil {
return nil, fmt.Errorf("list participants for task: %w", err)
}
defer func() { _ = rows.Close() }()
all := make([]*models.WorkflowStepParticipant, 0)
for rows.Next() {
p := &models.WorkflowStepParticipant{}
var role string
var decisionRequired int
if err := rows.Scan(&p.ID, &p.StepID, &p.TaskID, &role, &p.AgentProfileID, &decisionRequired, &p.Position); err != nil {
return nil, fmt.Errorf("scan step participant: %w", err)
}
p.Role = models.ParticipantRole(role)
p.DecisionRequired = decisionRequired == 1
all = append(all, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
return all, nil
}
// ListParticipantsForTaskWorkflow returns every per-task participant row for
// a task whose step belongs to workflowID. Participant overrides remain
// durable when a task changes workflow, but they must not leak into quorum
// evaluation for the new workflow.
func (r *Repository) ListParticipantsForTaskWorkflow(
ctx context.Context, taskID, workflowID string,
) ([]*models.WorkflowStepParticipant, error) {
if taskID == "" {
return nil, errors.New("task_id is required")
}
if workflowID == "" {
return nil, errors.New("workflow_id is required")
}
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT p.id, p.step_id, p.task_id, p.role, p.agent_profile_id, p.decision_required, p.position
FROM workflow_step_participants p
JOIN workflow_steps ws ON ws.id = p.step_id
WHERE p.task_id = ? AND ws.workflow_id = ?
ORDER BY p.role ASC, p.position ASC, p.id ASC
`), taskID, workflowID)
if err != nil {
return nil, fmt.Errorf("list participants for task workflow: %w", err)
}
defer func() { _ = rows.Close() }()
all := make([]*models.WorkflowStepParticipant, 0)
for rows.Next() {
p := &models.WorkflowStepParticipant{}
var role string
var decisionRequired int
if err := rows.Scan(&p.ID, &p.StepID, &p.TaskID, &role, &p.AgentProfileID, &decisionRequired, &p.Position); err != nil {
return nil, fmt.Errorf("scan step participant: %w", err)
}
p.Role = models.ParticipantRole(role)
p.DecisionRequired = decisionRequired == 1
all = append(all, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
return all, nil
}
// mergeParticipantRows enforces the per-task-precedence rule. Given a set of
// rows for one step (mixed template-level and per-task), drop any
// template-level row whose (role, agent_profile_id) is also present in a
@@ -510,6 +637,48 @@ func (r *Repository) FindParticipantID(
// WorkflowStepDecision CRUD
// ----------------------------------------------------------------------------
// decisionActiveDeciderIndexName is the partial unique index enforcing at
// most one non-superseded decision per (task_id, step_id, decider_id, role).
// Naming it explicitly keeps isDecisionActiveDeciderViolation attributable
// to this constraint specifically (AC-27/29): under PostgreSQL READ
// COMMITTED, two concurrent RecordStepDecision calls for the same decider
// identity can both run the "supersede prior" UPDATE and see zero matching
// rows (neither has committed yet), then both INSERT — this index turns the
// second INSERT into a constraint violation instead of a silent duplicate
// active row, and recordStepDecisionTx retries on that specific violation so
// the loser's write still lands (properly superseding the winner's row)
// rather than surfacing an error to the caller.
const decisionActiveDeciderIndexName = "uniq_workflow_step_decisions_active_decider"
// sqliteDecisionActiveDeciderViolationMessage is the substring go-sqlite3
// puts in a UNIQUE-constraint error for this index's column list.
const sqliteDecisionActiveDeciderViolationMessage = "UNIQUE constraint failed: workflow_step_decisions.task_id, " +
"workflow_step_decisions.step_id, workflow_step_decisions.decider_id, workflow_step_decisions.role"
// isDecisionActiveDeciderViolation reports whether err is a violation of
// decisionActiveDeciderIndexName specifically, not any unique violation. On
// PostgreSQL it inspects the typed pgconn.PgError's constraint name; on
// SQLite (no typed access to the constraint name) it matches the
// column-list message documented above.
func isDecisionActiveDeciderViolation(err error) bool {
if err == nil {
return false
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23505" && pgErr.ConstraintName == decisionActiveDeciderIndexName
}
return strings.Contains(err.Error(), sqliteDecisionActiveDeciderViolationMessage)
}
// recordStepDecisionMaxAttempts bounds the retry loop in RecordStepDecision.
// One retry resolves the race this index exists for (a second concurrent
// writer's supersede-then-insert observes the first writer's now-committed
// row and correctly supersedes it); further attempts only matter if a third
// writer interleaves in the same narrow window, which two spare attempts
// comfortably covers without ever looping unbounded.
const recordStepDecisionMaxAttempts = 3
// RecordStepDecision inserts a new decision row. If id is empty a UUID is
// generated. If decided_at is the zero value the current UTC time is used.
//
@@ -520,7 +689,9 @@ func (r *Repository) FindParticipantID(
// the prior row superseded inside a transaction, then inserts the new one.
// When decider_id is empty (engine-side callers that only know the
// participant_id) the prior-row check falls back to (task, step,
// participant_id). The transaction guarantees readers never observe a row gap.
// participant_id). The transaction guarantees readers never observe a row
// gap. See decisionActiveDeciderIndexName for why a decider_id+role write
// retries instead of erroring on a lost supersede race.
func (r *Repository) RecordStepDecision(ctx context.Context, d *models.WorkflowStepDecision) error {
if d == nil {
return errors.New("decision must not be nil")
@@ -538,6 +709,22 @@ func (r *Repository) RecordStepDecision(ctx context.Context, d *models.WorkflowS
d.DecidedAt = time.Now().UTC()
}
var err error
for attempt := 0; attempt < recordStepDecisionMaxAttempts; attempt++ {
err = r.recordStepDecisionTx(ctx, d)
if err == nil || !isDecisionActiveDeciderViolation(err) {
return err
}
}
return fmt.Errorf("record step decision: exhausted retries on active-decider race: %w", err)
}
// recordStepDecisionTx runs one supersede-then-insert attempt in its own
// transaction. A caller that loses the AC-27/29 race gets its INSERT
// rejected by decisionActiveDeciderIndexName; RecordStepDecision's retry
// loop then re-runs this from scratch so the supersede UPDATE sees the
// winner's now-committed row.
func (r *Repository) recordStepDecisionTx(ctx context.Context, d *models.WorkflowStepDecision) error {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
@@ -571,6 +758,9 @@ func (r *Repository) RecordStepDecision(ctx context.Context, d *models.WorkflowS
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?)
`), d.ID, d.TaskID, d.StepID, d.ParticipantID, d.Decision, d.Note, d.DecidedAt,
d.DeciderType, d.DeciderID, d.Role, d.Comment); err != nil {
if isDecisionActiveDeciderViolation(err) {
return err
}
return fmt.Errorf("record step decision: %w", err)
}
if err := tx.Commit(); err != nil {
@@ -115,6 +115,80 @@ func TestListStepParticipants_OrderedByRoleAndPosition(t *testing.T) {
}
}
func TestListParticipantsForTaskAnyStep_ReturnsPerTaskRowsAcrossSteps(t *testing.T) {
repo := setupTestRepo(t)
ctx := context.Background()
review := newPhase2TestStep(t, repo, "Review")
approval := newPhase2TestStep(t, repo, "Approval")
mustUpsert := func(stepID, taskID string, role models.ParticipantRole, profile string) {
t.Helper()
if err := repo.UpsertStepParticipant(ctx, &models.WorkflowStepParticipant{
StepID: stepID, TaskID: taskID, Role: role, AgentProfileID: profile,
}); err != nil {
t.Fatalf("upsert: %v", err)
}
}
mustUpsert(review.ID, "task-1", models.ParticipantRoleReviewer, "rev-A")
mustUpsert(approval.ID, "task-1", models.ParticipantRoleApprover, "app-A")
mustUpsert(review.ID, "", models.ParticipantRoleReviewer, "rev-template") // template row, not per-task
mustUpsert(review.ID, "task-2", models.ParticipantRoleReviewer, "rev-B") // different task
got, err := repo.ListParticipantsForTaskAnyStep(ctx, "task-1")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 per-task rows across both steps, got %d: %+v", len(got), got)
}
steps := map[string]bool{}
for _, p := range got {
if p.TaskID != "task-1" {
t.Fatalf("unexpected task_id in result: %+v", p)
}
steps[p.StepID] = true
}
if !steps[review.ID] || !steps[approval.ID] {
t.Fatalf("expected rows from both review and approval steps, got %+v", got)
}
}
func TestListParticipantsForTaskAnyStep_RejectsEmptyTaskID(t *testing.T) {
repo := setupTestRepo(t)
if _, err := repo.ListParticipantsForTaskAnyStep(context.Background(), ""); err == nil {
t.Fatal("expected error for empty task_id")
}
}
func TestListParticipantsForTaskWorkflow_ScopesRowsToActiveWorkflow(t *testing.T) {
repo, db := setupTestRepoWithDB(t)
ctx := context.Background()
if _, err := db.Exec(`INSERT INTO workflows (id, workspace_id, name, created_at, updated_at)
VALUES ('wf-other', '', 'Other', datetime('now'), datetime('now'))`); err != nil {
t.Fatalf("insert second workflow: %v", err)
}
review := newPhase2TestStep(t, repo, "Review")
other := &models.WorkflowStep{WorkflowID: "wf-other", Name: "Other review", Position: 0}
if err := repo.CreateStep(ctx, other); err != nil {
t.Fatalf("create other step: %v", err)
}
for _, p := range []*models.WorkflowStepParticipant{
{StepID: review.ID, TaskID: "task-1", Role: models.ParticipantRoleReviewer, AgentProfileID: "active", DecisionRequired: true},
{StepID: other.ID, TaskID: "task-1", Role: models.ParticipantRoleReviewer, AgentProfileID: "stale", DecisionRequired: true},
} {
if err := repo.UpsertStepParticipant(ctx, p); err != nil {
t.Fatalf("upsert participant: %v", err)
}
}
got, err := repo.ListParticipantsForTaskWorkflow(ctx, "task-1", "wf-test")
if err != nil {
t.Fatalf("list scoped participants: %v", err)
}
if len(got) != 1 || got[0].AgentProfileID != "active" {
t.Fatalf("got scoped participants %+v, want only active row", got)
}
}
func TestDeleteStepParticipant_RemovesRow(t *testing.T) {
repo := setupTestRepo(t)
ctx := context.Background()
@@ -319,6 +393,116 @@ func TestRecordStepDecision_SupersedesPriorByDeciderRole(t *testing.T) {
}
}
// TestIsDecisionActiveDeciderViolation_MatchesSQLiteConstraintMessage forces
// the exact constraint violation decisionActiveDeciderIndexName exists to
// catch — a second active row for the same (task, step, decider, role) —
// by inserting it directly, bypassing RecordStepDecision's own supersede
// step (which would otherwise correctly find and supersede the first row,
// never reaching the index). This is what a writer that lost the AC-27/29
// race produces, and confirms the SQLite branch of the classifier
// RecordStepDecision's retry loop depends on actually recognizes it.
func TestIsDecisionActiveDeciderViolation_MatchesSQLiteConstraintMessage(t *testing.T) {
repo := setupTestRepo(t)
ctx := context.Background()
step := newPhase2TestStep(t, repo, "Review")
first := &models.WorkflowStepDecision{
TaskID: "t-race-classify", StepID: step.ID, ParticipantID: "p1",
Decision: "approved", DeciderType: "agent", DeciderID: "alice", Role: "reviewer",
}
if err := repo.RecordStepDecision(ctx, first); err != nil {
t.Fatalf("record first: %v", err)
}
_, err := repo.db.Exec(repo.db.Rebind(`
INSERT INTO workflow_step_decisions
(id, task_id, step_id, participant_id, decision, decided_at, decider_type, decider_id, role)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`), "manual-conflict-id", "t-race-classify", step.ID, "p1", "approved", time.Now().UTC(), "agent", "alice", "reviewer")
if !isDecisionActiveDeciderViolation(err) {
t.Fatalf("expected isDecisionActiveDeciderViolation to recognize this constraint violation, got err=%v", err)
}
}
// TestInitPhase2Schema_DedupesPreExistingDuplicateActiveDecisionsBeforeUniqueIndex
// reproduces an existing install that hit the pre-fix AC-27/29 concurrent
// double-insert race (commit 73226d29b): two active rows already share the
// same (task_id, step_id, decider_id, role) identity by the time the new
// uniq_workflow_step_decisions_active_decider index is introduced. Without
// dedupeActiveStepDecisionsBeforeUniqueIndex running first,
// CREATE UNIQUE INDEX fails outright and aborts backend startup. This drops
// the index the initial setupTestRepo call already created, reinserts the
// duplicate-row scenario directly (bypassing RecordStepDecision's own
// supersede step, matching the sibling classifier test above), and reruns
// initPhase2Schema to prove it is safe to run again against a database that
// already has the conflicting rows.
func TestInitPhase2Schema_DedupesPreExistingDuplicateActiveDecisionsBeforeUniqueIndex(t *testing.T) {
repo := setupTestRepo(t)
ctx := context.Background()
step := newPhase2TestStep(t, repo, "Review")
if _, err := repo.db.Exec(`DROP INDEX IF EXISTS ` + decisionActiveDeciderIndexName); err != nil {
t.Fatalf("drop unique index to simulate a pre-fix install: %v", err)
}
older := time.Now().UTC().Add(-time.Hour).Truncate(time.Millisecond)
newer := time.Now().UTC().Truncate(time.Millisecond)
insertActive := func(id, taskID, deciderID, role string, decidedAt time.Time) {
t.Helper()
if _, err := repo.db.Exec(repo.db.Rebind(`
INSERT INTO workflow_step_decisions
(id, task_id, step_id, participant_id, decision, decided_at, decider_type, decider_id, role)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`), id, taskID, step.ID, "p1", "approved", decidedAt, "agent", deciderID, role); err != nil {
t.Fatalf("insert duplicate active decision %s: %v", id, err)
}
}
// Duplicate group: same (task_id, step_id, decider_id, role) identity,
// both active (superseded_at NULL) — the exact shape the pre-fix race left
// behind.
insertActive("dup-older", "t-dedupe", "alice", "reviewer", older)
insertActive("dup-newer", "t-dedupe", "alice", "reviewer", newer)
// A distinct decider's active row must survive untouched.
insertActive("solo-active", "t-dedupe", "bob", "reviewer", newer)
if err := repo.initPhase2Schema(); err != nil {
t.Fatalf("initPhase2Schema did not tolerate pre-existing duplicate active decisions: %v", err)
}
active, err := repo.ListActiveTaskDecisions(ctx, "t-dedupe")
if err != nil {
t.Fatalf("list active: %v", err)
}
if len(active) != 2 {
t.Fatalf("expected 2 active decisions after dedupe (1 per decider), got %d: %+v", len(active), active)
}
byID := map[string]*models.WorkflowStepDecision{}
for _, d := range active {
byID[d.ID] = d
}
if byID["dup-newer"] == nil {
t.Fatalf("expected the newest duplicate (dup-newer) to remain active, got %+v", active)
}
if byID["solo-active"] == nil {
t.Fatalf("expected the unrelated decider's active row (solo-active) to remain untouched, got %+v", active)
}
if byID["dup-older"] != nil {
t.Fatalf("expected the older duplicate (dup-older) to be superseded, but it is still active")
}
// The index must now genuinely be enforcing uniqueness again: a fresh
// conflicting insert should fail the same way it would on a database that
// never had duplicates.
_, err = repo.db.Exec(repo.db.Rebind(`
INSERT INTO workflow_step_decisions
(id, task_id, step_id, participant_id, decision, decided_at, decider_type, decider_id, role)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`), "post-dedupe-conflict", "t-dedupe", step.ID, "p1", "approved", time.Now().UTC(), "agent", "alice", "reviewer")
if !isDecisionActiveDeciderViolation(err) {
t.Fatalf("expected the recreated unique index to reject a new conflicting active row, got err=%v", err)
}
}
// TestRecordStepDecision_DifferentDecidersIndependent verifies decisions
// recorded by distinct deciders coexist as separate active rows.
func TestRecordStepDecision_DifferentDecidersIndependent(t *testing.T) {
+3
View File
@@ -429,6 +429,9 @@ const (
ActionMCPListPluginTools = "mcp.list_plugin_tools"
ActionMCPInvokePluginTool = "mcp.invoke_plugin_tool"
// Office quorum decision recording.
ActionMCPRecordStepDecision = "mcp.record_step_decision"
// Config-mode MCP actions (agent-native configuration)
ActionMCPCreateWorkflow = "mcp.create_workflow"
ActionMCPUpdateWorkflow = "mcp.update_workflow"
@@ -0,0 +1,180 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { StateProvider } from "@/components/state-provider";
import type { QuorumGuardEntry, QuorumResponseDTO } from "@/lib/state/slices/office/quorum-types";
import type { Task } from "@/app/office/tasks/[id]/types";
const getTaskQuorumMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/domains/office-extended-api", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/domains/office-extended-api")>(
"@/lib/api/domains/office-extended-api",
);
return {
...actual,
getTaskQuorum: getTaskQuorumMock,
};
});
import { QuorumStatusBadge, aggregateQuorum } from "./quorum-status-badge";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const TS = "2026-05-01T00:00:00Z";
const baseTask: Task = {
id: "t-1",
workspaceId: "ws-1",
identifier: "TASK-1",
title: "First task",
status: "in_review",
priority: "medium",
labels: [],
blockedBy: [],
blocking: [],
children: [],
reviewers: [],
approvers: [],
decisions: [],
createdBy: "user",
createdAt: TS,
updatedAt: TS,
};
function Wrapper({ children }: { children: ReactNode }) {
return (
<StateProvider>
<TooltipProvider>{children}</TooltipProvider>
</StateProvider>
);
}
const awaitingEntry: QuorumGuardEntry = {
target_step_id: "step-2",
role: "approver",
threshold: "n_approve:2",
required_count: 2,
received_count: 1,
satisfied: false,
reason: "threshold_not_met",
};
const stuckEntry: QuorumGuardEntry = {
target_step_id: "step-2",
role: "approver",
threshold: "n_approve:foo",
required_count: 0,
received_count: 0,
satisfied: false,
reason: "threshold_unrecognized",
};
describe("aggregateQuorum", () => {
it("aggregates to clear for an empty guard list", () => {
expect(aggregateQuorum([], false)).toEqual({ state: "clear" });
});
it("aggregates to awaiting when an entry is unsatisfied on threshold_not_met", () => {
expect(aggregateQuorum([awaitingEntry], false)).toEqual({
state: "awaiting",
entry: awaitingEntry,
});
});
it("aggregates to stuck when an entry reports a non-threshold_not_met reason", () => {
expect(aggregateQuorum([awaitingEntry, stuckEntry], false)).toEqual({
state: "stuck",
entry: stuckEntry,
});
});
it("aggregates to stuck with no selected entry when only reevaluation_blocked fires", () => {
expect(aggregateQuorum([], true)).toEqual({ state: "stuck", entry: undefined });
});
it("aggregates to stuck when reevaluation_blocked holds even with all entries satisfied", () => {
const satisfied: QuorumGuardEntry = { ...awaitingEntry, satisfied: true, reason: undefined };
expect(aggregateQuorum([satisfied], true)).toEqual({ state: "stuck", entry: undefined });
});
it("aggregates to clear when every entry is satisfied and not blocked", () => {
const satisfied: QuorumGuardEntry = { ...awaitingEntry, satisfied: true, reason: undefined };
expect(aggregateQuorum([satisfied], false)).toEqual({ state: "clear" });
});
});
const BADGE_TEST_ID = "quorum-status-badge";
function seedQuorum(quorum: QuorumResponseDTO) {
getTaskQuorumMock.mockResolvedValue(quorum);
}
describe("QuorumStatusBadge", () => {
it("fetches when a guarded workflow step has an in-progress legacy status", async () => {
seedQuorum({ guards: [awaitingEntry], reevaluation_blocked: false });
render(
<Wrapper>
<QuorumStatusBadge task={{ ...baseTask, status: "todo" }} />
</Wrapper>,
);
await vi.waitFor(() =>
expect(getTaskQuorumMock).toHaveBeenCalledWith(baseTask.id, baseTask.workspaceId),
);
expect(await screen.findByTestId(BADGE_TEST_ID)).toBeTruthy();
});
it("hides when the guard list aggregates to clear", async () => {
seedQuorum({ guards: [], reevaluation_blocked: false });
render(
<Wrapper>
<QuorumStatusBadge task={baseTask} />
</Wrapper>,
);
await vi.waitFor(() =>
expect(getTaskQuorumMock).toHaveBeenCalledWith(baseTask.id, baseTask.workspaceId),
);
expect(screen.queryByTestId(BADGE_TEST_ID)).toBeNull();
});
it("renders the awaiting state with received/required counts", async () => {
seedQuorum({ guards: [awaitingEntry], reevaluation_blocked: false });
render(
<Wrapper>
<QuorumStatusBadge task={baseTask} />
</Wrapper>,
);
const badge = await screen.findByTestId(BADGE_TEST_ID);
expect(badge.textContent).toContain("1");
expect(badge.textContent).toContain("2");
expect(badge.getAttribute("data-variant")).toBe("outline");
});
it("renders the stuck state naming the selected entry's reason", async () => {
seedQuorum({ guards: [stuckEntry], reevaluation_blocked: false });
render(
<Wrapper>
<QuorumStatusBadge task={baseTask} />
</Wrapper>,
);
const badge = await screen.findByTestId(BADGE_TEST_ID);
expect(badge.getAttribute("data-variant")).toBe("destructive");
expect(screen.getByTestId("quorum-status-reason").textContent).not.toBe("");
});
it("renders stuck when only reevaluation_blocked fires with a satisfied entry", async () => {
const satisfied: QuorumGuardEntry = { ...awaitingEntry, satisfied: true, reason: undefined };
seedQuorum({ guards: [satisfied], reevaluation_blocked: true });
render(
<Wrapper>
<QuorumStatusBadge task={baseTask} />
</Wrapper>,
);
const badge = await screen.findByTestId(BADGE_TEST_ID);
expect(badge.getAttribute("data-variant")).toBe("destructive");
});
});
@@ -0,0 +1,80 @@
"use client";
import { Badge } from "@kandev/ui/badge";
import { useTranslation } from "react-i18next";
import { useTaskQuorum } from "@/hooks/domains/office/use-task-quorum";
import type { QuorumGuardEntry } from "@/lib/state/slices/office/quorum-types";
import type { Task } from "@/app/office/tasks/[id]/types";
export type QuorumAggregate =
| { state: "clear" }
| { state: "awaiting"; entry: QuorumGuardEntry }
| { state: "stuck"; entry: QuorumGuardEntry | undefined };
// aggregateQuorum implements AC-61: a task's AC-24b guard entries collapse to
// exactly one card-level state. `stuck` outranks `awaiting` because a guard
// reporting a real diagnosis (e.g. a mistyped threshold) must not be masked
// by a sibling that is merely waiting on a decision — rendering it as
// "awaiting decisions" forever is the defect AC-53 exists to close. The
// selected entry is the first match in the entries' configured order
// (already AC-17-ordered by the AC-57d snapshot); `reevaluation_blocked`
// alone can trigger `stuck` with no qualifying entry (AC-62), hence the
// `undefined` case.
export function aggregateQuorum(
entries: QuorumGuardEntry[],
reevaluationBlocked: boolean,
): QuorumAggregate {
const stuckEntry = entries.find((e) => !e.satisfied && e.reason !== "threshold_not_met");
if (reevaluationBlocked || stuckEntry) {
return { state: "stuck", entry: stuckEntry };
}
const awaitingEntry = entries.find((e) => !e.satisfied);
if (awaitingEntry) {
return { state: "awaiting", entry: awaitingEntry };
}
return { state: "clear" };
}
const ROLE_LABEL_KEYS: Record<string, string> = {
approver: "task:roleApprover",
reviewer: "task:roleReviewer",
};
type QuorumStatusBadgeProps = {
task: Task;
};
export function QuorumStatusBadge({ task }: QuorumStatusBadgeProps) {
const { t } = useTranslation();
const { quorum } = useTaskQuorum(task.id, task.workspaceId);
if (!quorum) return null;
const aggregate = aggregateQuorum(quorum.guards, quorum.reevaluation_blocked);
if (aggregate.state === "clear") return null;
if (aggregate.state === "stuck") {
const reasonLabel = aggregate.entry
? t(`task:quorumReason.${aggregate.entry.reason}`, { defaultValue: aggregate.entry.reason })
: t("task:quorumStuckPendingReevaluation");
return (
<Badge variant="destructive" className="text-xs" data-testid="quorum-status-badge">
<span>{t("task:quorumStuck")}</span>
<span data-testid="quorum-status-reason">{reasonLabel}</span>
</Badge>
);
}
const { entry } = aggregate;
const roleLabel = t(ROLE_LABEL_KEYS[entry.role] ?? entry.role, { defaultValue: entry.role });
return (
<Badge variant="outline" className="text-xs" data-testid="quorum-status-badge">
{t("task:quorumAwaiting", {
received: entry.received_count,
required: entry.required_count,
role: roleLabel,
})}
</Badge>
);
}
@@ -17,6 +17,7 @@ import { SubIssuesRow } from "./components/sub-issues-row";
import { ReviewersPicker } from "./components/reviewers-picker";
import { ApproversPicker } from "./components/approvers-picker";
import { PendingApprovalBadge } from "./components/pending-approval-badge";
import { QuorumStatusBadge } from "./components/quorum-status-badge";
import { useTranslation } from "react-i18next";
type TaskPropertiesProps = {
@@ -58,6 +59,7 @@ function IdentitySection({ task }: { task: Task }) {
<>
<PropertyRow label={t("common:status")} valueClassName="ml-auto">
<span className="flex items-center gap-2 ml-auto">
<QuorumStatusBadge task={task} />
<PendingApprovalBadge task={task} />
<StatusPicker task={task} />
</span>
@@ -0,0 +1,372 @@
import { test, expect } from "../../fixtures/office-fixture";
import { dwell } from "../../helpers/causal-waits";
import type { ApiClient } from "../../helpers/api-client";
/**
* E2E coverage for the workflow engine's guarded `move_to_step` transitions
* (the office-default template's Review/Approval steps, each gated by a
* `wait_for_quorum` action). Spec:
* docs/specs/workflow-quorum-decision-recording/spec.md
*
* Drives Work -> Review -> Approval with a reviewer and an approver
* attached, asserting advance-on-approve, return-on-reject, and the AC-25
* awaiting-decisions presentation (including the AC-61 role switch once the
* Review guard clears and the Approval guard becomes current). The same
* spec covers AC-56 at no extra cost: after the return-on-reject leg,
* re-entering the guarded step must not immediately bounce again.
*
* Entry into Review/Approval is driven by `POST /tasks/:id/move` (the same
* endpoint the kanban board uses for a manual drag) rather than the office
* `PATCH .../status` endpoint: office-default's Review/Approval steps set
* `allow_manual_move: true` specifically to permit this, and — unlike the
* status PATCH, which only updates the legacy `task.status` column and logs
* activity — a step move fires the engine's real `on_enter` events (e.g.
* Review's `clear_decisions`), which AC-56 depends on.
*
* `office-default`'s Work step has no guarded transition (Work -> Review is
* unconditional), so `GET .../quorum` returning `guards: []` is the signal
* that a task left Review; a non-empty `guards` list is the signal that it
* is currently sitting at a guarded step. This is a backend read rather
* than a UI wait per the repo's causal-waits convention: "the backend
* reached state X" needs no DOM primitive.
*/
// office-default's step ids are regenerated as fresh UUIDs per workspace
// (createWorkflowFromTemplate), so a step must be resolved by its stable
// `stage_type` hint rather than the YAML template's literal `id: review`.
async function findStepId(
apiClient: ApiClient,
workflowId: string,
stageType: "review" | "approval",
): Promise<string> {
const { steps } = await apiClient.listWorkflowSteps(workflowId);
const step = steps.find((s) => s.stage_type === stageType);
if (!step) throw new Error(`no ${stageType} step found in workflow ${workflowId}`);
return step.id;
}
// Mirrors the backend's IsTerminalStepName board convention
// (workflow/models/terminal.go) — office-default's step chain has no
// stage_type for its final column, only a recognized terminal name.
const TERMINAL_STEP_NAMES = new Set(["done", "complete", "completed", "approved"]);
async function findDoneStepId(apiClient: ApiClient, workflowId: string): Promise<string> {
const { steps } = await apiClient.listWorkflowSteps(workflowId);
const step = steps.find((s) => TERMINAL_STEP_NAMES.has(s.name.trim().toLowerCase()));
if (!step) throw new Error(`no terminal step found in workflow ${workflowId}`);
return step.id;
}
// Decisions must be attributed to the reviewer/approver's own role
// (role=reviewer vs role=approver), not the singleton-user shortcut: the
// unauthenticated `X-Office-User-Caller` header always records a decision
// as an implicit approver (resolveDeciderRole in decisions.go), which
// would never satisfy a role=reviewer guard. An agent-scoped decision
// instead requires the same Bearer-JWT identity a real office agent
// authenticates with (AgentAuthMiddleware); the E2E test harness's
// runtime-token mint endpoint (already used by the runtime-*.spec.ts
// suite) is the supported way to obtain one outside a real agent process.
type RecordDecisionOptions = {
workspaceId: string;
taskId: string;
endpoint: "approve" | "request-changes";
agentProfileId: string;
comment?: string;
};
async function recordDecision(apiClient: ApiClient, options: RecordDecisionOptions) {
const { workspaceId, taskId, endpoint, agentProfileId, comment } = options;
const run = await apiClient.seedRun({
agentProfileId,
status: "claimed",
reason: "quorum_decision",
taskId,
});
const { token } = await apiClient.mintRuntimeToken({
agentProfileId,
workspaceId,
runId: run.run_id,
taskId,
});
const baseUrl = (apiClient as unknown as { baseUrl: string }).baseUrl;
const res = await fetch(`${baseUrl}/api/v1/office/tasks/${taskId}/${endpoint}`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ comment }),
});
expect(res.ok).toBe(true);
// The seeded run exists only to mint a decider-scoped JWT; left "claimed"
// forever, it eventually reads to the office scheduler as an abandoned
// run and its reactivity pipeline reacts by flipping the legacy status
// column to "blocked" (apps/backend/internal/office/scheduler/
// reactivity.go) asynchronously, racing the assertions below. Finishing
// it here removes that race instead of chasing its timing.
await apiClient.updateRunStatus(run.run_id, { status: "finished" });
}
async function getQuorumGuards(
apiClient: { rawRequest: (method: string, path: string) => Promise<Response> },
workspaceId: string,
taskId: string,
): Promise<Array<{ role: string; satisfied: boolean; reason?: string }>> {
const res = await apiClient.rawRequest(
"GET",
`/api/v1/office/workspaces/${workspaceId}/tasks/${taskId}/quorum`,
);
expect(res.ok).toBe(true);
const body = (await res.json()) as {
guards: Array<{ role: string; satisfied: boolean; reason?: string }>;
};
return body.guards;
}
test.describe("Office workflow quorum-guarded transitions", () => {
test("advance-on-approve moves Review to Approval, then Approval to Done", async ({
testPage,
apiClient,
officeApi,
officeSeed,
seedData,
}) => {
const reviewer = (await officeApi.createAgent(officeSeed.workspaceId, {
name: "Quorum Reviewer",
role: "worker",
})) as Record<string, unknown>;
const reviewerId = reviewer.id as string;
const approver = (await officeApi.createAgent(officeSeed.workspaceId, {
name: "Quorum Approver",
role: "worker",
})) as Record<string, unknown>;
const approverId = approver.id as string;
// EnsureSession requires a resolvable agent profile (PrepareSession
// rejects an empty one); a task with no assignee and a workspace with no
// configured default has nothing to resolve otherwise, so the task's own
// agent_profile_id metadata is what EnsureSession picks up
// (resolveTaskAgentProfile checks task.Metadata first).
const task = await apiClient.createTask(officeSeed.workspaceId, "Quorum Advance Task", {
workflow_id: officeSeed.workflowId,
agent_profile_id: seedData.agentProfileId,
});
// Move to Review BEFORE ensuring a session: only the Work step has the
// auto_start_agent on-enter action, so EnsureSession would otherwise
// resolve intent=start and launch through startTask, which requires
// office-scheduler-injected KANDEV_* runtime env this external HTTP call
// has no way to supply. On Review, EnsureSession resolves intent=prepare
// instead, which only needs a DB row.
const reviewStepId = await findStepId(apiClient, officeSeed.workflowId, "review");
await apiClient.moveTask(task.id, officeSeed.workflowId, reviewStepId);
// AddTaskParticipant binds the new row to the task's CURRENT
// workflow_step_id (workflow_step_participants.step_id), not a
// caller-chosen step: register the reviewer only after the move so the
// row lands under Review's step id — registering beforehand (while the
// task still sits on Work) would silently orphan it, since
// resolveDeciderRole's participant lookup filters by the task's
// present step.
await apiClient.rawRequest("POST", `/api/v1/office/tasks/${task.id}/reviewers`, {
agent_profile_id: reviewerId,
});
// The quorum evaluator resolves a session-scoped machine state (AC-16/
// F38), so a task with no session at all always yields an empty
// snapshot regardless of workflow_step_id.
await apiClient.ensureTaskSession(task.id);
// AC-25: the Review step's guard is unsatisfied (reviewer has not
// decided yet), so the diagnostic read reports one awaiting entry.
await expect
.poll(
async () => (await getQuorumGuards(apiClient, officeSeed.workspaceId, task.id))[0]?.role,
)
.toBe("reviewer");
// AC-25 UI presentation: the badge renders the awaiting state.
await testPage.goto(`/office/tasks/${task.id}`);
await expect(testPage.getByRole("heading", { name: "Quorum Advance Task" })).toBeVisible({
timeout: 10_000,
});
const badge = testPage.getByTestId("quorum-status-badge");
await expect(badge).toBeVisible({ timeout: 10_000 });
await expect(badge).toContainText("Reviewer");
await expect(badge).toHaveAttribute("data-variant", "outline");
// The sole reviewer approves: the Review->Approval guard
// (role=reviewer, threshold=all_approve) is now satisfied and fires.
await recordDecision(apiClient, {
workspaceId: officeSeed.workspaceId,
taskId: task.id,
endpoint: "approve",
agentProfileId: reviewerId,
comment: "looks good",
});
// AC-61: the card-level state now reflects the Approval step's guard
// (role=approver) rather than the cleared Review guard.
await expect
.poll(
async () => (await getQuorumGuards(apiClient, officeSeed.workspaceId, task.id))[0]?.role,
)
.toBe("approver");
// The task now sits on Approval (confirmed by the guard poll above), so
// registering the approver here binds their participant row to
// Approval's step id, per the same current-step-scoping rule as the
// reviewer registration. This must happen before the reload/badge
// assertion below: with no approver seat registered yet, the AC-50
// required slate for role=approver is empty, which the engine reports
// as ReasonSlateEmpty (a genuine "stuck" diagnostic, not
// threshold_not_met) — registering first is what makes the badge's
// awaiting-approver state observable at all.
await apiClient.rawRequest("POST", `/api/v1/office/tasks/${task.id}/approvers`, {
agent_profile_id: approverId,
});
await testPage.reload();
await expect(badge).toContainText("Approver", { timeout: 10_000 });
// The sole approver approves: Approval->Done fires and the task has no
// guarded transition left, so the diagnostic read goes empty.
await recordDecision(apiClient, {
workspaceId: officeSeed.workspaceId,
taskId: task.id,
endpoint: "approve",
agentProfileId: approverId,
comment: "shipping it",
});
await expect
.poll(async () => (await getQuorumGuards(apiClient, officeSeed.workspaceId, task.id)).length)
.toBe(0);
// Engine-driven transitions (both the ordinary ApplyTransition path and
// this feature's AC-46/48 compare-and-swap) move workflow_step_id only —
// syncing the legacy task.status/state column is exclusive to the
// manual-move service path (MoveTaskWithOptions) and is unrelated,
// pre-existing behavior this spec does not change. Assert landing via
// the engine's own record of position instead of the legacy column.
const doneStepId = await findDoneStepId(apiClient, officeSeed.workflowId);
await expect
.poll(async () => (await apiClient.getTask(task.id)).workflow_step_id)
.toBe(doneStepId);
await testPage.reload();
await expect(testPage.getByTestId("quorum-status-badge")).toHaveCount(0);
});
test("return-on-reject sends the task back to Work, and re-entering Review does not immediately re-bounce (AC-56)", async ({
testPage,
apiClient,
officeApi,
officeSeed,
seedData,
}) => {
const reviewer = (await officeApi.createAgent(officeSeed.workspaceId, {
name: "Quorum Rejecting Reviewer",
role: "worker",
})) as Record<string, unknown>;
const reviewerId = reviewer.id as string;
const task = await apiClient.createTask(officeSeed.workspaceId, "Quorum Reject Task", {
workflow_id: officeSeed.workflowId,
agent_profile_id: seedData.agentProfileId,
});
// Move to Review BEFORE ensuring a session: only the Work step has the
// auto_start_agent on-enter action, so EnsureSession would otherwise
// resolve intent=start and launch through startTask, which requires
// office-scheduler-injected KANDEV_* runtime env this external HTTP call
// has no way to supply. On Review, EnsureSession resolves intent=prepare
// instead, which only needs a DB row.
const reviewStepId = await findStepId(apiClient, officeSeed.workflowId, "review");
await apiClient.moveTask(task.id, officeSeed.workflowId, reviewStepId);
// AddTaskParticipant binds to the task's CURRENT step id, so the
// reviewer must be registered after the move lands the task on Review
// (see the matching comment in the advance-on-approve test above).
await apiClient.rawRequest("POST", `/api/v1/office/tasks/${task.id}/reviewers`, {
agent_profile_id: reviewerId,
});
// The quorum evaluator resolves a session-scoped machine state (AC-16/
// F38), so a task with no session at all always yields an empty
// snapshot regardless of workflow_step_id. Seed the row directly rather
// than calling EnsureSession: a real (or mock-agent) launch finishes its
// turn and leaves the DB-tracked state within this test's runtime,
// dropping out of AC-16's active-session set (CREATED/STARTING/RUNNING/
// WAITING_FOR_INPUT). AC-62's `reevaluation_blocked` is computed live
// from that same query, so a session that goes idle between setup and
// the assertions below makes the card render "stuck" — a real, correctly
// computed state per spec, just not the one this test means to hold
// constant. A directly seeded row has no lifecycle to decay.
await apiClient.seedTaskSession(task.id, {
state: "WAITING_FOR_INPUT",
agentProfileId: seedData.agentProfileId,
});
// Nothing past this point needs the task's own agent_profile_id
// metadata. Clear it: the request-changes decision below also queues a
// "task_changes_requested" run for whichever agent IS the assignee
// (decisions.go:runReactivityForDecision), unrelated to the quorum
// guard this test exercises, so a real agent could act on the
// feedback. This synthetic HTTP-driven harness has no such agent behind
// that run, so it fails and its office-scheduler reactivity pipeline
// flips the legacy status column to "blocked" (reactivity.go's
// normalisedStatus FAILED->blocked mapping), racing the status-dependent
// assertions below. With no assignee, decisions.go's own guard
// (`exec.AssigneeAgentProfileID == ""`) means the run is never queued in
// the first place.
const clearAssignee = await apiClient.rawRequest("PATCH", `/api/v1/office/tasks/${task.id}`, {
assignee_agent_profile_id: "",
});
expect(clearAssignee.ok).toBe(true);
await expect
.poll(async () => (await getQuorumGuards(apiClient, officeSeed.workspaceId, task.id)).length)
.toBeGreaterThan(0);
// any_reject fires on a single veto: the task returns to Work, which
// has no guarded transition, so the diagnostic read goes empty.
await recordDecision(apiClient, {
workspaceId: officeSeed.workspaceId,
taskId: task.id,
endpoint: "request-changes",
agentProfileId: reviewerId,
comment: "please rework this",
});
await expect
.poll(async () => (await getQuorumGuards(apiClient, officeSeed.workspaceId, task.id)).length)
.toBe(0);
// Re-enter Review. Its on_enter action clears superseded decisions, so
// the stale any_reject vote must not immediately fire the same guard
// again (AC-56) — the fresh read should show an unsatisfied-but-pending
// reviewer guard, not an empty (already-bounced) one.
await apiClient.moveTask(task.id, officeSeed.workflowId, reviewStepId);
await expect
.poll(
async () => (await getQuorumGuards(apiClient, officeSeed.workspaceId, task.id))[0]?.reason,
)
.toBe("threshold_not_met");
// Confirm it stays put rather than bouncing back to Work shortly after.
await dwell(500, "negative-assertion", "asserting the guard does not re-fire on stale state");
const guardsAfterDwell = await getQuorumGuards(apiClient, officeSeed.workspaceId, task.id);
expect(guardsAfterDwell[0]?.role).toBe("reviewer");
expect(guardsAfterDwell[0]?.satisfied).toBe(false);
// AC-25 UI presentation of the stable awaiting state. The badge fetches
// by task and workspace, so it does not depend on the legacy status
// column matching the workflow step.
await testPage.goto(`/office/tasks/${task.id}`);
await expect(testPage.getByRole("heading", { name: "Quorum Reject Task" })).toBeVisible({
timeout: 10_000,
});
const badge = testPage.getByTestId("quorum-status-badge");
await expect(badge).toBeVisible({ timeout: 10_000 });
await expect(badge).toHaveAttribute("data-variant", "outline");
});
});
@@ -0,0 +1,92 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useTaskQuorum } from "./use-task-quorum";
const mocks = vi.hoisted(() => ({
getTaskQuorum: vi.fn(),
}));
vi.mock("@/lib/api/domains/office-extended-api", () => ({
getTaskQuorum: mocks.getTaskQuorum,
}));
const setTaskQuorum = vi.fn();
let byTaskId: Record<string, unknown> = {};
let refetchTriggers: Record<string, number> = {};
const WORKSPACE_ID = "workspace-1";
vi.mock("@/components/state-provider", () => ({
useAppStore: (sel: (state: unknown) => unknown) =>
sel({
office: {
taskQuorum: { byTaskId },
refetchTriggers,
},
setTaskQuorum,
}),
}));
describe("useTaskQuorum", () => {
beforeEach(() => {
setTaskQuorum.mockReset();
mocks.getTaskQuorum.mockReset();
mocks.getTaskQuorum.mockResolvedValue({ guards: [], reevaluation_blocked: false });
byTaskId = {};
refetchTriggers = {};
});
it("fetches once on mount for a given task", async () => {
renderHook(() => useTaskQuorum("task-1", WORKSPACE_ID));
await waitFor(() => expect(mocks.getTaskQuorum).toHaveBeenCalledTimes(1));
expect(mocks.getTaskQuorum).toHaveBeenCalledWith("task-1", WORKSPACE_ID);
});
it("refetches when taskId changes, even though it already fetched once (AC: task switch)", async () => {
const { rerender } = renderHook(
({ taskId }: { taskId: string | null }) => useTaskQuorum(taskId, WORKSPACE_ID),
{
initialProps: { taskId: "task-1" },
},
);
await waitFor(() => expect(mocks.getTaskQuorum).toHaveBeenCalledTimes(1));
rerender({ taskId: "task-2" });
await waitFor(() => expect(mocks.getTaskQuorum).toHaveBeenCalledTimes(2));
expect(mocks.getTaskQuorum).toHaveBeenLastCalledWith("task-2", WORKSPACE_ID);
});
it("refetches when a task leaves and re-enters review (null -> id -> null -> id), driven by the WS-bumped trigger rather than the taskId prop alone", async () => {
// office.task.status_changed (lib/ws/handlers/office.ts) bumps
// `task:${taskId}` on every status transition, including re-entering
// review with the same task id — this is what actually invalidates the
// stale snapshot in production, not the taskId prop toggling alone.
const { rerender } = renderHook(
({ taskId }: { taskId: string | null }) => useTaskQuorum(taskId, WORKSPACE_ID),
{
initialProps: { taskId: "task-1" as string | null },
},
);
await waitFor(() => expect(mocks.getTaskQuorum).toHaveBeenCalledTimes(1));
rerender({ taskId: null });
refetchTriggers = { "task:task-1": 1 };
rerender({ taskId: "task-1" });
await waitFor(() => expect(mocks.getTaskQuorum).toHaveBeenCalledTimes(2));
});
it("refetches when office.task.decision_recorded bumps the task's refetch trigger", async () => {
const { rerender } = renderHook(() => useTaskQuorum("task-1", WORKSPACE_ID));
await waitFor(() => expect(mocks.getTaskQuorum).toHaveBeenCalledTimes(1));
refetchTriggers = { "task:task-1": 1 };
rerender();
await waitFor(() => expect(mocks.getTaskQuorum).toHaveBeenCalledTimes(2));
});
it("does not refetch on mount just because the trigger map already has an unrelated entry", async () => {
refetchTriggers = { "task:task-other": 3 };
renderHook(() => useTaskQuorum("task-1", WORKSPACE_ID));
await waitFor(() => expect(mocks.getTaskQuorum).toHaveBeenCalledTimes(1));
expect(mocks.getTaskQuorum).toHaveBeenCalledWith("task-1", WORKSPACE_ID);
});
});
@@ -0,0 +1,67 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useAppStore } from "@/components/state-provider";
import { getTaskQuorum } from "@/lib/api/domains/office-extended-api";
import type { QuorumResponseDTO } from "@/lib/state/slices/office/quorum-types";
import { t } from "@/lib/i18n";
export type UseTaskQuorumResult = {
quorum: QuorumResponseDTO | undefined;
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
};
export function useTaskQuorum(
taskId: string | null,
workspaceId: string | null,
): UseTaskQuorumResult {
const quorum = useAppStore((s) => (taskId ? s.office.taskQuorum.byTaskId[taskId] : undefined));
const setTaskQuorum = useAppStore((s) => s.setTaskQuorum);
// office.task.decision_recorded (lib/ws/handlers/office.ts) bumps this
// counter for `task:${taskId}` — recording a decision changes the guard's
// approve/reject count, so the cached snapshot must be invalidated even
// though taskId itself hasn't changed.
const decisionRefetchTrigger = useAppStore((s) =>
taskId ? s.office.refetchTriggers[`task:${taskId}`] : undefined,
);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fetchedScope, setFetchedScope] = useState<string | null>(null);
const requestGeneration = useRef(0);
const refresh = useCallback(async () => {
if (!taskId || !workspaceId) return;
const generation = ++requestGeneration.current;
setIsLoading(true);
setError(null);
try {
const res = await getTaskQuorum(taskId, workspaceId);
if (generation !== requestGeneration.current) return;
setTaskQuorum(taskId, res);
setFetchedScope(`${workspaceId}:${taskId}`);
} catch (e) {
if (generation !== requestGeneration.current) return;
setError(e instanceof Error ? e.message : t("office:failedToLoadTaskQuorum"));
} finally {
if (generation === requestGeneration.current) setIsLoading(false);
}
}, [taskId, workspaceId, setTaskQuorum]);
useEffect(() => {
requestGeneration.current += 1;
}, [taskId, workspaceId]);
useEffect(() => {
if (!taskId || !workspaceId || fetchedScope === `${workspaceId}:${taskId}`) return;
void refresh();
}, [taskId, workspaceId, fetchedScope, refresh]);
useEffect(() => {
if (!taskId || !workspaceId || decisionRefetchTrigger === undefined) return;
void refresh();
}, [taskId, workspaceId, decisionRefetchTrigger, refresh]);
return { quorum, isLoading, error, refresh };
}
@@ -1,5 +1,6 @@
import { fetchJson, fetchJsonWithRetry, type ApiRequestOptions } from "../client";
import type { DashboardData } from "@/lib/state/slices/office/types";
import type { QuorumResponseDTO } from "@/lib/state/slices/office/quorum-types";
import { normalizeOfficeTask, type OfficeTaskWire } from "./office-task-normalize";
const BASE = "/api/v1/office";
@@ -337,6 +338,15 @@ export function listTaskDecisions(taskId: string, options?: ApiRequestOptions) {
).then((res) => res.decisions ?? []);
}
// --- Task quorum (AC-24b diagnostic read) ---
export function getTaskQuorum(taskId: string, workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<QuorumResponseDTO>(
`${BASE}/workspaces/${workspaceId}/tasks/${taskId}/quorum`,
options,
);
}
// --- Comments ---
export type TaskCommentResponse = {
@@ -51,6 +51,7 @@ export const defaultOfficeState: OfficeSliceState = {
providerHealth: { byWorkspace: {} },
runAttempts: { byRunId: {} },
agentRouting: { byAgentId: {} },
taskQuorum: { byTaskId: {} },
},
};
@@ -346,6 +347,13 @@ function createRoutingActions(set: SetFn) {
set((draft) => {
draft.office.agentRouting.byAgentId[agentId] = data;
}),
setTaskQuorum: (
taskId: string,
quorum: OfficeSlice["office"]["taskQuorum"]["byTaskId"][string],
) =>
set((draft) => {
draft.office.taskQuorum.byTaskId[taskId] = quorum;
}),
};
}
@@ -0,0 +1,22 @@
// QuorumGuardEntry mirrors the backend's GuardStateDTO
// (internal/office/dashboard/quorum_dto.go) — one guarded on_turn_complete
// transition's live evaluation state at the task's current step (AC-24b).
export type QuorumGuardEntry = {
target_step_id: string;
role: string;
threshold: string;
required_count: number;
received_count: number;
satisfied: boolean;
reason?: string;
error?: string;
};
export type QuorumResponseDTO = {
guards: QuorumGuardEntry[];
reevaluation_blocked: boolean;
};
export type TaskQuorumSliceState = {
byTaskId: Record<string, QuorumResponseDTO | undefined>;
};
@@ -19,6 +19,7 @@ export type {
export type { OfficeAgentProfile as AgentProfile } from "@/lib/types/agent-profile";
import type { OfficeAgentProfile as AgentProfile } from "@/lib/types/agent-profile";
import type { QuorumResponseDTO, TaskQuorumSliceState } from "./quorum-types";
export type SkillSourceType =
| "inline"
@@ -542,6 +543,7 @@ export type OfficeSliceState = {
providerHealth: ProviderHealthSliceState;
runAttempts: RunAttemptsState;
agentRouting: AgentRoutingSliceState;
taskQuorum: TaskQuorumSliceState;
};
};
@@ -588,6 +590,7 @@ export type OfficeSliceActions = {
setRunAttempts: (runId: string, attempts: RouteAttempt[]) => void;
appendRunAttempt: (runId: string, attempt: RouteAttempt) => void;
setAgentRouting: (agentId: string, data: AgentRouteData | undefined) => void;
setTaskQuorum: (taskId: string, quorum: QuorumResponseDTO) => void;
};
export type OfficeSlice = OfficeSliceState & OfficeSliceActions;
+2 -1
View File
@@ -902,5 +902,6 @@
"failedToLoadProviderHealth": "Failed to load provider health",
"failedToLoadRouteAttempts": "Failed to load route attempts",
"failedToLoadRoutingConfig": "Failed to load routing config",
"failedToLoadRoutingPreview": "Failed to load routing preview"
"failedToLoadRoutingPreview": "Failed to load routing preview",
"failedToLoadTaskQuorum": "Failed to load task quorum"
}
+3
View File
@@ -1043,6 +1043,9 @@
"questionOfTotalAnswered": "Question {{index}} of {{total}} (answered)",
"queueMoreInstructions": "Queue more instructions...",
"queued": "Queued",
"quorumAwaiting": "Awaiting {{received}} of {{required}} {{role}} decisions",
"quorumStuck": "Stuck",
"quorumStuckPendingReevaluation": "Blocked: no active session to re-evaluate this step",
"ranCommands_one": "· ran {{count}} command",
"ranCommands_other": "· ran {{count}} commands",
"random": "Random",
+2 -1
View File
@@ -902,5 +902,6 @@
"failedToLoadProviderHealth": "Ƒàĩĺēď ţō ĺōàď ƥŕōvĩďēŕ ĥēàĺţĥ",
"failedToLoadRouteAttempts": "Ƒàĩĺēď ţō ĺōàď ŕōũţē àţţēḿƥţś",
"failedToLoadRoutingConfig": "Ƒàĩĺēď ţō ĺōàď ŕōũţĩńĝ ćōńƒĩĝ",
"failedToLoadRoutingPreview": "Ƒàĩĺēď ţō ĺōàď ŕōũţĩńĝ ƥŕēvĩēŵ"
"failedToLoadRoutingPreview": "Ƒàĩĺēď ţō ĺōàď ŕōũţĩńĝ ƥŕēvĩēŵ",
"failedToLoadTaskQuorum": "Ƒàĩĺēď ţō ĺōàď ţàśķ qũōŕũḿ"
}
+3
View File
@@ -1043,6 +1043,9 @@
"questionOfTotalAnswered": "Qũēśţĩōń {{index}} ōƒ {{total}} (àńśŵēŕēď)",
"queueMoreInstructions": "Qũēũē ḿōŕē ĩńśţŕũćţĩōńś...",
"queued": "Qũēũēď",
"quorumAwaiting": "Àŵàĩţĩńĝ {{received}} ōƒ {{required}} {{role}} ďēćĩśĩōńś",
"quorumStuck": "Śţũćķ",
"quorumStuckPendingReevaluation": "Ɓĺōćķēď: ńō àćţĩvē śēśśĩōń ţō ŕē-ēvàĺũàţē ţĥĩś śţēƥ",
"ranCommands_one": "· ŕàń {{count}} ćōḿḿàńď",
"ranCommands_other": "· ŕàń {{count}} ćōḿḿàńďś",
"random": "Ŕàńďōḿ",
+2 -1
View File
@@ -902,5 +902,6 @@
"failedToLoadProviderHealth": "Falha ao carregar a saúde do fornecedor",
"failedToLoadRouteAttempts": "Falha ao carregar as tentativas de rota",
"failedToLoadRoutingConfig": "Falha ao carregar a configuração de encaminhamento",
"failedToLoadRoutingPreview": "Falha ao carregar a pré-visualização do encaminhamento"
"failedToLoadRoutingPreview": "Falha ao carregar a pré-visualização do encaminhamento",
"failedToLoadTaskQuorum": "Falha ao carregar o quórum da tarefa"
}
+3
View File
@@ -998,6 +998,9 @@
"questionOfTotalAnswered": "Pergunta {{index}} de {{total}} (respondida)",
"queueMoreInstructions": "Colocar mais instruções em fila...",
"queued": "Em fila",
"quorumAwaiting": "A aguardar {{received}} de {{required}} decisões de {{role}}",
"quorumStuck": "Bloqueado",
"quorumStuckPendingReevaluation": "Bloqueado: sem sessão ativa para reavaliar esta etapa",
"ranCommands_one": "· executou {{count}} comando",
"ranCommands_other": "· executou {{count}} comandos",
"random": "Aleatória",
+2 -1
View File
@@ -902,5 +902,6 @@
"failedToLoadProviderHealth": "加载服务商健康状态失败",
"failedToLoadRouteAttempts": "加载路由尝试记录失败",
"failedToLoadRoutingConfig": "加载路由配置失败",
"failedToLoadRoutingPreview": "加载路由预览失败"
"failedToLoadRoutingPreview": "加载路由预览失败",
"failedToLoadTaskQuorum": "加载任务法定人数失败"
}
+3
View File
@@ -997,6 +997,9 @@
"questionOfTotalAnswered": "问题 {{index}} / {{total}}(已回答)",
"queueMoreInstructions": "排队更多指示…",
"queued": "已排队",
"quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 项决定",
"quorumStuck": "受阻",
"quorumStuckPendingReevaluation": "受阻:没有活动会话可重新评估此步骤",
"ranCommands_one": "· 运行了 {{count}} 条命令",
"ranCommands_other": "· 运行了 {{count}} 条命令",
"random": "随机",
+3 -3
View File
@@ -44,7 +44,7 @@
"failedToMergeQueuedMessages": "合併排隊中的訊息失敗。",
"failedToRemoveQueuedMessage": "移除排隊中的訊息失敗。",
"failedToReorderQueuedMessages": "無法重新排序佇列中的訊息。",
"failedToSetQueueAutoRun": "更新訊息佇列的自動執行設定失敗。",
"failedToSetQueueAutoRun": "更新佇列自動執行設定失敗。",
"failedToStartQuickChat": "啓動快速聊天失敗",
"fileTreeActions": "檔案操作",
"loadingRepositories": "正在載入儲存庫",
@@ -76,8 +76,8 @@
"queueCapacityFull_one": "{{count}} / {{max}} · 已滿",
"queueCapacityFull_other": "{{count}} / {{max}} · 已滿",
"queueAutoRun": "自動執行",
"queueAutoRunOffHelp": "完成目前的回覆後,排隊中的訊息會等待。",
"queueAutoRunOnHelp": "逐一執行排隊中的訊息。",
"queueAutoRunOffHelp": "完成目前回覆後,排隊中的訊息將等待。",
"queueAutoRunOnHelp": "逐條執行排隊中的訊息。",
"queueChipExpand_one": "{{count}} 條排隊訊息,點擊展開",
"queueChipExpand_other": "{{count}} 條排隊訊息,點擊展開",
"queueCount_one": "{{count}}",
+2 -1
View File
@@ -902,5 +902,6 @@
"failedToLoadProviderHealth": "載入服務商健康狀態失敗",
"failedToLoadRouteAttempts": "載入路由嘗試記錄失敗",
"failedToLoadRoutingConfig": "載入路由設定失敗",
"failedToLoadRoutingPreview": "載入路由預覽失敗"
"failedToLoadRoutingPreview": "載入路由預覽失敗",
"failedToLoadTaskQuorum": "載入任務法定人數失敗"
}
+3
View File
@@ -997,6 +997,9 @@
"questionOfTotalAnswered": "問題 {{index}} / {{total}}(已回答)",
"queueMoreInstructions": "排隊更多指示…",
"queued": "已排隊",
"quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定",
"quorumStuck": "受阻",
"quorumStuckPendingReevaluation": "受阻:沒有活動工作階段可重新評估此步驟",
"ranCommands_one": "· 執行了 {{count}} 條命令",
"ranCommands_other": "· 執行了 {{count}} 條命令",
"random": "隨機",
+3 -3
View File
@@ -44,7 +44,7 @@
"failedToMergeQueuedMessages": "合併排隊中的訊息失敗。",
"failedToRemoveQueuedMessage": "移除排隊中的訊息失敗。",
"failedToReorderQueuedMessages": "無法重新排序佇列中的訊息。",
"failedToSetQueueAutoRun": "更新訊息佇列的自動執行設定失敗。",
"failedToSetQueueAutoRun": "更新佇列自動執行設定失敗。",
"failedToStartQuickChat": "啟動快速聊天失敗",
"fileTreeActions": "檔案操作",
"loadingRepositories": "正在載入儲存庫",
@@ -76,8 +76,8 @@
"queueCapacityFull_one": "{{count}} / {{max}} · 已滿",
"queueCapacityFull_other": "{{count}} / {{max}} · 已滿",
"queueAutoRun": "自動執行",
"queueAutoRunOffHelp": "完成目前的回覆後,排隊中的訊息會等待。",
"queueAutoRunOnHelp": "逐一執行排隊中的訊息。",
"queueAutoRunOffHelp": "完成目前回覆後,排隊中的訊息將等待。",
"queueAutoRunOnHelp": "逐條執行排隊中的訊息。",
"queueChipExpand_one": "{{count}} 條排隊訊息,點選展開",
"queueChipExpand_other": "{{count}} 條排隊訊息,點選展開",
"queueCount_one": "{{count}}",
+2 -1
View File
@@ -902,5 +902,6 @@
"failedToLoadProviderHealth": "載入服務商健康狀態失敗",
"failedToLoadRouteAttempts": "載入路由嘗試記錄失敗",
"failedToLoadRoutingConfig": "載入路由設定失敗",
"failedToLoadRoutingPreview": "載入路由預覽失敗"
"failedToLoadRoutingPreview": "載入路由預覽失敗",
"failedToLoadTaskQuorum": "載入任務法定人數失敗"
}
+3
View File
@@ -997,6 +997,9 @@
"questionOfTotalAnswered": "問題 {{index}} / {{total}}(已回答)",
"queueMoreInstructions": "排隊更多指示…",
"queued": "已排隊",
"quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定",
"quorumStuck": "受阻",
"quorumStuckPendingReevaluation": "受阻:沒有活動工作階段可重新評估此步驟",
"ranCommands_one": "· 執行了 {{count}} 條命令",
"ranCommands_other": "· 執行了 {{count}} 條命令",
"random": "隨機",
+1
View File
@@ -280,6 +280,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance,
| [auth](auth/spec.md) | building |
| [create-local-repository](create-local-repository/spec.md) | shipped |
| [workflow-cycle-guardrails](workflow-cycle-guardrails/spec.md) | building |
| [workflow-quorum-decision-recording](workflow-quorum-decision-recording/spec.md) | draft |
| [improve-kandev](improve-kandev/spec.md) | building |
| [homebrew-core](homebrew-core/spec.md) | building |
| [native-kandev-cli](native-kandev-cli/spec.md) | draft |
File diff suppressed because it is too large Load Diff