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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -902,5 +902,6 @@
|
||||
"failedToLoadProviderHealth": "Ƒàĩĺēď ţō ĺōàď ƥŕōvĩďēŕ ĥēàĺţĥ",
|
||||
"failedToLoadRouteAttempts": "Ƒàĩĺēď ţō ĺōàď ŕōũţē àţţēḿƥţś",
|
||||
"failedToLoadRoutingConfig": "Ƒàĩĺēď ţō ĺōàď ŕōũţĩńĝ ćōńƒĩĝ",
|
||||
"failedToLoadRoutingPreview": "Ƒàĩĺēď ţō ĺōàď ŕōũţĩńĝ ƥŕēvĩēŵ"
|
||||
"failedToLoadRoutingPreview": "Ƒàĩĺēď ţō ĺōàď ŕōũţĩńĝ ƥŕēvĩēŵ",
|
||||
"failedToLoadTaskQuorum": "Ƒàĩĺēď ţō ĺōàď ţàśķ qũōŕũḿ"
|
||||
}
|
||||
|
||||
@@ -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": "Ŕàńďōḿ",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -902,5 +902,6 @@
|
||||
"failedToLoadProviderHealth": "加载服务商健康状态失败",
|
||||
"failedToLoadRouteAttempts": "加载路由尝试记录失败",
|
||||
"failedToLoadRoutingConfig": "加载路由配置失败",
|
||||
"failedToLoadRoutingPreview": "加载路由预览失败"
|
||||
"failedToLoadRoutingPreview": "加载路由预览失败",
|
||||
"failedToLoadTaskQuorum": "加载任务法定人数失败"
|
||||
}
|
||||
|
||||
@@ -997,6 +997,9 @@
|
||||
"questionOfTotalAnswered": "问题 {{index}} / {{total}}(已回答)",
|
||||
"queueMoreInstructions": "排队更多指示…",
|
||||
"queued": "已排队",
|
||||
"quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 项决定",
|
||||
"quorumStuck": "受阻",
|
||||
"quorumStuckPendingReevaluation": "受阻:没有活动会话可重新评估此步骤",
|
||||
"ranCommands_one": "· 运行了 {{count}} 条命令",
|
||||
"ranCommands_other": "· 运行了 {{count}} 条命令",
|
||||
"random": "随机",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -902,5 +902,6 @@
|
||||
"failedToLoadProviderHealth": "載入服務商健康狀態失敗",
|
||||
"failedToLoadRouteAttempts": "載入路由嘗試記錄失敗",
|
||||
"failedToLoadRoutingConfig": "載入路由設定失敗",
|
||||
"failedToLoadRoutingPreview": "載入路由預覽失敗"
|
||||
"failedToLoadRoutingPreview": "載入路由預覽失敗",
|
||||
"failedToLoadTaskQuorum": "載入任務法定人數失敗"
|
||||
}
|
||||
|
||||
@@ -997,6 +997,9 @@
|
||||
"questionOfTotalAnswered": "問題 {{index}} / {{total}}(已回答)",
|
||||
"queueMoreInstructions": "排隊更多指示…",
|
||||
"queued": "已排隊",
|
||||
"quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定",
|
||||
"quorumStuck": "受阻",
|
||||
"quorumStuckPendingReevaluation": "受阻:沒有活動工作階段可重新評估此步驟",
|
||||
"ranCommands_one": "· 執行了 {{count}} 條命令",
|
||||
"ranCommands_other": "· 執行了 {{count}} 條命令",
|
||||
"random": "隨機",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -902,5 +902,6 @@
|
||||
"failedToLoadProviderHealth": "載入服務商健康狀態失敗",
|
||||
"failedToLoadRouteAttempts": "載入路由嘗試記錄失敗",
|
||||
"failedToLoadRoutingConfig": "載入路由設定失敗",
|
||||
"failedToLoadRoutingPreview": "載入路由預覽失敗"
|
||||
"failedToLoadRoutingPreview": "載入路由預覽失敗",
|
||||
"failedToLoadTaskQuorum": "載入任務法定人數失敗"
|
||||
}
|
||||
|
||||
@@ -997,6 +997,9 @@
|
||||
"questionOfTotalAnswered": "問題 {{index}} / {{total}}(已回答)",
|
||||
"queueMoreInstructions": "排隊更多指示…",
|
||||
"queued": "已排隊",
|
||||
"quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定",
|
||||
"quorumStuck": "受阻",
|
||||
"quorumStuckPendingReevaluation": "受阻:沒有活動工作階段可重新評估此步驟",
|
||||
"ranCommands_one": "· 執行了 {{count}} 條命令",
|
||||
"ranCommands_other": "· 執行了 {{count}} 條命令",
|
||||
"random": "隨機",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user