From 8898459c0bfef602df75fc60480998a2f9d16e5f Mon Sep 17 00:00:00 2001 From: nova28 Date: Fri, 21 Aug 2026 23:36:54 +0800 Subject: [PATCH] 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 --- apps/backend/config/prompts/office-context.md | 1 + apps/backend/internal/backendapp/helpers.go | 3 + .../mcp/handlers/agent_decision_handlers.go | 96 ++ .../handlers/agent_decision_handlers_test.go | 157 +++ .../backend/internal/mcp/handlers/handlers.go | 8 + .../mcp/server/agent_decision_tool.go | 53 + .../mcp/server/agent_decision_tool_test.go | 88 ++ apps/backend/internal/mcp/server/server.go | 1 + .../internal/mcp/server/server_test.go | 9 +- .../office/dashboard/agent_decisions.go | 207 +++ .../office/dashboard/agent_decisions_test.go | 233 ++++ .../internal/office/dashboard/decisions.go | 53 +- .../office/dashboard/decisions_test.go | 48 + .../dashboard/engine_test_helpers_test.go | 266 ++++ .../internal/office/dashboard/handler.go | 1 + .../internal/office/dashboard/handler_test.go | 9 +- .../internal/office/dashboard/quorum_dto.go | 50 + .../office/dashboard/quorum_handler.go | 39 + .../dashboard/quorum_reevaluation_test.go | 69 + .../office/dashboard/quorum_service.go | 29 + .../internal/office/dashboard/quorum_test.go | 185 +++ .../office/engine_dispatcher/dispatcher.go | 184 +++ .../dispatcher_decisions_test.go | 304 +++++ .../engine_dispatcher/dispatcher_role_test.go | 61 + .../engine_dispatcher/dispatcher_test.go | 65 + .../orchestrator/event_handlers_test.go | 34 + .../orchestrator/event_handlers_workflow.go | 115 +- apps/backend/internal/orchestrator/service.go | 9 +- .../internal/orchestrator/workflow_store.go | 127 ++ .../orchestrator/workflow_store_test.go | 130 ++ .../internal/task/repository/sqlite/task.go | 153 ++- .../sqlite/task_wip_cas_postgres_test.go | 196 +++ .../repository/sqlite/task_wip_cas_test.go | 276 ++++ .../internal/workflow/adapters/adapters.go | 69 + .../workflow/adapters/adapters_test.go | 113 +- .../internal/workflow/engine/adapters.go | 39 +- .../internal/workflow/engine/engine.go | 51 +- .../internal/workflow/engine/engine_test.go | 12 + .../engine/office_default_smoke_test.go | 24 +- .../workflow/engine/queue_run_test.go | 4 + .../internal/workflow/engine/quorum.go | 873 ++++++++++-- .../engine/quorum_reevaluation_test.go | 460 +++++++ .../workflow/engine/quorum_role_test.go | 135 ++ .../internal/workflow/engine/quorum_test.go | 468 ++++++- .../engine/triggers_event_driven_test.go | 5 + .../workflow/quorummetrics/metrics_vars.go | 21 + .../quorummetrics/metrics_vars_test.go | 46 + .../repository/decisions_postgres_test.go | 157 +++ .../workflow/repository/phase2_sqlite.go | 192 ++- .../workflow/repository/phase2_sqlite_test.go | 184 +++ apps/backend/pkg/websocket/actions.go | 3 + .../components/quorum-status-badge.test.tsx | 180 +++ .../simple/components/quorum-status-badge.tsx | 80 ++ .../task/simple/task-properties.tsx | 2 + .../workflow-quorum-transitions.spec.ts | 372 ++++++ .../domains/office/use-task-quorum.test.ts | 92 ++ .../hooks/domains/office/use-task-quorum.ts | 67 + .../lib/api/domains/office-extended-api.ts | 10 + .../lib/state/slices/office/office-slice.ts | 8 + .../lib/state/slices/office/quorum-types.ts | 22 + apps/web/lib/state/slices/office/types.ts | 3 + apps/web/src/locales/en/office.json | 3 +- apps/web/src/locales/en/task.json | 3 + apps/web/src/locales/pseudo/office.json | 3 +- apps/web/src/locales/pseudo/task.json | 3 + apps/web/src/locales/pt-pt/office.json | 3 +- apps/web/src/locales/pt-pt/task.json | 3 + apps/web/src/locales/zh-cn/office.json | 3 +- apps/web/src/locales/zh-cn/task.json | 3 + apps/web/src/locales/zh-hk/chat.json | 6 +- apps/web/src/locales/zh-hk/office.json | 3 +- apps/web/src/locales/zh-hk/task.json | 3 + apps/web/src/locales/zh-tw/chat.json | 6 +- apps/web/src/locales/zh-tw/office.json | 3 +- apps/web/src/locales/zh-tw/task.json | 3 + docs/specs/INDEX.md | 1 + .../spec.md | 1179 +++++++++++++++++ 77 files changed, 7923 insertions(+), 256 deletions(-) create mode 100644 apps/backend/internal/mcp/handlers/agent_decision_handlers.go create mode 100644 apps/backend/internal/mcp/handlers/agent_decision_handlers_test.go create mode 100644 apps/backend/internal/mcp/server/agent_decision_tool.go create mode 100644 apps/backend/internal/mcp/server/agent_decision_tool_test.go create mode 100644 apps/backend/internal/office/dashboard/agent_decisions.go create mode 100644 apps/backend/internal/office/dashboard/agent_decisions_test.go create mode 100644 apps/backend/internal/office/dashboard/engine_test_helpers_test.go create mode 100644 apps/backend/internal/office/dashboard/quorum_dto.go create mode 100644 apps/backend/internal/office/dashboard/quorum_handler.go create mode 100644 apps/backend/internal/office/dashboard/quorum_reevaluation_test.go create mode 100644 apps/backend/internal/office/dashboard/quorum_service.go create mode 100644 apps/backend/internal/office/dashboard/quorum_test.go create mode 100644 apps/backend/internal/office/engine_dispatcher/dispatcher_decisions_test.go create mode 100644 apps/backend/internal/office/engine_dispatcher/dispatcher_role_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/task_wip_cas_postgres_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/task_wip_cas_test.go create mode 100644 apps/backend/internal/workflow/engine/quorum_reevaluation_test.go create mode 100644 apps/backend/internal/workflow/engine/quorum_role_test.go create mode 100644 apps/backend/internal/workflow/quorummetrics/metrics_vars.go create mode 100644 apps/backend/internal/workflow/quorummetrics/metrics_vars_test.go create mode 100644 apps/backend/internal/workflow/repository/decisions_postgres_test.go create mode 100644 apps/web/components/task/simple/components/quorum-status-badge.test.tsx create mode 100644 apps/web/components/task/simple/components/quorum-status-badge.tsx create mode 100644 apps/web/e2e/tests/office/workflow-quorum-transitions.spec.ts create mode 100644 apps/web/hooks/domains/office/use-task-quorum.test.ts create mode 100644 apps/web/hooks/domains/office/use-task-quorum.ts create mode 100644 apps/web/lib/state/slices/office/quorum-types.ts create mode 100644 docs/specs/workflow-quorum-decision-recording/spec.md diff --git a/apps/backend/config/prompts/office-context.md b/apps/backend/config/prompts/office-context.md index 9b5a4fafde..a95a28a73a 100644 --- a/apps/backend/config/prompts/office-context.md +++ b/apps/backend/config/prompts/office-context.md @@ -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. diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 7c12241bbc..481edd3399 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -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 diff --git a/apps/backend/internal/mcp/handlers/agent_decision_handlers.go b/apps/backend/internal/mcp/handlers/agent_decision_handlers.go new file mode 100644 index 0000000000..d68cc97516 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/agent_decision_handlers.go @@ -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) +} diff --git a/apps/backend/internal/mcp/handlers/agent_decision_handlers_test.go b/apps/backend/internal/mcp/handlers/agent_decision_handlers_test.go new file mode 100644 index 0000000000..ff9cd3d522 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/agent_decision_handlers_test.go @@ -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) +} diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 20bd14626f..5a8fed75f8 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -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) diff --git a/apps/backend/internal/mcp/server/agent_decision_tool.go b/apps/backend/internal/mcp/server/agent_decision_tool.go new file mode 100644 index 0000000000..9b4398b1db --- /dev/null +++ b/apps/backend/internal/mcp/server/agent_decision_tool.go @@ -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 + } +} diff --git a/apps/backend/internal/mcp/server/agent_decision_tool_test.go b/apps/backend/internal/mcp/server/agent_decision_tool_test.go new file mode 100644 index 0000000000..f25920e22a --- /dev/null +++ b/apps/backend/internal/mcp/server/agent_decision_tool_test.go @@ -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") +} diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 476cfac9fb..715c855ada 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -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() diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index 246d95a0fc..b6c806480b 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -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) { diff --git a/apps/backend/internal/office/dashboard/agent_decisions.go b/apps/backend/internal/office/dashboard/agent_decisions.go new file mode 100644 index 0000000000..5b2a00659c --- /dev/null +++ b/apps/backend/internal/office/dashboard/agent_decisions.go @@ -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) +} diff --git a/apps/backend/internal/office/dashboard/agent_decisions_test.go b/apps/backend/internal/office/dashboard/agent_decisions_test.go new file mode 100644 index 0000000000..dcf4acec6d --- /dev/null +++ b/apps/backend/internal/office/dashboard/agent_decisions_test.go @@ -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") + } +} diff --git a/apps/backend/internal/office/dashboard/decisions.go b/apps/backend/internal/office/dashboard/decisions.go index 3f64baba65..0ff5f16245 100644 --- a/apps/backend/internal/office/dashboard/decisions.go +++ b/apps/backend/internal/office/dashboard/decisions.go @@ -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) diff --git a/apps/backend/internal/office/dashboard/decisions_test.go b/apps/backend/internal/office/dashboard/decisions_test.go index b60213834c..2860ce1754 100644 --- a/apps/backend/internal/office/dashboard/decisions_test.go +++ b/apps/backend/internal/office/dashboard/decisions_test.go @@ -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)) + } +} diff --git a/apps/backend/internal/office/dashboard/engine_test_helpers_test.go b/apps/backend/internal/office/dashboard/engine_test_helpers_test.go new file mode 100644 index 0000000000..fc086d5137 --- /dev/null +++ b/apps/backend/internal/office/dashboard/engine_test_helpers_test.go @@ -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) +} diff --git a/apps/backend/internal/office/dashboard/handler.go b/apps/backend/internal/office/dashboard/handler.go index 454a007ba4..e684edb9e4 100644 --- a/apps/backend/internal/office/dashboard/handler.go +++ b/apps/backend/internal/office/dashboard/handler.go @@ -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) diff --git a/apps/backend/internal/office/dashboard/handler_test.go b/apps/backend/internal/office/dashboard/handler_test.go index cb39ac17a4..db33fede26 100644 --- a/apps/backend/internal/office/dashboard/handler_test.go +++ b/apps/backend/internal/office/dashboard/handler_test.go @@ -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 diff --git a/apps/backend/internal/office/dashboard/quorum_dto.go b/apps/backend/internal/office/dashboard/quorum_dto.go new file mode 100644 index 0000000000..7f8b72d62d --- /dev/null +++ b/apps/backend/internal/office/dashboard/quorum_dto.go @@ -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 +} diff --git a/apps/backend/internal/office/dashboard/quorum_handler.go b/apps/backend/internal/office/dashboard/quorum_handler.go new file mode 100644 index 0000000000..25c4fca02a --- /dev/null +++ b/apps/backend/internal/office/dashboard/quorum_handler.go @@ -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) +} diff --git a/apps/backend/internal/office/dashboard/quorum_reevaluation_test.go b/apps/backend/internal/office/dashboard/quorum_reevaluation_test.go new file mode 100644 index 0000000000..24c46b5875 --- /dev/null +++ b/apps/backend/internal/office/dashboard/quorum_reevaluation_test.go @@ -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) + } +} diff --git a/apps/backend/internal/office/dashboard/quorum_service.go b/apps/backend/internal/office/dashboard/quorum_service.go new file mode 100644 index 0000000000..365aa440a9 --- /dev/null +++ b/apps/backend/internal/office/dashboard/quorum_service.go @@ -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 +} diff --git a/apps/backend/internal/office/dashboard/quorum_test.go b/apps/backend/internal/office/dashboard/quorum_test.go new file mode 100644 index 0000000000..3276623b17 --- /dev/null +++ b/apps/backend/internal/office/dashboard/quorum_test.go @@ -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) + } +} diff --git a/apps/backend/internal/office/engine_dispatcher/dispatcher.go b/apps/backend/internal/office/engine_dispatcher/dispatcher.go index 6962356366..e61f023684 100644 --- a/apps/backend/internal/office/engine_dispatcher/dispatcher.go +++ b/apps/backend/internal/office/engine_dispatcher/dispatcher.go @@ -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 +} diff --git a/apps/backend/internal/office/engine_dispatcher/dispatcher_decisions_test.go b/apps/backend/internal/office/engine_dispatcher/dispatcher_decisions_test.go new file mode 100644 index 0000000000..a4ae419795 --- /dev/null +++ b/apps/backend/internal/office/engine_dispatcher/dispatcher_decisions_test.go @@ -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) + } +} diff --git a/apps/backend/internal/office/engine_dispatcher/dispatcher_role_test.go b/apps/backend/internal/office/engine_dispatcher/dispatcher_role_test.go new file mode 100644 index 0000000000..e71b7af2cf --- /dev/null +++ b/apps/backend/internal/office/engine_dispatcher/dispatcher_role_test.go @@ -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) + } +} diff --git a/apps/backend/internal/office/engine_dispatcher/dispatcher_test.go b/apps/backend/internal/office/engine_dispatcher/dispatcher_test.go index c0dc47bfcf..a9a557ab77 100644 --- a/apps/backend/internal/office/engine_dispatcher/dispatcher_test.go +++ b/apps/backend/internal/office/engine_dispatcher/dispatcher_test.go @@ -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 } diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index e3dfb14322..2b15e89084 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -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 diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 31822dfee2..d08f245d16 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -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 diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 564b25084c..566e4c3b47 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -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 diff --git a/apps/backend/internal/orchestrator/workflow_store.go b/apps/backend/internal/orchestrator/workflow_store.go index a9e1779009..66fa645f78 100644 --- a/apps/backend/internal/orchestrator/workflow_store.go +++ b/apps/backend/internal/orchestrator/workflow_store.go @@ -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 diff --git a/apps/backend/internal/orchestrator/workflow_store_test.go b/apps/backend/internal/orchestrator/workflow_store_test.go index ca7a1d0326..606faaec04 100644 --- a/apps/backend/internal/orchestrator/workflow_store_test.go +++ b/apps/backend/internal/orchestrator/workflow_store_test.go @@ -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) diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 2bc42c2f70..538c84694d 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -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 diff --git a/apps/backend/internal/task/repository/sqlite/task_wip_cas_postgres_test.go b/apps/backend/internal/task/repository/sqlite/task_wip_cas_postgres_test.go new file mode 100644 index 0000000000..d07ac42b57 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/task_wip_cas_postgres_test.go @@ -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) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/task_wip_cas_test.go b/apps/backend/internal/task/repository/sqlite/task_wip_cas_test.go new file mode 100644 index 0000000000..983c731119 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/task_wip_cas_test.go @@ -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) + } +} diff --git a/apps/backend/internal/workflow/adapters/adapters.go b/apps/backend/internal/workflow/adapters/adapters.go index 1cbd942391..76f25deaeb 100644 --- a/apps/backend/internal/workflow/adapters/adapters.go +++ b/apps/backend/internal/workflow/adapters/adapters.go @@ -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) } diff --git a/apps/backend/internal/workflow/adapters/adapters_test.go b/apps/backend/internal/workflow/adapters/adapters_test.go index 2ae126ca47..924f0fdceb 100644 --- a/apps/backend/internal/workflow/adapters/adapters_test.go +++ b/apps/backend/internal/workflow/adapters/adapters_test.go @@ -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) diff --git a/apps/backend/internal/workflow/engine/adapters.go b/apps/backend/internal/workflow/engine/adapters.go index e9316adae3..c99c7d153b 100644 --- a/apps/backend/internal/workflow/engine/adapters.go +++ b/apps/backend/internal/workflow/engine/adapters.go @@ -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 diff --git a/apps/backend/internal/workflow/engine/engine.go b/apps/backend/internal/workflow/engine/engine.go index f24492b7ae..c5aee9d99f 100644 --- a/apps/backend/internal/workflow/engine/engine.go +++ b/apps/backend/internal/workflow/engine/engine.go @@ -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) diff --git a/apps/backend/internal/workflow/engine/engine_test.go b/apps/backend/internal/workflow/engine/engine_test.go index 11e9ea0501..8d5ade8eea 100644 --- a/apps/backend/internal/workflow/engine/engine_test.go +++ b/apps/backend/internal/workflow/engine/engine_test.go @@ -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 diff --git a/apps/backend/internal/workflow/engine/office_default_smoke_test.go b/apps/backend/internal/workflow/engine/office_default_smoke_test.go index ad2844949b..8bd87aa7f6 100644 --- a/apps/backend/internal/workflow/engine/office_default_smoke_test.go +++ b/apps/backend/internal/workflow/engine/office_default_smoke_test.go @@ -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) { diff --git a/apps/backend/internal/workflow/engine/queue_run_test.go b/apps/backend/internal/workflow/engine/queue_run_test.go index dedd0d0c5e..25733694e3 100644 --- a/apps/backend/internal/workflow/engine/queue_run_test.go +++ b/apps/backend/internal/workflow/engine/queue_run_test.go @@ -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 diff --git a/apps/backend/internal/workflow/engine/quorum.go b/apps/backend/internal/workflow/engine/quorum.go index cbe45c69ff..dac07ee37c 100644 --- a/apps/backend/internal/workflow/engine/quorum.go +++ b/apps/backend/internal/workflow/engine/quorum.go @@ -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:), 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 } diff --git a/apps/backend/internal/workflow/engine/quorum_reevaluation_test.go b/apps/backend/internal/workflow/engine/quorum_reevaluation_test.go new file mode 100644 index 0000000000..df0c432c31 --- /dev/null +++ b/apps/backend/internal/workflow/engine/quorum_reevaluation_test.go @@ -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) + } +} diff --git a/apps/backend/internal/workflow/engine/quorum_role_test.go b/apps/backend/internal/workflow/engine/quorum_role_test.go new file mode 100644 index 0000000000..0d55da0f40 --- /dev/null +++ b/apps/backend/internal/workflow/engine/quorum_role_test.go @@ -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) + } +} diff --git a/apps/backend/internal/workflow/engine/quorum_test.go b/apps/backend/internal/workflow/engine/quorum_test.go index c0f248022b..2e0cf69182 100644 --- a/apps/backend/internal/workflow/engine/quorum_test.go +++ b/apps/backend/internal/workflow/engine/quorum_test.go @@ -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) + } }) } } diff --git a/apps/backend/internal/workflow/engine/triggers_event_driven_test.go b/apps/backend/internal/workflow/engine/triggers_event_driven_test.go index 1377669a0a..78e9fa3caa 100644 --- a/apps/backend/internal/workflow/engine/triggers_event_driven_test.go +++ b/apps/backend/internal/workflow/engine/triggers_event_driven_test.go @@ -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 diff --git a/apps/backend/internal/workflow/quorummetrics/metrics_vars.go b/apps/backend/internal/workflow/quorummetrics/metrics_vars.go new file mode 100644 index 0000000000..c0be13930f --- /dev/null +++ b/apps/backend/internal/workflow/quorummetrics/metrics_vars.go @@ -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) +} diff --git a/apps/backend/internal/workflow/quorummetrics/metrics_vars_test.go b/apps/backend/internal/workflow/quorummetrics/metrics_vars_test.go new file mode 100644 index 0000000000..059f153b41 --- /dev/null +++ b/apps/backend/internal/workflow/quorummetrics/metrics_vars_test.go @@ -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") + } +} diff --git a/apps/backend/internal/workflow/repository/decisions_postgres_test.go b/apps/backend/internal/workflow/repository/decisions_postgres_test.go new file mode 100644 index 0000000000..44fda6ada9 --- /dev/null +++ b/apps/backend/internal/workflow/repository/decisions_postgres_test.go @@ -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) + } +} diff --git a/apps/backend/internal/workflow/repository/phase2_sqlite.go b/apps/backend/internal/workflow/repository/phase2_sqlite.go index cb7271fb82..e4196e2825 100644 --- a/apps/backend/internal/workflow/repository/phase2_sqlite.go +++ b/apps/backend/internal/workflow/repository/phase2_sqlite.go @@ -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 { diff --git a/apps/backend/internal/workflow/repository/phase2_sqlite_test.go b/apps/backend/internal/workflow/repository/phase2_sqlite_test.go index f510ed67f8..a3d25b3a16 100644 --- a/apps/backend/internal/workflow/repository/phase2_sqlite_test.go +++ b/apps/backend/internal/workflow/repository/phase2_sqlite_test.go @@ -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) { diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 7d7afbd044..ca7dacb49b 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -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" diff --git a/apps/web/components/task/simple/components/quorum-status-badge.test.tsx b/apps/web/components/task/simple/components/quorum-status-badge.test.tsx new file mode 100644 index 0000000000..9f7f9e82f2 --- /dev/null +++ b/apps/web/components/task/simple/components/quorum-status-badge.test.tsx @@ -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( + "@/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 ( + + {children} + + ); +} + +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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + const badge = await screen.findByTestId(BADGE_TEST_ID); + expect(badge.getAttribute("data-variant")).toBe("destructive"); + }); +}); diff --git a/apps/web/components/task/simple/components/quorum-status-badge.tsx b/apps/web/components/task/simple/components/quorum-status-badge.tsx new file mode 100644 index 0000000000..fb35ba0ee8 --- /dev/null +++ b/apps/web/components/task/simple/components/quorum-status-badge.tsx @@ -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 = { + 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 ( + + {t("task:quorumStuck")} + {reasonLabel} + + ); + } + + const { entry } = aggregate; + const roleLabel = t(ROLE_LABEL_KEYS[entry.role] ?? entry.role, { defaultValue: entry.role }); + + return ( + + {t("task:quorumAwaiting", { + received: entry.received_count, + required: entry.required_count, + role: roleLabel, + })} + + ); +} diff --git a/apps/web/components/task/simple/task-properties.tsx b/apps/web/components/task/simple/task-properties.tsx index 222953ddd7..de51c9612d 100644 --- a/apps/web/components/task/simple/task-properties.tsx +++ b/apps/web/components/task/simple/task-properties.tsx @@ -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 }) { <> + diff --git a/apps/web/e2e/tests/office/workflow-quorum-transitions.spec.ts b/apps/web/e2e/tests/office/workflow-quorum-transitions.spec.ts new file mode 100644 index 0000000000..c0adf4c9f4 --- /dev/null +++ b/apps/web/e2e/tests/office/workflow-quorum-transitions.spec.ts @@ -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 { + 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 { + 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 }, + workspaceId: string, + taskId: string, +): Promise> { + 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; + const reviewerId = reviewer.id as string; + const approver = (await officeApi.createAgent(officeSeed.workspaceId, { + name: "Quorum Approver", + role: "worker", + })) as Record; + 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; + 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"); + }); +}); diff --git a/apps/web/hooks/domains/office/use-task-quorum.test.ts b/apps/web/hooks/domains/office/use-task-quorum.test.ts new file mode 100644 index 0000000000..fe2995f26a --- /dev/null +++ b/apps/web/hooks/domains/office/use-task-quorum.test.ts @@ -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 = {}; +let refetchTriggers: Record = {}; +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); + }); +}); diff --git a/apps/web/hooks/domains/office/use-task-quorum.ts b/apps/web/hooks/domains/office/use-task-quorum.ts new file mode 100644 index 0000000000..62ace46e1b --- /dev/null +++ b/apps/web/hooks/domains/office/use-task-quorum.ts @@ -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; +}; + +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(null); + const [fetchedScope, setFetchedScope] = useState(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 }; +} diff --git a/apps/web/lib/api/domains/office-extended-api.ts b/apps/web/lib/api/domains/office-extended-api.ts index dfd2880b36..0aefba90fd 100644 --- a/apps/web/lib/api/domains/office-extended-api.ts +++ b/apps/web/lib/api/domains/office-extended-api.ts @@ -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( + `${BASE}/workspaces/${workspaceId}/tasks/${taskId}/quorum`, + options, + ); +} + // --- Comments --- export type TaskCommentResponse = { diff --git a/apps/web/lib/state/slices/office/office-slice.ts b/apps/web/lib/state/slices/office/office-slice.ts index f8a32d1880..9438a4489e 100644 --- a/apps/web/lib/state/slices/office/office-slice.ts +++ b/apps/web/lib/state/slices/office/office-slice.ts @@ -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; + }), }; } diff --git a/apps/web/lib/state/slices/office/quorum-types.ts b/apps/web/lib/state/slices/office/quorum-types.ts new file mode 100644 index 0000000000..be8a271ae4 --- /dev/null +++ b/apps/web/lib/state/slices/office/quorum-types.ts @@ -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; +}; diff --git a/apps/web/lib/state/slices/office/types.ts b/apps/web/lib/state/slices/office/types.ts index 9e41206aab..b8e2d1cd1c 100644 --- a/apps/web/lib/state/slices/office/types.ts +++ b/apps/web/lib/state/slices/office/types.ts @@ -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; diff --git a/apps/web/src/locales/en/office.json b/apps/web/src/locales/en/office.json index 0480cc5873..b183578fba 100644 --- a/apps/web/src/locales/en/office.json +++ b/apps/web/src/locales/en/office.json @@ -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" } diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index cb73c9b0be..619a8f0a35 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -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", diff --git a/apps/web/src/locales/pseudo/office.json b/apps/web/src/locales/pseudo/office.json index 33b273c15a..5bea849e57 100644 --- a/apps/web/src/locales/pseudo/office.json +++ b/apps/web/src/locales/pseudo/office.json @@ -902,5 +902,6 @@ "failedToLoadProviderHealth": "Ƒàĩĺēď ţō ĺōàď ƥŕōvĩďēŕ ĥēàĺţĥ", "failedToLoadRouteAttempts": "Ƒàĩĺēď ţō ĺōàď ŕōũţē àţţēḿƥţś", "failedToLoadRoutingConfig": "Ƒàĩĺēď ţō ĺōàď ŕōũţĩńĝ ćōńƒĩĝ", - "failedToLoadRoutingPreview": "Ƒàĩĺēď ţō ĺōàď ŕōũţĩńĝ ƥŕēvĩēŵ" + "failedToLoadRoutingPreview": "Ƒàĩĺēď ţō ĺōàď ŕōũţĩńĝ ƥŕēvĩēŵ", + "failedToLoadTaskQuorum": "Ƒàĩĺēď ţō ĺōàď ţàśķ qũōŕũḿ" } diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 521bcc67e6..bc3a882341 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -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": "Ŕàńďōḿ", diff --git a/apps/web/src/locales/pt-pt/office.json b/apps/web/src/locales/pt-pt/office.json index 281f1a3515..a8077662e3 100644 --- a/apps/web/src/locales/pt-pt/office.json +++ b/apps/web/src/locales/pt-pt/office.json @@ -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" } diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index 332e32195e..e59b79f240 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -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", diff --git a/apps/web/src/locales/zh-cn/office.json b/apps/web/src/locales/zh-cn/office.json index f207e02d1e..f596602f62 100644 --- a/apps/web/src/locales/zh-cn/office.json +++ b/apps/web/src/locales/zh-cn/office.json @@ -902,5 +902,6 @@ "failedToLoadProviderHealth": "加载服务商健康状态失败", "failedToLoadRouteAttempts": "加载路由尝试记录失败", "failedToLoadRoutingConfig": "加载路由配置失败", - "failedToLoadRoutingPreview": "加载路由预览失败" + "failedToLoadRoutingPreview": "加载路由预览失败", + "failedToLoadTaskQuorum": "加载任务法定人数失败" } diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index 4337d33368..4f0efb5913 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -997,6 +997,9 @@ "questionOfTotalAnswered": "问题 {{index}} / {{total}}(已回答)", "queueMoreInstructions": "排队更多指示…", "queued": "已排队", + "quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 项决定", + "quorumStuck": "受阻", + "quorumStuckPendingReevaluation": "受阻:没有活动会话可重新评估此步骤", "ranCommands_one": "· 运行了 {{count}} 条命令", "ranCommands_other": "· 运行了 {{count}} 条命令", "random": "随机", diff --git a/apps/web/src/locales/zh-hk/chat.json b/apps/web/src/locales/zh-hk/chat.json index b1a1f3c993..77e9367ac7 100644 --- a/apps/web/src/locales/zh-hk/chat.json +++ b/apps/web/src/locales/zh-hk/chat.json @@ -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}}", diff --git a/apps/web/src/locales/zh-hk/office.json b/apps/web/src/locales/zh-hk/office.json index 92398b1396..92332ca1b7 100644 --- a/apps/web/src/locales/zh-hk/office.json +++ b/apps/web/src/locales/zh-hk/office.json @@ -902,5 +902,6 @@ "failedToLoadProviderHealth": "載入服務商健康狀態失敗", "failedToLoadRouteAttempts": "載入路由嘗試記錄失敗", "failedToLoadRoutingConfig": "載入路由設定失敗", - "failedToLoadRoutingPreview": "載入路由預覽失敗" + "failedToLoadRoutingPreview": "載入路由預覽失敗", + "failedToLoadTaskQuorum": "載入任務法定人數失敗" } diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 9e3c02bddc..4d277ea478 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -997,6 +997,9 @@ "questionOfTotalAnswered": "問題 {{index}} / {{total}}(已回答)", "queueMoreInstructions": "排隊更多指示…", "queued": "已排隊", + "quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定", + "quorumStuck": "受阻", + "quorumStuckPendingReevaluation": "受阻:沒有活動工作階段可重新評估此步驟", "ranCommands_one": "· 執行了 {{count}} 條命令", "ranCommands_other": "· 執行了 {{count}} 條命令", "random": "隨機", diff --git a/apps/web/src/locales/zh-tw/chat.json b/apps/web/src/locales/zh-tw/chat.json index aa7cc7b654..46f138cbfe 100644 --- a/apps/web/src/locales/zh-tw/chat.json +++ b/apps/web/src/locales/zh-tw/chat.json @@ -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}}", diff --git a/apps/web/src/locales/zh-tw/office.json b/apps/web/src/locales/zh-tw/office.json index 11083c293e..2c64dc37ef 100644 --- a/apps/web/src/locales/zh-tw/office.json +++ b/apps/web/src/locales/zh-tw/office.json @@ -902,5 +902,6 @@ "failedToLoadProviderHealth": "載入服務商健康狀態失敗", "failedToLoadRouteAttempts": "載入路由嘗試記錄失敗", "failedToLoadRoutingConfig": "載入路由設定失敗", - "failedToLoadRoutingPreview": "載入路由預覽失敗" + "failedToLoadRoutingPreview": "載入路由預覽失敗", + "failedToLoadTaskQuorum": "載入任務法定人數失敗" } diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 47597575f1..3830358b14 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -997,6 +997,9 @@ "questionOfTotalAnswered": "問題 {{index}} / {{total}}(已回答)", "queueMoreInstructions": "排隊更多指示…", "queued": "已排隊", + "quorumAwaiting": "正在等待 {{role}} 的 {{received}}/{{required}} 項決定", + "quorumStuck": "受阻", + "quorumStuckPendingReevaluation": "受阻:沒有活動工作階段可重新評估此步驟", "ranCommands_one": "· 執行了 {{count}} 條命令", "ranCommands_other": "· 執行了 {{count}} 條命令", "random": "隨機", diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 4ea71c3c9a..8e8ec8f9db 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -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 | diff --git a/docs/specs/workflow-quorum-decision-recording/spec.md b/docs/specs/workflow-quorum-decision-recording/spec.md new file mode 100644 index 0000000000..e2afdb946c --- /dev/null +++ b/docs/specs/workflow-quorum-decision-recording/spec.md @@ -0,0 +1,1179 @@ +--- +status: draft +created: 2026-08-19 +owner: Kandev +--- + +# Workflow Quorum Decision Recording + +## Why + +A `move_to_step` guarded by `wait_for_quorum` never fires. An Office task +driven through Work → Review with a reviewer attached runs the reviewer, gets a +substantive review back, and then sits at Review permanently. It is +indistinguishable, from every surface a human can see, from a hung card. + +Verified 2026-08-19 against task `7611f8da-4fe3-4bb2-a5a2-ebe6f90130f2` +(workspace `95542bf3`) in `~/.kandev/data/kandev.db`: + +``` +sqlite> SELECT COUNT(*) FROM workflow_step_decisions; +0 +``` + +Zero rows in the entire store, ever. + +## Verified current state + +The originating report named two causes. Only the first survives inspection. +Investigation surfaced four defects, three of which the report did not name. + +### The evaluation path is intact (originally reported as broken) + +`wait_for_quorum` **is** evaluated on the path that runs these workflows. The +claim that the orchestrator ignores the guard is false; the orchestrator +delegates to the engine, which owns the semantics. + +| Link | Evidence | +|---|---| +| Guard evaluated on the transition loop | `apps/backend/internal/workflow/engine/engine.go:275` | +| All five thresholds implemented | `apps/backend/internal/workflow/engine/quorum.go:88-119` | +| Office dispatches into that engine | `apps/backend/internal/office/engine_dispatcher/dispatcher.go:108` | +| Office shares the orchestrator's engine instance | `apps/backend/internal/backendapp/main.go:1520,1527` | +| Participant + decision stores wired in production | `apps/backend/internal/backendapp/main.go:1514-1515` | +| `clear_decisions` / `queue_run_for_each_participant` callbacks wired | `apps/backend/internal/orchestrator/workflow_callbacks.go:49,56` | + +The persistence layer is likewise complete: `RecordStepDecision` supersedes +prior verdicts inside a transaction (`phase2_sqlite.go:522-578`), +`ListStepDecisions` returns oldest-first (`:583-600`), and `AddTaskParticipant` +writes `decision_required = 1` for every reviewer and approver +(`office/repository/sqlite/participants.go:84-87`). + +The engine's evaluation is therefore correct **given its inputs**. With zero +decision rows, `all_approve` and `any_reject` both correctly evaluate false and +the card correctly stays put. The defects are all on the input side. + +### Defect 1 — an agent has no tool to record a decision + +The MCP tool registry (`apps/backend/internal/mcp/server/server.go:892-923`) +enables exactly four groups for `SurfaceOfficeTask`: `plan` (4 tools), +`related-tasks` (1), `office-documents` (3), and the capability-gated +`user-question` (1). Nine tools, none of which records a decision. + +`POST /api/v1/office/tasks/:id/approve` and `.../request-changes` exist +(`office/dashboard/handler.go:91-92`) and are reachable from the human UI +(`apps/web/components/task/simple/components/approval-action-bar.tsx:122-123`), +but no agent can reach either. A reviewer agent's only means of expressing a +verdict is a free-text comment, which nothing parses. + +### Defect 2 — the human path writes a verdict the evaluator cannot read + +Office records `changes_requested` (`office/models/models.go:653`), surfaced in +the frontend contract as `"approved" | "changes_requested"` +(`apps/web/lib/api/domains/office-extended-api.ts:297`). + +The evaluator counts only `approved` and `rejected` +(`workflow/engine/quorum.go:25-26,100-103`). + +A human clicking **Request changes** therefore writes a row that satisfies +neither `all_approve` (it is not an approval) nor `any_reject` (it is not +literally `rejected`). The task stays at Review with a rejection on file. This +is live today on the human path and is independent of Defect 1. + +The vocabulary is only half of it. `resolveParticipantID` +(`office/dashboard/decisions.go`) stamps every human decision with the constant +`userParticipantSentinel = "user"`, because the singleton user has no +`agent_profile_id` and therefore no `workflow_step_participants` row. +`latestDecisionsPerParticipant` (`workflow/engine/quorum.go`) discards any +decision whose `participant_id` is absent from the required slate, and that +slate is built from participant-row UUIDs. **A human decision is therefore +discarded before its verdict string is ever inspected.** Correcting the +vocabulary alone changes nothing on the human path; AC-43 is what makes AC-10 +reachable. + +### Defect 3 — recording a decision does not re-evaluate the transition + +`recordTaskDecision` (`office/dashboard/decisions.go:225-265`) persists the row, +publishes `OfficeTaskDecisionRecorded`, logs activity, and queues follow-up +agent runs. It never re-evaluates the guarded transition. + +`Engine.RecordParticipantDecision` (`workflow/engine/quorum.go:152-200`) does +exactly that re-evaluation and has **zero production callers** — only tests. + +So even once a verdict is recorded with a vocabulary the evaluator understands, +the transition fires only if some unrelated turn later completes on that task. +When the reviewer has already finished, nothing re-triggers. + +### Defect 4 — participant rows are pinned to the step they were attached at + +`AddTaskParticipant` (`office/repository/sqlite/participants.go:59-88`) writes +`step_id` = the task's step *at attach time*. No code ever updates a +participant's `step_id`; the only `UPDATE workflow_step_participants` statements +in the tree touch `agent_profile_id`. + +`requiredParticipants` filters on `state.CurrentStepID` +(`workflow/engine/quorum.go:70-83`). A participant attached at one step is +invisible at every other step, and an empty slate fails closed +(`quorum.go:57-60`). + +Live evidence from the verified task — the approver exists only at Work: + +| participant | step | role | decision_required | +|---|---|---|---| +| `afbd72cd` | Review | reviewer | 1 | +| `8eefba93` | **Work** | approver | 1 | +| `3cb7cd33` | Work | reviewer | 1 | + +Review's reviewer slate is non-empty, so Defects 1–3 are what block this task +today. But Approval's approver slate is **empty**, so fixing Review alone moves +the card one step and strands it again. + +Two further facts make this wider than the slate. First, `AddTaskParticipant`'s +idempotency probe is itself scoped to `step_id`, so re-attaching the same agent +after a step change **creates a second row** for the same +`(task_id, role, agent_profile_id)` — the two-row shape is how the live data +above came to exist, not a hypothetical. Second, `ListAllTaskParticipants` +(`office/repository/sqlite/participants.go`) is *also* step-scoped, so +`resolveDeciderRole` — the role-resolution precedent AC-4 cites — is blind in +exactly the same way: an approver whose row sits at Work cannot be resolved as +an approver while the task is at Approval. Slate membership, role resolution, +and the `participant_id` written on a decision must therefore all use one +canonical-row rule, which AC-44 defines. + +### Defect 5 — every failure is silent and identical + +The engine holds no logger (`workflow/engine/engine.go:131-144`) and `quorum.go` +emits nothing. `evaluateTransitionGuard` returns `(false, nil)` for all of: +store not wired, empty required slate, threshold genuinely unmet, and unknown +guard variant. From outside, a card legitimately awaiting two more approvals is +byte-for-byte identical to a card whose guard can never be satisfied. This is +the diagnostic gap that let the bug survive. + +### Live workflow configuration + +`Office Default` (`7561d51f`), steps Backlog(0) Work(1) Review(2) Approval(3) +Done(4). Both guarded steps carry the guard nested inside the action's `config`, +which `ConfigTransitionGuard` accepts (`workflow/engine/types.go:533`): + +```json +"on_turn_complete": [ + {"type": "move_to_step", "config": {"step_id": "", + "wait_for_quorum": {"role": "reviewer", "threshold": "all_approve"}}}, + {"type": "move_to_step", "config": {"step_id": "", + "wait_for_quorum": {"role": "reviewer", "threshold": "any_reject"}}} +] +``` + +Approval is the same shape with `role: approver`. + +## What + +Five behaviors, matching the five defects. + +1. An agent holding `reviewer` or `approver` on the current step can record an + approve or reject verdict with a reason, via an MCP tool, and it persists to + `workflow_step_decisions`. +2. A rejection recorded through any surface — agent tool or human UI — is + counted by `any_reject`. This needs both a vocabulary fix and a seat fix: the + human decider has no participant row at all, so a rejection counts as a veto + without requiring a quorum seat, while approvals still require one. +3. Recording a verdict through any surface immediately re-evaluates the guarded + transitions for that task's current step. +4. A participant with `decision_required` is counted at the step where the + decision is being taken, not only at the step where they were attached. +5. A guarded transition that does not fire says why, distinguishably, on a + surface a human reads. + +Ownership follows the engine. The engine already owns guard semantics, +threshold arithmetic, and the required-participant slate; this feature adds no +second implementation in the orchestrator. The MCP tool and the HTTP handler are +transports that call into the engine's decision API. AC-57 names that API and +the ports that make it reachable, because "call into the engine" is not today a +thing `office/dashboard` can do. There are two such ports and they are named +separately: AC-57a for the write side (record a decision, re-evaluate) and +AC-57d for the read side (evaluate every guard read-only, for the AC-24b +diagnostic surface). Behavior 5 is unbuildable without the second one. + +## Tool contract + +Pinned here because Build would otherwise invent it and Review would relitigate it. + +**Name.** `record_step_decision_kandev`, registered under a new +`office-decisions` group in the profile registry +(`apps/backend/internal/mcp/server/server.go:892-923`), enabled for +`SurfaceOfficeTask` only. + +**Arguments.** + +| Argument | Required | Shape | +|---|---|---| +| `decision` | yes | `"approved"` or `"rejected"` | +| `reason` | yes | non-empty string | + +No idempotency argument. A retry is a new verdict that supersedes the previous +one under AC-27; the tool is deliberately NOT idempotent across calls, and +AC-45 states this as contract so a builder does not invent a client-supplied +key. Idempotency in this feature is confined to transition application +(AC-30, AC-46). + +**Scoping.** The tool takes no `task_id`. It resolves the task and step from the +calling session, matching every other Office tool. An agent cannot record a +decision on a task other than the one its session is bound to. + +**Return.** On success the tool returns exactly these seven fields, observed by +AC-64: + +| Field | Meaning | +|---|---| +| `decision` | the recorded verdict | +| `role` | the caller's resolved role, per AC-4 | +| `step_id` | the validated step the row landed on, per AC-37 | +| `decision_id` | the decision row id, per AC-66 | +| `decided_at` | the row's `decided_at` value as persisted | +| `transition_applied` | `true` meaning the task left the step, per AC-48 — NOT that it was admitted at the target | +| `guards` | the AC-57d per-guard entries for the validated step, identical in shape to AC-24b's | + +The agent needs `transition_applied` to know whether its work at this step is +finished, and `step_id` to detect the AC-37 race in which its decision landed on +a step the task has since left. `decision_id` and `decided_at` are returned +because AC-57b requires `publishDecisionRecorded` to keep publishing +`decision_id` and `created_at` off the `DecisionRecord`, and once the persist +moves behind the AC-57a entry point those two values are no longer office's to +generate. + +There is deliberately NO scalar `required_count` / `received_count` pair. A step +may configure several guards, and AC-4's approver-wins precedence can resolve the +caller to `approver` while every guard at that step names `reviewer` — which is +`Office Default`'s Review exactly, per `## Live workflow configuration`. In that +case AC-42 means the decision is never counted by that guard at all, so a single +pair of counts is either ambiguous or actively misleading. `guards` gives the +agent the count against each guard that exists, which is the question it was +actually asking. + +**Reason column.** The reason is written to `workflow_step_decisions.comment`, +the column every Office reader already projects (`office/dashboard/decisions.go:141,428`). +It is NOT written to `note`, which `Engine.RecordParticipantDecision` currently +uses (`workflow/engine/quorum.go:180`) and which no Office surface reads. Any +engine-side decision API used by this feature writes `comment` for the +human-facing reason. A reason written only to `note` would be invisible in the +task timeline — this is the concrete bug this paragraph exists to prevent. + +## Acceptance criteria + +### Recording a decision (agent surface) + +- **AC-1** WHEN an Office agent session is attached to the task's current step + with role `reviewer` or `approver` and `decision_required = 1`, THE SYSTEM + SHALL expose an MCP tool that records a decision, accepting a verdict of + `approved` or `rejected` and a required non-empty reason. +- **AC-2** WHEN that tool is called with a valid verdict, THE SYSTEM SHALL write + one row to `workflow_step_decisions` with `task_id` = the calling session's + task, `step_id` = that task's current `workflow_step_id`, `role` = the + caller's resolved role, `decider_type` = `agent`, `decider_id` = the caller's + agent profile id, and `participant_id` = the id of the **seat** the participant occupies, as + defined by AC-50 — that is, AC-44 canonicalization followed by AC-20 collapse, + not AC-44 alone. The written `participant_id` SHALL be the same id + the quorum evaluator uses for that participant in the required slate; a + decision whose `participant_id` is not the canonical id is a defect, because + `latestDecisionsPerParticipant` silently discards it. +- **AC-3** WHEN the caller is not a participant on the task with + `decision_required = 1` for `reviewer` or `approver`, THE SYSTEM SHALL reject + the call with a permission error and write no row. +- **AC-4** WHEN the caller holds both `reviewer` and `approver`, THE SYSTEM + SHALL record the decision under `approver`, matching the existing + approver-wins precedence in `resolveDeciderRole` + (`office/dashboard/decisions.go`). +- **AC-4a** THE SYSTEM SHALL resolve the caller's role over the same + participant population that AC-50 builds, not over rows scoped to the + task's current step alone. `ListAllTaskParticipants` is step-scoped today, so + an approver attached at an earlier step is currently unresolvable at the step + where the approval is due; role resolution and slate membership SHALL NOT + disagree about who participates. +- **AC-5** WHEN the verdict is any value other than `approved` or `rejected`, + THE SYSTEM SHALL reject the call with a validation error and write no row. +- **AC-6** WHEN the reason is empty or whitespace-only, THE SYSTEM SHALL reject + the call with a validation error and write no row. +- **AC-7** WHEN the task has no `workflow_step_id` bound, THE SYSTEM SHALL + reject the call with an error naming the unbound step and write no row. +- **AC-8** THE SYSTEM SHALL NOT expose the decision tool to sessions whose + profile does not enable `SurfaceOfficeTask`. + +- **AC-55** THE SYSTEM SHALL validate the decision tool's preconditions in this + order: (1) the task has a bound `workflow_step_id` (AC-7), (2) the caller + resolves to a participant with `decision_required = 1` for `reviewer` or + `approver` (AC-3), (3) the verdict is `approved` or `rejected` (AC-5), (4) the + reason is non-empty (AC-6). The step check precedes the permission check + because `AddTaskParticipant` silently no-ops when a task has no bound step + (`office/repository/sqlite/participants.go`), so an unbound-step task has zero + participant rows **by construction**; a permission-first implementation would + report AC-3's opaque permission error on the most likely real trigger for AC-7, + a task created before a workflow step was assigned. The order is stated because + both preconditions are false at once in that case and the two errors differ in + diagnostic value. + +- **AC-64** WHEN the decision tool returns successfully, THE SYSTEM SHALL return + the seven fields enumerated in `## Tool contract` — `decision`, `role`, + `step_id`, `decision_id`, `decided_at`, `transition_applied` and `guards` — + and SHALL NOT return a scalar `required_count` or `received_count`. + `guards` SHALL equal the AC-57d snapshot's `Guards` for the validated step, + in the same order, so the tool and the AC-24b endpoint report the same + arithmetic. `guards` SHALL be an empty list, not an error and not omitted, + when the validated step configures no guarded transition. + WHEN the post-write AC-57d snapshot cannot be computed — the AC-15 case, where + the write succeeded and the re-evaluation errored — THE SYSTEM SHALL return + `guards` as an empty list and `transition_applied` as `false`, and SHALL still + report the call as successful per AC-15, the failure being surfaced through + AC-24 rather than to the agent. A diagnostic read that failed SHALL NOT put the + recorded verdict at risk. + This AC exists because without it only two of the returned fields — `step_id` + and `transition_applied`, via AC-37 and AC-46 — were observable by any + acceptance criterion, so a wrong `role` or a wrong count could ship untested. + +### Verdict vocabulary + +- **AC-9** THE SYSTEM SHALL treat `changes_requested` as a rejection wherever + `rejected` is treated as one, so that `any_reject` is satisfied by either. +- **AC-10** WHEN a human records a decision through + `POST /tasks/:id/request-changes`, THE SYSTEM SHALL produce a stored verdict + that satisfies `any_reject` for the guard evaluating at that step, whatever + role that guard names. AC-9 alone does not achieve this; AC-43 and AC-58 are + the other halves. +- **AC-43** THE SYSTEM SHALL satisfy `any_reject` when, among decisions + recorded at the evaluating step for the guard's role, the LAST row per decider + identity `(decider_type, decider_id)` under the AC-26 ordering is a rejection + — whether or not that decider's `participant_id` is in the required slate. A + rejection is a veto: one is sufficient and it does not require a quorum seat. + This is what makes a singleton-user rejection countable, since that decider + has no `agent_profile_id` and therefore no participant row, and is written + with the `userParticipantSentinel` participant id. +- **AC-43b** THE SYSTEM SHALL key the AC-43 veto scan on the latest verdict per + decider, NOT on the presence of any rejection row. A decider who rejects and + then records an approval at the same step SHALL NOT continue to veto. Stated + because the naive reading — scan for a rejection — reintroduces the defect + named under `## Persistence guarantees`: the engine's `DecisionInfo` + projection carries no `superseded_at`, so "non-superseded" is not a predicate + the evaluator can express, and only last-row-wins is. +- **AC-43c** THE SYSTEM SHALL extend the engine's `DecisionInfo` projection to + carry `decider_type`, `decider_id`, `role`, and `comment`. AC-43 needs decider + identity, AC-42 needs `role`, and AC-38 needs `comment`; the projection today + carries only `participant_id`, `decision`, and `note`, so none of the three is + satisfiable without this. The underlying columns already exist and + `ListStepDecisions` already selects them. +- **AC-43a** THE SYSTEM SHALL count only slate members, per AC-50, toward + `all_approve`, `all_decide`, `majority_approve`, and `n_approve:`. An + approval is a quorum contribution and does require a seat. AC-43 is scoped to + `any_reject` alone, so a non-slate approval never advances a task. +- **AC-58** THE SYSTEM SHALL evaluate the AC-43 veto over decisions with + `decider_type = user` WITHOUT filtering on the guard's role, while continuing + to filter agent decisions by role per AC-42. The singleton human's stored + `role` SHALL remain whatever `resolveDeciderRole` returns for a user caller — + today the constant `approver`, which `## Out of scope` leaves unchanged — and + the veto SHALL NOT depend on it. + Without this, AC-10 is unsatisfiable at every reviewer-guarded step, which is + the step the motivating card in `## Why` is stuck at: `resolveDeciderRole` + sets `hasApprover = true` unconditionally for `decider_type = user` and + returns on the approver-wins branch, so a human clicking **Request changes** + at `Office Default`'s Review — whose guards name `role: reviewer` — writes + `role = approver`, and AC-42 discards it before AC-43 ever sees it. + The alternative, stamping the human's decision with the evaluating guard's + role, is rejected rather than merely unchosen: a decision is recorded once + while a step may configure several guards, so "the guard's role" is not well + defined at write time. The human is the operator of the board rather than a + seated participant, so a human rejection is a veto over the step, not a + role-scoped quorum contribution. AC-43a already denies a seatless approval any + effect, so this asymmetry touches rejections only. + Boundary, stated so it is a decision rather than an accident: when a step + configures `any_reject` guards under two DIFFERENT roles, one human rejection + satisfies both at once, because the veto is role-agnostic by construction. + That is intended — the human is the operator of the board and is rejecting the + step, not a role's share of it — and AC-17 still means only the first + satisfied transition in configured order is applied. `Office Default` does not + configure this shape; nothing forbids it. +- **AC-11** THE SYSTEM SHALL continue to accept and store verdict strings + outside the recognized set without error, counting them toward `all_decide` + and toward neither `all_approve` nor `any_reject` — preserving the documented + free-form behavior at `workflow/engine/quorum.go:22-24`. +- **AC-12** THE SYSTEM SHALL preserve the existing wire value written by the + human `request-changes` path, so the frontend DTO union + `"approved" | "changes_requested"` continues to typecheck unchanged. + +### Re-evaluation on record + +- **AC-13** WHEN a decision is recorded through any surface, THE SYSTEM SHALL + re-evaluate the current step's `on_turn_complete` guarded transitions before + returning to the caller, using the task's active session as defined in AC-16 + — including on the human HTTP path, which has no calling session of its own. +- **AC-14** WHEN that re-evaluation finds a satisfied guard, THE SYSTEM SHALL + apply the transition, and the recording call SHALL still report success. +- **AC-15** WHEN that re-evaluation fails or errors, THE SYSTEM SHALL keep the + recorded decision, report the recording as successful, and surface the + re-evaluation failure through the observability required by AC-24. +- **AC-16** THE SYSTEM SHALL resolve the re-evaluation session as + `GetActiveTaskSessionByTaskID` already defines it + (`task/repository/sqlite/session.go`): the task's most recently started + session whose state is one of `CREATED`, `STARTING`, `RUNNING`, or + `WAITING_FOR_INPUT`, ordered by `started_at` descending, limit one. A task is + "unresolvable" for this purpose exactly when that query returns no row. +- **AC-16a** WHEN no session is resolvable under AC-16, THE SYSTEM SHALL record + the decision and skip re-evaluation without erroring, matching the existing + blank-session behavior in `Engine.RecordParticipantDecision`, and SHALL report + the skip under AC-23 so a card that recorded a verdict but never re-evaluated + is distinguishable from one that re-evaluated and found the threshold unmet. +- **AC-17** WHEN two guarded transitions are both satisfied, THE SYSTEM SHALL + apply the first in the step's configured `on_turn_complete` action order, + preserving first-transition-wins (`workflow/engine/engine.go:268-283`). + +- **AC-47** THE SYSTEM SHALL run the AC-13 re-evaluation with the engine in + **committing mode** (`EvaluateOnly = false`), so the engine applies a satisfied + transition itself rather than returning a payload for a transport to apply. + Consequently the AC-30 operation id SHALL be marked applied only after the + transition has been applied, or deliberately abandoned under AC-46 — never + before. This is stated because the two precedents in tree disagree and only one + is safe: `Engine.RecordParticipantDecision` passes `EvaluateOnly: true` today + while `office/engine_dispatcher` does not, and `HandleTrigger` calls + `markOperationApplied` on its success path only. Under committing mode an apply + failure returns before the mark, so a retry can still land; under evaluate-only + the id is marked while no transition has been applied, and every retry then + returns `Idempotent: true` with the transition lost permanently — the exact + stuck-card failure this feature exists to eliminate. The existing + `EvaluateOnly: true` on `RecordParticipantDecision` SHALL therefore change. + Keeping the apply inside the engine is also what `## What` means by ownership + following the engine. "Committing mode" here names APPLY-OWNERSHIP — the + engine performs the apply and marks the operation id after it — and NOT reuse + of the full trigger path; AC-65 bounds what the re-evaluation is allowed to + run. + +- **AC-65** THE SYSTEM SHALL scope the AC-13 re-evaluation to the step's guarded + transition actions ONLY. The AC-57a entry point SHALL evaluate those + `on_turn_complete` actions for which `isTransitionAction` holds and which + carry a `wait_for_quorum` guard, in configured order, and apply at most one, + per AC-17 and AC-46. It SHALL NOT execute non-transition action callbacks, and + it SHALL NOT be implemented as a plain + `HandleTrigger(TriggerOnTurnComplete)` call. + This is stated because the obvious implementation is exactly the wrong one. + `evaluateActions` guards only actions for which `isTransitionAction` holds and + routes EVERY other action to `executeCallback` — and that call is not gated on + `EvaluateOnly`, unlike `applyTransition` and `PersistData`. So a + HandleTrigger-based re-evaluation re-runs whatever else the step configures on + `on_turn_complete` — `queue_run`, `clear_decisions`, + `queue_run_for_each_participant`, `run_code_review`, `create_child_task`, + `switch_workflow`, `set_workflow_data` are all registered kinds — once per + RECORDED DECISION rather than once per completed turn. AC-30's operation id is + deliberately unique per decision, so the id-dedup does not suppress the repeat + either. `Office Default` configures only two `move_to_step` actions on + `on_turn_complete`, so this is invisible on the shipped workflow and on every + regression in `### Regression`; it would first appear on a workflow that pairs + a quorum guard with a side-effect action, as `clear_decisions` and + `queue_run_for_each_participant` already are elsewhere in this same workflow's + `on_enter`. A reviewer vote is not a turn completion, and this AC is what keeps + the two from being conflated. + Boundary: when the current step configures no guarded transition at all, the + re-evaluation SHALL apply nothing and report `transition_applied = false` + without erroring. A decision is legitimately recordable at such a step — AC-3 + gates on participation, not on the presence of a guard — and treating "nothing + to evaluate" as a failure would reject valid verdicts. + +- **AC-57** THE SYSTEM SHALL implement the AC-50 slate construction, the AC-51 + seat matching, the AC-4/AC-4a role resolution and the decision write EXACTLY + ONCE, in the engine, against the engine's `ParticipantStore` and + `DecisionStore` ports, and SHALL expose them to both transports through a + single engine decision entry point. Neither the MCP tool nor + `office/dashboard` SHALL carry its own copy. This is stated because the + ownership sentence in `## What` is not satisfiable as written today: + `DashboardService`'s only engine connection is + `engineDispatcher shared.WorkflowEngineDispatcher`, whose entire interface + surface is one `HandleTrigger` method — no slate read, no role resolution, no + decision write. The office decision path therefore runs through + `recordTaskDecision` -> `resolveDeciderRole` -> `s.repo.ListAllTaskParticipants` + in `office/repository/sqlite`, a DIFFERENT Go package from the + `workflow/repository` one backing the engine's `ParticipantStore`, while both + read and write the same `workflow_step_participants` table. Left unstated, a + builder may reasonably implement AC-50's four steps a second time on the + office side — two canonicalizations over one table, which is precisely the + read-side/write-side divergence AC-44 exists to prevent. +- **AC-57a** THE SYSTEM SHALL extend `shared.WorkflowEngineDispatcher` with an + additive **write-side** method that records a decision and performs the AC-13 + re-evaluation, and SHALL wire it at the existing dispatcher construction site + (`backendapp/main.go`, where `officeenginedispatcher.New` is already built + from the shared engine instance and the task repo). `HandleTrigger` and every + existing caller of it SHALL be unchanged. This is the write half of the + minimum port change that makes AC-57 buildable; without naming it, "call into + the engine's decision API" has no referent. It is the FIRST of exactly two + additive methods this feature adds to that interface — AC-57d names the + second, read-side one. Earlier wording said "one additive method", which read + as forbidding the read-side method that AC-24b cannot be built without. +- **AC-57b-i** THE AC-57a entry point's return SHALL carry the decision row id + and the persisted `decided_at`, because AC-57b promises + `publishDecisionRecorded` is preserved unchanged and that function publishes + `"decision_id": d.ID` and `"created_at": d.CreatedAt` off the + `DecisionRecord`. Once the persist moves behind the entry point, office can no + longer source those two values itself. Without this the builder must either + widen the entry point's return undocumented or locally synthesise an id and a + timestamp, and a locally taken timestamp need not equal the row's stored + `decided_at` — publishing an event whose `created_at` disagrees with the row + it describes. AC-64 observes both fields on the tool surface and they are the + same two values. + Concretely: the entry point SHALL stamp `decided_at` once, persist that value, + and return THAT value — never re-read the clock to build the response. Two + clock reads around one write are the defect this clause exists to forbid; they + would put `created_at` on the published event ahead of the row every time, + which is invisible in review and wrong in every timeline that joins the two. +- **AC-57b** THE SYSTEM SHALL confine the office-side change to the resolve, + persist and re-evaluate core of `recordTaskDecision`. Its surrounding + behavior — `publishDecisionRecorded`, `logDecisionActivity`, + `runReactivityForDecision`, and the `DecisionRecord` shape returned to the + handler — SHALL be preserved, and every other reader of + `ListAllTaskParticipants` (task inbox, scheduler reactivity, the task-detail + handler) SHALL keep today's step-scoped behavior, as `## Out of scope` + already states. This bounds the refactor: the card repoints one function's + core, it does not migrate `office/dashboard` onto the engine wholesale. +- **AC-57c** WHEN the engine decision entry point is not wired — `main.go` + already returns early and logs "workflow engine not initialised; office engine + dispatcher disabled" when `orchestratorSvc.WorkflowEngine()` is nil, leaving + `DashboardService.engineDispatcher` nil — THE SYSTEM SHALL reject the decision + call with an error and write no row, on BOTH transports. It SHALL NOT fall back + to a second office-side implementation of AC-50, which would resurrect exactly + the divergence AC-57 forbids, and it SHALL NOT silently persist a decision that + can never be counted. This mirrors the existing `decisionStoreNotWiredErr` + guard in `recordTaskDecision` rather than inventing a new failure mode. +- **AC-57d** THE SYSTEM SHALL extend `shared.WorkflowEngineDispatcher` with a + second additive method, **read-only**, that evaluates every guarded + `on_turn_complete` transition configured at a task's current step and returns + one entry per guard together with the AC-62 `reevaluation_blocked` value: + + `EvaluateStepQuorum(ctx, taskID) (QuorumSnapshot, error)`, where + `QuorumSnapshot` carries `StepID`, `ReevaluationBlocked bool`, and + `Guards []QuorumGuardState`, and each `QuorumGuardState` carries + `TargetStepID`, `Role`, `Threshold`, `RequiredCount`, `ReceivedCount`, + `Satisfied`, `Reason` and `Error`. + + This method SHALL apply no transition, write no row, execute no action + callback, emit no AC-24 log record and increment no AC-24a counter. It SHALL + reuse the AC-50 slate construction, the AC-51 seat matching and the AC-52 + reason precedence — it is a second CALLER of that logic, never a second + implementation of it, per AC-57. + + **Ordering.** `Guards` SHALL be returned in the step's configured + `on_turn_complete` action order, so that AC-61's selection rule and AC-17's + first-transition-wins read off one and the same order. This is the named + ordering AC-61 depends on; without it AC-61's "first such entry" has no + referent. + + **Nil / empty / error.** A task with no bound `workflow_step_id` SHALL return + an empty `Guards`, `ReevaluationBlocked = false` and no error, which is what + AC-24c requires. A step with a bound id but no guarded transition SHALL + likewise return an empty `Guards` and no error; AC-61 aggregates both to + `clear`. A store error SHALL surface as a per-guard `evaluation_error` entry + per AC-23, not as a method-level error, so one failing guard does not blank + the others. A method-level error is reserved for the not-wired case. + + **Not wired.** When the dispatcher is nil, per the AC-57c condition, the + AC-24b handler SHALL return an error rather than falling back to an + office-side evaluation — the read side carries the same no-fallback rule as + the write side, and for the same reason. + + **Unknown task.** An unresolvable `taskID` SHALL surface as a method-level + error, distinct from the unbound-step case of AC-24c which is a successful + empty result; the AC-24b handler SHALL map it exactly as the sibling + `GET /tasks/:id/decisions` route already maps an unknown task. Stated because + AC-24c's "return empty rather than error" is easy to over-apply to a task that + does not exist at all, which would report a missing task as a healthy one. + + **Idempotency and concurrency.** The method is a pure read: two concurrent + calls compute independently from committed state and neither observes nor + mutates the other. It needs no operation id. + + AC-24b's HTTP payload SHALL be a direct projection of this snapshot, so the + endpoint and the tool's `guards` field cannot drift apart. + + Without this AC there is no legal way to build AC-24b at all: AC-57 forbids + `office/dashboard` from carrying its own copy of the slate machinery, and + `DashboardService`'s only engine connection is + `engineDispatcher shared.WorkflowEngineDispatcher`, whose declared surface is + `HandleTrigger` alone. AC-24b, AC-24c, AC-25, AC-60, AC-61 and AC-62 all + depend on this method existing. + + **Implementation note, not a requirement:** the in-tree precedent for adding a + dispatcher capability is a separate optional interface plus a type assertion + (`handledWorkflowEngineDispatcher` in `office/dashboard/service_tasks.go` + reaching `HandleTriggerHandled`), rather than widening the shared interface. + Either shape satisfies this AC; the requirement is that the method exist and + be reachable from the AC-24b handler, not which of the two idioms carries it. + +### Participant slate + +- **AC-18** WHEN a quorum guard evaluates at a step, THE SYSTEM SHALL count a + **per-task** participant row (`task_id != ''`) with `decision_required = 1` + and a matching role even when that row was attached under a different step of + the same workflow. +- **AC-18a** THE SYSTEM SHALL NOT extend cross-step counting to template-level + rows (`task_id = ''`). A template row is bound to one step by workflow design, + so it remains visible only at its own step. Without this, a Work-step template + reviewer would be pulled into a Review-step quorum. +- **AC-44** THE SYSTEM SHALL identify a participant by the natural key + `(task_id, role, agent_profile_id)`, and SHALL select one **canonical row** + per key: the row whose `step_id` equals the evaluating step when such a row + exists, otherwise the row with the lowest `id` in ASCII order among that key's + rows. `workflow_step_participants` carries no timestamp column, so `id` ASC is + the tiebreak — "most recent" is not expressible here. The same rule SHALL be + used by slate construction (AC-18), by role resolution (AC-4a), and by the + `participant_id` written on a decision (AC-2), so that the read side and the + write side cannot disagree. AC-44 is step 3 of AC-50; the id that finally + identifies a participant is the one surviving AC-50 step 4, because AC-20's + template-versus-per-task collapse keys on `(role, agent_profile_id)` and can + still discard an AC-44 canonical row. +- **AC-19** WHEN both a step-specific row and a differently-stepped row exist + for the same `(task_id, role, agent_profile_id)`, THE SYSTEM SHALL count the + participant exactly once, as the AC-44 canonical row. This shape is produced + in normal operation: `AddTaskParticipant`'s idempotency probe is scoped to + `step_id`, so re-attaching the same agent after a step change inserts a second + row rather than matching the first. +- **AC-20** THE SYSTEM SHALL continue to honor template-level rows + (`task_id = ''`) with per-task rows taking precedence on + `(role, agent_profile_id)`, per `phase2_sqlite.go:20-27`. +- **AC-21** WHEN the required slate for the guard's role is empty AND the + guard's threshold is an approve-style one (`all_approve`, `all_decide`, + `majority_approve`, `n_approve:`), THE SYSTEM SHALL NOT fire the + transition, and SHALL report the empty slate distinctly from an unmet + threshold per AC-23. The scoping to approve-style thresholds is required by + AC-59; an unscoped empty-slate short-circuit makes the AC-43 veto unreachable. +- **AC-22** WHEN a decision is on file for a participant who WAS in the required + slate and has since been removed, THE SYSTEM SHALL ignore that decision for + the counting thresholds, preserving the documented mid-flight removal + behavior in `applyThreshold`. This governs removed slate members only; it does + NOT govern deciders who never held a seat, whose rejections are handled by + AC-43 and whose approvals are excluded by AC-43a. + +- **AC-49** THE SYSTEM SHALL extend the engine's `ParticipantStore` port with a + task-scoped read — `ListTaskParticipants(ctx, taskID)`, returning every + per-task row (`task_id = ?`) for that task irrespective of `step_id` — and + SHALL leave `ListStepParticipants(ctx, stepID, taskID)` unchanged. AC-18's + cross-step counting is not expressible through the existing port, whose only + production query is `WHERE step_id = ? AND (task_id = '' OR task_id = ?)`; + widening that predicate in place would also pull template rows across steps, + which AC-18a forbids. A task with no per-task rows SHALL yield an empty list, not an + error, matching `ListStepParticipants`'s documented empty-is-valid contract. + This is the participant-side twin of AC-43c — the projection and the port both + have to be named, or the builder invents them. +- **AC-50** THE SYSTEM SHALL build the required slate for a guard, at the + evaluating step, in exactly this order: + 1. **Gather** — per-task rows for the task via AC-49 (any step), plus + template rows (`task_id = ''`) at the evaluating step only, per AC-18a. + 2. **Filter** — keep rows whose `role` matches the guard and whose + `decision_required` is true, per `requiredParticipants`. + 3. **Canonicalize** — collapse to one row per `(task_id, role, + agent_profile_id)` by AC-44. + 4. **Collapse** — collapse across `task_id` to one row per + `(role, agent_profile_id)`, the per-task row winning, per AC-20. + + The row surviving step 4 is THE **seat**; its `id` is the slate id that AC-2 + writes and AC-26 counts. The order is stated because AC-44 keys on + `(task_id, role, agent_profile_id)` while AC-20 keys on + `(role, agent_profile_id)`: running AC-44 alone seats one agent twice when it + holds both a template row and a per-task row, inflating `totalRequired` so + `all_approve` can never be met, and running AC-20 alone discards AC-44's + cross-step canonicalization. Boundary: a row whose `agent_profile_id` is empty + has no identity to canonicalize on and SHALL be kept as its own seat, keyed by + its row `id`, at both step 3 and step 4 — collapsing two such rows into one + seat would under-count `totalRequired` and let `all_approve` fire on a single + approval. +- **AC-51** WHEN counting decisions against the slate, THE SYSTEM SHALL map each + decision to at most one AC-50 seat: by `participant_id` when that id is a seat + id, otherwise by the seat whose `(role, agent_profile_id)` equals the + decision's `(role, decider_id)` when `decider_type` = `agent`. A decision that + maps to no seat is not counted toward any approve-style threshold, per AC-43a; + `any_reject` does not use this mapping at all, per AC-43. The fallback exists + because a participant's AC-44 canonical row can CHANGE after that participant + has already decided — AC-19 states the two-row shape arises in normal + operation, so attaching a current-step row for an agent who decided while only + an earlier-step row existed flips the seat id, and an id-only match would + silently discard a real verdict, which is the discard class AC-2 exists to + prevent. AC-22 is unaffected: a participant removed from the slate entirely has + no seat under either match, so its decision is still ignored. +- **AC-59** WHEN the required slate is empty, THE SYSTEM SHALL still evaluate an + `any_reject` guard and SHALL NOT short-circuit it. The empty-slate fail-closed + of AC-21 exists because `all_approve` is `approveCount == totalRequired`, + which is vacuously TRUE at `0 == 0` — an empty slate would otherwise advance a + task nobody reviewed. `any_reject` is `rejectCount > 0` and cannot fire + vacuously, so the same guard rail is unnecessary there and actively harmful: + it makes the AC-43 seatless veto unreachable in exactly the case where every + decider is seatless. Implementation consequence, stated because this is not a + free change: `evaluateWaitForQuorum` returns at `len(required) == 0` before + `ListStepDecisions` is called, so the empty-slate check must become + threshold-aware rather than remaining a precondition. + +### Distinguishing waiting from stuck + +- **AC-23** WHEN a guarded transition does not fire, THE SYSTEM SHALL record + exactly one reason code from this closed set: + `threshold_not_met`, `slate_empty`, `decision_store_unwired`, + `participant_store_unwired`, `guard_variant_unrecognized`, + `threshold_unrecognized`, `threshold_unsatisfiable`, `evaluation_error`, and + `session_unresolvable`. + `decision_store_unwired` and `participant_store_unwired` are separate because + `evaluateWaitForQuorum` SHALL nil-check them separately — today it does not, + the current code being a single combined + `if e.decisions == nil || e.participants == nil { return false, nil }`, so + splitting that branch in two is a REQUIRED change of this feature and not an + existing property to be relied on. Left unsplit, the two codes are + indistinguishable in practice and AC-23's taxonomy silently loses a member; + `threshold_unsatisfiable` carries AC-40, the case where the threshold can + never be met by any future decision and the card is stuck rather than waiting; + `evaluation_error` carries a genuine error returned by + `ListStepParticipants` or `ListStepDecisions`; `session_unresolvable` carries + AC-16a. `threshold_unrecognized` carries AC-53, a threshold string + the evaluator does not recognize at all. The set is closed rather than "at + minimum" so that AC-24a's expvar keys are enumerable and a new reason cannot + appear unlabelled. More than one condition can hold at once; AC-52 fixes which + single code is reported. +- **AC-24** THE SYSTEM SHALL emit, each time a guarded transition does not fire + **on the engine's transition-evaluation path**, a structured log record + carrying the task id, step id, guard role, + threshold, required-participant count, decisions-received count, the AC-23 + reason code, and an `error` field which is populated when and only when the + reason is `evaluation_error`. The engine holds no logger today, so wiring one + is part of this feature. +- **AC-24a** THE SYSTEM SHALL expose counts of not-fired guard evaluations under + `/debug/vars`, keyed by the AC-23 reason, following the existing `workflow_*` + expvar convention documented in the root `CLAUDE.md`. Like AC-24, this counts + **engine-path evaluations only**: the AC-24b diagnostic endpoint evaluates + guards read-only and SHALL emit no log record and increment no counter. Stated + because AC-54 requires that endpoint to evaluate every guard live, so the two + ACs otherwise overlap: were diagnostic reads counted, these counters would + measure UI polling rather than workflow health, and a card left open in a + browser would out-count a genuinely stuck one. +- **AC-24b** THE SYSTEM SHALL expose guard state for a task at + `GET /api/v1/office/tasks/:id/quorum`, a sibling of the existing + `GET /api/v1/office/tasks/:id/decisions` route + (`office/dashboard/handler.go`) and carrying the same authorization as that + route. The response SHALL contain one entry per guarded `on_turn_complete` + transition configured at the task's current step, each with: `target_step_id`, + `role`, `threshold`, `required_count`, `received_count`, `satisfied` (a + boolean), `reason` (an AC-23 code, present if and only if `satisfied` is + `false` — see AC-60), and `error` (populated only for `evaluation_error`). A + task at a step with no guarded transition SHALL return an empty list, not a + 404. Entries are computed live per AC-54, which also defines the payload's + top-level `reevaluation_blocked` field. The payload SHALL be a direct + projection of the AC-57d snapshot — that is the only path by which this + handler may reach the slate machinery, since AC-57 forbids it from carrying + its own copy — preserving AC-57d's ordering. AC-61 defines how the card + renders when entries disagree. +- **AC-24c** WHEN the task has no bound `workflow_step_id`, the AC-24b endpoint + SHALL return an empty list rather than an error, so a diagnostic read never + fails on the state it exists to diagnose. +- **AC-25** WHEN the task's AC-24b entries aggregate to `awaiting` under AC-61, + the Office task detail view SHALL render the task as awaiting decisions — + showing `received_count` of `required_count` for the role of the entry that + AC-61 selects — and SHALL NOT render it as failed or errored. When they + aggregate to `stuck`, the view SHALL render a visually distinct stuck state + naming the selected entry's AC-23 reason. This is the whole diagnostic gap: + the two states are byte-identical today. AC-61 exists because AC-24b returns a + LIST and `Office Default`'s Review configures two guards, so "the reason" is + not well defined without an aggregation rule. + +- **AC-52** WHEN more than one AC-23 condition holds at once, THE SYSTEM SHALL + report the first that applies in this order, which mirrors the order the code + discovers them: `guard_variant_unrecognized`, `decision_store_unwired`, + `participant_store_unwired`, `evaluation_error`, `slate_empty`, + `threshold_unrecognized`, `threshold_unsatisfiable`, `threshold_not_met`. + AC-23 requires exactly one code, and AC-21 and AC-40 both fire for an empty + slate with any positive `n_approve:`, since every positive N exceeds a slate + of zero. `slate_empty` wins: it is the more actionable diagnosis, and + `evaluateWaitForQuorum` already returns at `len(required) == 0` before the + threshold is examined. The ninth code, `session_unresolvable`, is not in this + ordering because it is not a guard-evaluation outcome at all — it is the + recording-time skip of AC-16a, surfaced through AC-24/AC-24a and, on the + AC-24b payload, through the separate field AC-54 defines. `slate_empty` is + reachable only for approve-style thresholds, per AC-21 as scoped and AC-59; an + `any_reject` guard over an empty slate reports `threshold_not_met`, because a + seatless rejection can still arrive and so the card is waiting, not stuck. +- **AC-53** WHEN the guard's threshold is a string that is neither one of the + five recognized thresholds nor prefixed `n_approve:`, THE SYSTEM SHALL not fire + the transition and SHALL report AC-23 reason `threshold_unrecognized`, which + AC-25 renders as stuck. `applyThreshold` returns false from its final + fall-through for such a value today, indistinguishably from + `threshold_not_met`, so a mistyped threshold would render as "awaiting + decisions" forever — the exact diagnostic gap this feature exists to close. + `guard_variant_unrecognized` is reserved for a guard whose variant is not + `wait_for_quorum` at all, matching `evaluateTransitionGuard`'s fail-closed + branch. +- **AC-54** THE SYSTEM SHALL compute AC-24b's per-guard entries by evaluating + each guarded `on_turn_complete` transition configured at the task's current + step **live, at request time, and independently** — not by replaying the + engine's transition loop, and not by reading a stored evaluation. Independent + evaluation is required because `evaluateActions` short-circuits, evaluating a + guard only while `targetStepID == ""`, so a replay cannot enumerate every + guard the way AC-24b requires. A per-guard `reason` SHALL therefore never be + `session_unresolvable`, which is a recording-time skip rather than an + evaluation outcome; the AC-24b payload SHALL instead carry a top-level + `reevaluation_blocked` boolean, defined by AC-62, and AC-61 SHALL treat it as + stuck-distinct. The field SHALL be `false` when the task has no decisions at + its current step, so an untouched task is never rendered as stuck. + +- **AC-60** THE SYSTEM SHALL set an AC-24b entry's `satisfied` to `true` when + that guard's threshold is met at request time, and SHALL omit `reason` + entirely for such an entry. AC-23's set is a taxonomy of why a guard did NOT + fire and SHALL NOT gain a "satisfied" member, so AC-24a's expvar keys stay + enumerable. The state is reachable rather than theoretical: under AC-16a a + verdict is recorded, re-evaluation is skipped, and the task sits at the step + with its quorum already met. AC-16a is the whole of it. An earlier draft also + claimed the AC-46 abandon as a second route; it is not one, and the claim is + removed rather than left to send a builder hunting: AC-46 abandons PRECISELY + when the task's `workflow_step_id` no longer equals the evaluated step — the + race winner has already moved it — and AC-24b evaluates at the task's CURRENT + step, so the abandoned guard is not in the returned list at all. +- **AC-61** THE SYSTEM SHALL aggregate a task's AC-24b entries to exactly one + card-level state: `stuck` when `reevaluation_blocked` is `true` or when ANY + entry is unsatisfied with a `reason` other than `threshold_not_met`; + otherwise `awaiting` when any entry is unsatisfied; otherwise `clear`. When + the state is `stuck` the SELECTED entry is the first such entry in the step's + configured `on_turn_complete` action order, matching AC-17's + first-transition-wins; when `awaiting`, it is the first unsatisfied entry in + that same order. Stuck outranks awaiting because a stuck guard is the + actionable diagnosis and MUST NOT be masked by a sibling that is merely + waiting — a mistyped `n_approve:` reporting `threshold_unsatisfiable` + beside a healthy `any_reject` reporting `threshold_not_met` is the concrete + case, and rendering that card as "awaiting decisions" forever is the exact + defect AC-53 was added to close. Boundary: an EMPTY entry list aggregates to + `clear`, which is the correct rendering for both a step with no guarded + transition and the AC-24c unbound-step case — neither is stuck, and neither is + waiting on a decision. +- **AC-62** THE SYSTEM SHALL compute `reevaluation_blocked` live at request time + as the conjunction of: the task has at least one non-superseded decision at + its current step, AND the AC-16 session query returns no row. It SHALL NOT be + read from a stored flag. + The first conjunct SHALL be evaluated as "`ListStepDecisions` returns a + non-empty list for (task, current step)". That is equivalent, and it is the + form the engine can actually express: a row is superseded only when a + replacement is inserted for the same decider, so a step holding at least one + row always holds at least one non-superseded row. Stating the equivalence + matters because the engine's `DecisionInfo` projection carries no + `superseded_at` — the same limitation AC-43b turns into last-row-wins — so a + literal reading of "non-superseded" would be a predicate the evaluator cannot + compute. + This is a CURRENT-STATE predicate, not a history of AC-16a skips, and the + difference is deliberate. A stored "the last recording skipped" flag would keep + rendering a card as stuck after a session returned and the card became healthy, + and reading a stored evaluation is the very thing AC-54 forbids for the + per-guard entries. The live predicate also needs no ordering rule, no tiebreak + and no new column, so the "most recent decision" ambiguity does not arise; two + concurrent reads compute the same value from committed state. + Naming: the field is `reevaluation_blocked`, NOT `last_reevaluation_skipped`, + because it asserts something different from the original and a reader must not + carry the historical reading across the rename. + +### Ordering, idempotency, concurrency + +- **AC-26** THE SYSTEM SHALL order decisions for threshold evaluation by + `decided_at` ascending, tie-broken by `id` ascending — the existing + `ListStepDecisions` contract (`phase2_sqlite.go:594`) — and SHALL count only + the last row per **AC-50 seat** under that ordering, decisions being matched to + seats by AC-51. This clause governs the approve-style thresholds + (`all_approve`, `all_decide`, `majority_approve`, `n_approve:`) ONLY; + `any_reject` is keyed on decider identity and needs no seat, per AC-43 and + AC-43b. The scoping is explicit because an unqualified "last row per + `participant_id`" is precisely the reading that reintroduces the + discarded-human-rejection defect AC-43 exists to fix — production funnels every + threshold through one `latestDecisionsPerParticipant` call today, so reusing + this rule for `any_reject` is the path of least resistance. +- **AC-27** WHEN the same participant records a second decision for the same + `(task_id, step_id)`, THE SYSTEM SHALL mark the prior row `superseded_at` and + insert the new one in a single transaction, and the new verdict SHALL replace + the old one for threshold purposes. +- **AC-28** WHEN two decisions are recorded concurrently for the same + `(task_id, step_id)` by different participants, THE SYSTEM SHALL persist both, + and at most one guarded transition SHALL be applied for the resulting step + change. The engine's operation-id mechanism CANNOT provide this: + `HandleTrigger` dedupes by exact `OperationID` match only + (`isOperationAlreadyApplied`) and offers no mutual exclusion across distinct + ids, and two concurrent decisions necessarily observe different decision sets + and so compute different ids. Mutual exclusion SHALL therefore be enforced at + apply time per AC-46, not by the operation id. +- **AC-46** WHEN a transition is applied after a quorum re-evaluation, THE + SYSTEM SHALL apply it only if the task's `workflow_step_id` still equals the + step the guard was evaluated against, and SHALL abandon the apply otherwise. + AC-48 defines the port this needs and why the existing one cannot express it. + The loser of a concurrent race SHALL report `transition_applied = false` with + its decision still persisted, and SHALL NOT be reported as an error: losing + the race is a normal outcome, not a failure. +- **AC-29** WHEN two decisions are recorded concurrently by the *same* + participant for the same `(task_id, step_id)`, THE SYSTEM SHALL leave exactly + one non-superseded row for that participant. +- **AC-30** WHEN a re-evaluation is triggered twice for the same recorded + decision, THE SYSTEM SHALL apply the transition at most once, keyed on a + **deterministic** operation id of the form + `decision:::`. Determinism is the whole + point: `Engine.RecordParticipantDecision` currently builds its operation id + with a `time.Now().UnixNano()` suffix, which yields a fresh id on every call + and therefore deduplicates nothing. That existing construction SHALL NOT be + reused as-is. The repo's convention for this primitive is a deterministic + content-derived key — see `childCompletionOperationID` + (`orchestrator/event_handlers_children_completed.go`). +- **AC-45** THE SYSTEM SHALL NOT accept a client-supplied idempotency key on the + decision tool. A repeated call is a new verdict and supersedes the previous one + per AC-27. Consequence, stated so a builder does not quietly invent a key: an + agent that retries after a timeout on a call that actually succeeded records a + second verdict rather than a no-op. This is acceptable because the verdict is + the same value and AC-27 leaves exactly one non-superseded row; it is NOT + acceptable to paper over it with a synthesized key derived from the arguments, + which would make a deliberate change-of-mind silently fail. +- **AC-66** THE SYSTEM SHALL generate the decision row id BEFORE the write and + pass it in `DecisionInfo.ID`, following the existing office precedent + (`recordTaskDecision` sets `ID: uuid.New().String()` on the row it hands to + `RecordStepDecision`). It SHALL NOT follow `Engine.RecordParticipantDecision`, + which leaves `ID` blank and lets the repository generate one. The + `DecisionStore.RecordStepDecision(ctx, d DecisionInfo) error` signature is + UNCHANGED by this feature. + The two in-tree precedents disagree and only one is usable: the port returns + no id, so a blank-`ID` write leaves AC-30's operation id + `decision:::` unconstructible, and AC-57b-i + unsatisfiable since office would have no id to publish. Recovering the id by + reading the row back after the write is NOT permitted — under the AC-28 + concurrency this must coexist with, a read-back can select a different + decider's row for the same `(task_id, step_id)`. Pre-generation also makes the + operation id available before the write, which is what lets AC-47 mark it + applied strictly after the apply. +- **AC-31** WHEN a task re-enters a guarded step, THE SYSTEM SHALL clear that + step's decisions before the round's participants are queued, so a new round + starts with zero decisions — the existing `clear_decisions` ordering on + `on_enter`. AC-56 makes this observable rather than assumed, and names what it + means when it does not hold. + +- **AC-48** THE SYSTEM SHALL add exactly one method to the engine's transition + port — `ApplyTransitionIfAtStep(ctx, taskID, sessionID, expectedStepID, + toStepID, trigger) (applied bool, err error)` — used ONLY by the AC-46 quorum + apply, and SHALL leave the existing `ApplyTransition(...) error` and every + transition that uses it semantically unchanged. `HandleResult` SHALL gain an + additive `TransitionAbandoned` field so an abandoned apply is distinguishable + from both a transition and an error. This is required because AC-46's outcome + is not expressible today: `ApplyTransition` receives `fromStepID` and ignores + it, its orchestrator implementation unconditionally sets + `task.WorkflowStepID = toStepID`, and a single `error` return cannot separate + "lost the race" from "the write failed". The compare-and-swap SHALL read the + task's `workflow_step_id` inside the same transaction that performs the update + — `updateTaskWithWorkflowStepAdmission` already opens one and already takes the + workspace and workflow-step locks — so no new locking primitive is introduced + and no non-quorum transition changes behavior. `applied = true` means the task + left `expectedStepID`; it does NOT mean the task was WIP-admitted at the + target, because a step at capacity queues the task there and that is still a + completed transition. + +### Step binding and threshold boundaries + +- **AC-36** THE SYSTEM SHALL bind a decision to the task's `workflow_step_id` + read during validation, and SHALL write the row inside the same transaction + that supersedes the participant's prior verdict for that step. +- **AC-37** WHEN the task's step changes between validation and write, THE + SYSTEM SHALL persist the decision against the **validated** step — not reject + it, and not re-target it at the new step. The recorded row SHALL NOT be + counted at the new step, the call SHALL report success, `transition_applied` + SHALL be `false`, and the returned `step_id` SHALL be the validated step so + the caller can see its verdict landed on a step the task has left. Persisting + is chosen over rejecting because the reviewer's verdict is real work and + discarding it would silently lose a review; the returned `step_id` is what + makes the outcome detectable. +- **AC-38** THE SYSTEM SHALL write the caller's reason to + `workflow_step_decisions.comment`. +- **AC-39** THE SYSTEM SHALL satisfy `majority_approve` only when approvals + strictly exceed half the required slate, so a slate of 2 requires 2 approvals + and a slate of 3 requires 2 — preserving `approveCount*2 > totalRequired` + (`workflow/engine/quorum.go:113`). +- **AC-40** WHEN `n_approve:` names an N greater than the required slate size, + THE SYSTEM SHALL never fire that transition and SHALL report AC-23 reason + `threshold_unsatisfiable` rather than an error. It is deliberately NOT + `threshold_not_met`: no future decision can satisfy it, so it is a stuck card, + and AC-25 requires stuck to look different from waiting. +- **AC-41** WHEN `n_approve:` names a non-numeric or non-positive N, THE + SYSTEM SHALL not fire the transition and SHALL report AC-23 reason + `threshold_unrecognized`. The threshold family is recognized here and only its + parameter is malformed, so this is not a guard-variant failure; + `guard_variant_unrecognized` is reserved for the case AC-53 names. +- **AC-42** WHEN a decision exists for a role other than the guard's role at the + same step, THE SYSTEM SHALL ignore it for that guard — EXCEPT for the + `decider_type = user` veto of AC-58, which is deliberately role-agnostic. + AC-42 governs seated, role-scoped quorum contributions; the human veto is not + one. + +### Regression + +- **AC-32** GIVEN a task at Review with exactly one reviewer participant + (`decision_required = 1`) and one recorded `approved` decision from that + reviewer, WHEN quorum is evaluated, THE SYSTEM SHALL move the task to + Approval. +- **AC-33** GIVEN the same task with one recorded rejection instead, WHEN quorum + is evaluated, THE SYSTEM SHALL move the task to Work. +- **AC-34** GIVEN two reviewer participants and one approval, WHEN quorum is + evaluated under `all_approve`, THE SYSTEM SHALL leave the task at Review and + report it as awaiting one further decision. +- **AC-35** GIVEN a workflow whose steps carry no guard, THE SYSTEM SHALL + transition exactly as it does today. +- **AC-63** GIVEN a task at Review, whose guards name `role: reviewer`, and a + human recording **Request changes** through `POST /tasks/:id/request-changes`, + WHEN quorum is evaluated, THE SYSTEM SHALL move the task to Work — even though + that decision is stored with `role = approver`. This is the regression that + makes AC-10 and AC-58 observable end to end, and it is the exact path that is + broken in production today. + +- **AC-56** GIVEN a task moved off a guarded step by `any_reject` under AC-33 + and later returned to that step, WHEN quorum is next evaluated there, THE + SYSTEM SHALL evaluate against zero decisions from the prior round. This AC + exists to make AC-31's premise observable rather than assumed: `clear_decisions` + on `on_enter` is depended upon but verified by no other AC, and the only + `on_enter` trigger dispatch in the tree is inside `SwitchWorkflowCallback` — + the orchestrator's callback registry wires a `DispatchTriggerFn` for + `switch_workflow` only. If this AC fails, this card is blocked on the sibling + `on_enter` action-dispatch feature and SHALL be reported as blocked; it SHALL + NOT be closed by adding a second decision-clearing path inside this feature. + Without it, a rejection persists at the guarded step and re-fires `any_reject` + on the next completed turn, so AC-32 and AC-33 cannot both hold across a round + trip. + +## Failure modes + +| Condition | Behavior | +|---|---| +| Agent not a participant | Permission error; no row written (AC-3) | +| Verdict outside `approved`/`rejected` on the tool | Validation error; no row (AC-5) | +| Empty reason | Validation error; no row (AC-6) | +| Task has no bound step | Error naming the unbound step; no row (AC-7) | +| Decision store not wired | Guard does not fire; reason recorded (AC-23) | +| Required slate empty, approve-style threshold | Guard does not fire; reason `slate_empty` (AC-21, AC-23) | +| Required slate empty, `any_reject` | Guard still evaluated, not short-circuited; a seatless rejection vetoes (AC-59, AC-43) | +| Unknown guard variant | Guard does not fire; reason recorded (AC-23) | +| Re-evaluation errors after a successful write | Decision kept; success reported; failure surfaced (AC-15) | +| No resolvable session | Decision kept; re-evaluation skipped, no error (AC-16) | +| Duplicate decision from one participant | Prior superseded transactionally (AC-27) | +| Human rejection, no participant row | Counted by `any_reject` as a veto (AC-43) | +| Non-slate approval | Not counted toward any approve threshold (AC-43a) | +| Participant has rows at two steps | Canonical row chosen by AC-44; one seat (AC-19) | +| Task step changed between validate and write | Persisted against validated step; `transition_applied=false`; returned `step_id` is the validated step (AC-37) | +| Concurrent transition race lost | `transition_applied=false`, decision kept, not an error (AC-46) | +| Participant store not wired | Guard does not fire; reason `participant_store_unwired` (AC-23) | +| `ListStepDecisions` / `ListStepParticipants` errors | Guard does not fire; reason `evaluation_error` with `error` field (AC-23, AC-24) | +| No active session for re-evaluation | Decision kept, re-eval skipped, reason `session_unresolvable` (AC-16a) | +| Unrecognized threshold string | Guard does not fire; reason `threshold_unrecognized`; rendered as stuck (AC-53) | +| Empty slate AND `n_approve:` unsatisfiable | Single reason `slate_empty` wins by AC-52 precedence | +| Seat id changed after a verdict was recorded | Verdict still counted, matched by decider identity (AC-51) | +| Re-evaluation apply fails | Operation id left unmarked so a retry can still land (AC-47) | +| Quorum apply loses the step compare-and-swap | `applied = false`, not an error; `TransitionAbandoned` set (AC-48, AC-46) | +| Target step at WIP capacity | Transition still applied; task queued at target (AC-48) | +| Decision on file and no resolvable session | AC-24b reports `reevaluation_blocked`; card renders stuck (AC-62, AC-61) | +| Human rejects at a reviewer-guarded step | Veto counts; role filter waived for `decider_type = user` (AC-58, AC-10) | +| Guard's threshold already met at request time | Entry reports `satisfied = true` and omits `reason` (AC-60) | +| One guard stuck, a sibling merely awaiting | Card renders stuck; stuck outranks awaiting (AC-61) | +| Diagnostic `GET /quorum` read | No AC-24 log record, no AC-24a counter increment (AC-24, AC-24a) | +| Engine decision entry point not wired | Both transports error; no row written; no office-side fallback (AC-57c) | +| Task at a step with no guarded transition | AC-24b returns empty list; card aggregates to `clear` (AC-61, AC-24c) | +| Task returns to a guarded step with prior decisions live | AC-56 fails; card is blocked on the `on_enter` card, not patched here | +| AC-24b read while the engine dispatcher is not wired | Endpoint errors; no office-side fallback evaluation (AC-57d, AC-57c) | +| One guard's store read errors, siblings healthy | That entry alone reports `evaluation_error`; the others are still returned (AC-57d, AC-23) | +| Decision recorded at a step that also configures a side-effect action on `on_turn_complete` | Only guarded transitions are evaluated; no callback re-fires (AC-65) | +| Tool called at a step with no guarded transition | `guards` is an empty list, not an error (AC-64) | +| Decision row id needed for the AC-30 operation id | Pre-generated caller-side into `DecisionInfo.ID`; never read back after the write (AC-66) | +| Office needs `decision_id` / `created_at` to publish | Both returned by the AC-57a entry point (AC-57b-i) | + +## Persistence guarantees + +- A decision write and its supersede of the prior verdict are one transaction; a + reader never observes zero non-superseded rows for a participant that has + decided (existing `RecordStepDecision` behavior). +- A recorded decision survives a re-evaluation failure (AC-15). +- Decisions cleared by `clear_decisions` on step re-entry are hard-deleted; + decisions superseded by a new verdict are retained with `superseded_at` set — + the existing split at `phase2_sqlite.go:686-697`. +- Threshold evaluation counts, per AC-50 seat, the LAST + row under the AC-26 ordering — it does not filter on `superseded_at`. + `ListStepDecisions` deliberately returns superseded rows so timelines can + render full history, and the engine's `DecisionInfo` projection carries no + `SupersededAt` field, so the engine could not filter on it even if it wanted + to. Last-row-wins and non-superseded coincide whenever the supersede predicate + and the `participant_id` key agree; AC-44 is what makes them agree, and + without it the two diverge exactly in the multi-row case of AC-19. + +## Out of scope + +Each of the following is a deliberate exclusion, not an oversight. + +- **Workspace `approvals` subsystem.** `POST /approvals/:id/decide` and its + `"approved" | "rejected"` shape (`apps/web/lib/api/domains/office-api.ts:534`) + are a separate feature from task step decisions. Untouched. +- **New thresholds.** `all_approve`, `all_decide`, `any_reject`, + `majority_approve`, `n_approve:` are the complete set. No new threshold. +- **Guard variants beyond `wait_for_quorum`.** `TransitionGuard` stays + single-variant. +- **`on_enter` action dispatch.** Tracked by its own card, and this feature does + not change it. It is a **hard dependency**, not merely an adjacent card: + AC-31's decision-clearing is what stops a rejection recorded at a guarded step + from re-firing `any_reject` every time the task returns there, and the only + `on_enter` trigger dispatch in the tree today is inside + `SwitchWorkflowCallback`. AC-56 turns that dependency into an observable + regression so it fails loudly instead of shipping as an unbounded + reject/return loop. This feature SHALL NOT grow a second decision-clearing + path to compensate. +- **Decision revocation.** A participant may supersede a verdict by recording a + new one; there is no withdraw-to-undecided operation. +- **Timeouts and escalation.** A quorum that is never reached waits + indefinitely. No auto-advance, no reminder, no deadline. +- **Delegating or reassigning a pending decision.** Not addressed. +- **Kanban-workflow decisions.** The decision tool is Office-surface only + (AC-8); Kanban keeps `step_complete_kandev`. +- **Backfilling the zero existing decision rows.** There are none to migrate. +- **Client-supplied idempotency keys on the decision tool.** Excluded by AC-45. + Transition application is idempotent (AC-30); recording is not. +- **Giving the singleton human user a participant row.** AC-43 makes a human + rejection count without one. Seating the user in the slate would change who + `all_approve` waits for on every existing task, which is a larger contract + change than this card carries. +- **The `allApproversApproved` / `pendingApprovers` close gate.** These drive the + `task_ready_to_close` reactivity run and the `ApprovalsPendingError` 409 on + `UpdateTaskStatus`, and they read through the same step-scoped + `office/repository/sqlite` query, so they carry Defect 4's blindness: an + approver attached at an earlier step is invisible to them. Excluded + deliberately, and the consequence is stated rather than hidden — after this + card, quorum can advance a task while that separate gate still blocks its + close on an approver it cannot see. Folding it in would extend AC-50's slate + to a second consumer with different semantics (per-role current-approval, not + threshold arithmetic) on a card that AC-57 has already grown. It needs its own + card. + The residual is sharper than "quorum can advance a task while that gate still + blocks", and is recorded in full so the follow-up card is scoped from fact: a + task whose AC-24b entries all report `satisfied` renders as `clear` under + AC-61 — the healthy rendering — and can still take a 409 + `ApprovalsPendingError` on close, naming an approver that nothing this card + touches can help the operator locate, because that gate reads through the same + step-scoped `ListAllTaskParticipants` query Defect 4 diagnoses. This card + therefore converts one invisible stuck class into one visible-but-confusing + class, which is an improvement but not a completion. The follow-up should be + tracked, not deferred indefinitely. +- **Repointing `resolveDeciderRole` for non-decision callers.** AC-4a fixes role + resolution on the decision path only. Other readers of + `ListAllTaskParticipants` keep today's step-scoped behavior. +- **A generic transition-lock primitive.** AC-48 adds exactly one additive port + method, used only by the AC-46 quorum apply; `ApplyTransition` and every + transition that uses it keep today's semantics, and the compare-and-swap reuses + the transaction and the workspace/step locks + `updateTaskWithWorkflowStepAdmission` already takes. No engine-wide locking + mechanism, and no behavior change to any non-quorum transition. +- **Repointing already-attached participant rows.** AC-18 changes how the slate + is *read*; whether existing rows are also rewritten is an implementation + choice the plan may make either way, provided AC-18–AC-22 hold. + +## E2E surfaces + +User-visible surfaces this touches: + +- Office task detail approval action bar (`approval-action-bar.tsx`) — the + Request-changes path changes behavior under AC-10. +- Office task board — a card at Review now advances on quorum (AC-32, AC-33). +- `GET /api/v1/office/tasks/:id/quorum` — new read endpoint (AC-24b, AC-24c), + projecting the AC-57d engine snapshot. +- Office task detail awaiting-decisions rendering (AC-25), including the + stuck-distinct rendering of `reevaluation_blocked` (AC-62) and the AC-61 + aggregation when two guards disagree. + +E2E recommendation: one Playwright spec driving Work → Review → Approval with a +reviewer and an approver attached, asserting advance-on-approve, return-on-reject, +and the awaiting-decisions presentation of AC-25. The same spec covers AC-56 at +no extra cost: after the return-on-reject leg, drive the task back to the guarded +step and assert it does not immediately bounce again. Backend quorum arithmetic, +vocabulary, and concurrency are Go-test territory, not E2E.