fix(workflow): dispatch on_enter actions for review/approval steps (#2907)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-08-27 22:19:00 +00:00
committed by GitHub
parent 144106c72c
commit 5ae96c324f
29 changed files with 4368 additions and 116 deletions
@@ -11,6 +11,16 @@ func IsPostgres(driver string) bool {
return driver == PGX
}
// AutoIncrementIDColumn returns the dialect-specific column definition for an
// auto-incrementing integer primary key named "id", for use in CREATE TABLE
// statements shared between SQLite and PostgreSQL.
func AutoIncrementIDColumn(driver string) string {
if IsPostgres(driver) {
return "id BIGSERIAL PRIMARY KEY"
}
return "id INTEGER PRIMARY KEY AUTOINCREMENT"
}
// BoolToInt converts a boolean to an integer for SQL storage.
func BoolToInt(value bool) int {
if value {
@@ -24,8 +24,10 @@ import (
"github.com/kandev/kandev/internal/steptelemetry"
"github.com/kandev/kandev/internal/sysprompt"
"github.com/kandev/kandev/internal/task/models"
workflowadapters "github.com/kandev/kandev/internal/workflow/adapters"
"github.com/kandev/kandev/internal/workflow/engine"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/workflow/stepentry"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -1953,7 +1955,14 @@ func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID strin
return fmt.Errorf("load session for on_enter: %w", err)
}
s.processOnEnter(ctx, taskID, session, targetStep, taskDescription)
// entryID 0: this path (manual move / legacy on_turn_start/complete) does
// not attach a step-entry ResultHolder before ApplyTransition, so no
// entry was allocated for this step-entry. processOnEnter's engine-owned
// action cases treat entryID==0 as "not this Build round's dispatch
// path" and skip with a log rather than executing — see
// docs/specs/workflow-on-enter-action-dispatch/spec.md and the task
// plan's scope note for why E2-E5 dispatch is deferred.
s.processOnEnter(ctx, taskID, session, targetStep, taskDescription, 0)
return nil
}
@@ -2360,8 +2369,98 @@ func (s *Service) maybySwitchSessionForProfile(
return effective, true
}
// dispatchOnEnterActions runs each on_enter action declared on step, in
// order, and reports whether an auto_start_agent action was among them.
// Extracted from processOnEnter to keep that function's cognitive
// complexity within the repo's lint threshold.
//
// AC-C2: if clear_decisions fails, no further on_enter action for this step
// entry may execute — stale decision rows plus a fresh participant fan-out
// could otherwise satisfy wait_for_quorum without every current-round
// participant actually voting. The loop stops entirely (not just further
// engine-owned actions) the moment that happens.
//
// AC-F1: a caller that loses a *live* claim on an engine-owned position
// (dispatchEngineOwnedOnEnterAction's abandon=true) must abandon the entry
// the same way — it does not skip to a later position, because that later
// action could execute while the winner is still committing an earlier
// one (e.g. enqueueing queue_run_for_each_participant's fan-out before the
// winner's clear_decisions delete commits). This is a normal outcome, not
// a fault, so the loop stops silently (no WARNING/ERROR) rather than via
// AC-C2's error-logged abort.
type onEnterDispatchResult struct {
hasAutoStart bool
aborted bool
}
func (s *Service) dispatchOnEnterActions(ctx context.Context, taskID string, session *models.TaskSession, step *wfmodels.WorkflowStep, entryID int64, isPassthrough, hasPlanMode bool) onEnterDispatchResult {
result := onEnterDispatchResult{}
dispatchLoop:
for i, action := range step.Events.OnEnter {
switch action.Type {
case wfmodels.OnEnterEnablePlanMode:
// Skip plan mode for passthrough — CLI manages its own state.
// Also skip if agent doesn't support MCP (hasPlanMode is already false above).
if !isPassthrough && hasPlanMode {
s.setSessionPlanMode(ctx, session, true)
}
case wfmodels.OnEnterSetSessionMode:
mode, _ := action.Config["mode"].(string)
s.applyStepSessionMode(ctx, session, mode, isPassthrough)
case wfmodels.OnEnterAutoStartAgent:
result.hasAutoStart = true
case wfmodels.OnEnterResetAgentContext, wfmodels.OnEnterConfigureSession:
// Already handled earlier in processOnEnter (context reset must run
// before auto_start_agent; session config runs right after it).
case wfmodels.OnEnterClearDecisions, wfmodels.OnEnterQueueRunForEachParticipant:
abandon, failed, cause := s.dispatchEngineOwnedOnEnterAction(ctx, taskID, step, action, i, entryID)
if abandon {
s.logger.Debug("processOnEnter: lost a live claim to a concurrent dispatch of this step entry, abandoning remaining on_enter actions",
zap.String("workflow_id", step.WorkflowID),
zap.String("step_id", step.ID),
zap.String("task_id", taskID),
zap.String("action_type", string(action.Type)),
)
result.aborted = true
break dispatchLoop
}
if failed && action.Type == wfmodels.OnEnterClearDecisions {
s.logger.Error("processOnEnter: clear_decisions failed, aborting remaining on_enter actions for step entry",
zap.String("workflow_id", step.WorkflowID),
zap.String("step_id", step.ID),
zap.String("task_id", taskID),
zap.String("cause", cause),
)
result.aborted = true
break dispatchLoop
}
case wfmodels.OnEnterQueueRun, wfmodels.OnEnterRunCodeReview:
// Engine-owned per the spec, but their on_enter dispatch (AC-A7/
// AC-A8) is explicitly deferred to a later Build round — see
// docs/specs/workflow-on-enter-action-dispatch/spec.md. This is a
// known, recognized type, not the AC-A6 default warning case.
default:
// AC-A6: a genuinely unrecognized on_enter action type. Warn
// instead of silently discarding it — this is the exact failure
// mode the step-entry dispatch fix exists to close.
s.logger.Warn("processOnEnter: unrecognized on_enter action type",
zap.String("task_id", taskID),
zap.String("step_id", step.ID),
zap.String("action_type", string(action.Type)),
)
}
}
return result
}
// processOnEnter processes the on_enter events for a step after transitioning to it.
func (s *Service) processOnEnter(ctx context.Context, taskID string, session *models.TaskSession, step *wfmodels.WorkflowStep, taskDescription string) {
// entryID is the workflow_step_entries row allocated for this step-entry by
// the write site that persisted the transition (0 when no entry was
// allocated for this call path — see finalizeStepEnter's call site). Only a
// non-zero entryID lets the engine-owned on_enter cases below dispatch;
// this is this Build round's E1-only scope boundary, not a general
// precondition of the marker CAS mechanism itself.
func (s *Service) processOnEnter(ctx context.Context, taskID string, session *models.TaskSession, step *wfmodels.WorkflowStep, taskDescription string, entryID int64) {
// One GetWorkflowMeta read shared by profile resolution and prompt build.
ctx = withWorkflowMetaCache(ctx)
@@ -2421,23 +2520,28 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo
// auto-start prompt is dispatched. It never switches or creates a tab.
s.applyWorkflowSessionConfigOnEnter(ctx, taskID, session, step)
hasAutoStart := false
for _, action := range step.Events.OnEnter {
switch action.Type {
case wfmodels.OnEnterEnablePlanMode:
// Skip plan mode for passthrough — CLI manages its own state.
// Also skip if agent doesn't support MCP (hasPlanMode is already false above).
if !isPassthrough && hasPlanMode {
s.setSessionPlanMode(ctx, session, true)
}
case wfmodels.OnEnterSetSessionMode:
mode, _ := action.Config["mode"].(string)
s.applyStepSessionMode(ctx, session, mode, isPassthrough)
case wfmodels.OnEnterAutoStartAgent:
hasAutoStart = true
}
dispatchResult := s.dispatchOnEnterActions(ctx, taskID, session, step, entryID, isPassthrough, hasPlanMode)
if dispatchResult.aborted {
return
}
s.launchAfterOnEnterDispatch(ctx, taskID, session, step, taskDescription, hasPlanMode, dispatchResult.hasAutoStart, sessionSwitched)
}
// launchAfterOnEnterDispatch runs the auto-start decision that follows
// on_enter action dispatch: passthrough vs. ACP auto-start, the
// profile-switch fallback launch, and the plain wait-for-input path.
// Extracted from processOnEnter to keep that function's complexity within
// the repo's lint thresholds.
//
//nolint:cyclop,funlen,gocognit // profile-switch recovery has independent terminal and retry branches.
func (s *Service) launchAfterOnEnterDispatch(
ctx context.Context, taskID string, session *models.TaskSession, step *wfmodels.WorkflowStep,
taskDescription string, hasPlanMode, hasAutoStart, sessionSwitched bool,
) {
sessionID := session.ID
isPassthrough := s.agentManager.IsPassthroughSession(ctx, sessionID)
switch {
case hasAutoStart && isPassthrough && session.State != models.TaskSessionStateCreated:
// Started passthrough path: write prompt directly to PTY stdin.
@@ -2605,9 +2709,6 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo
}()
return
}
// Same active-turn guard as the no-on_enter branch above: if the agent
// is still mid-turn, leave state alone so handleAgentReady can run on
// turn end. See that branch for the full rationale.
if session.State == models.TaskSessionStateRunning || session.State == models.TaskSessionStateStarting {
return
}
@@ -2620,6 +2721,126 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo
// from inline processOnEnter.
s.drainQueuedMessageForPromptableSession(ctx, sessionID)
}
}
// dispatchEngineOwnedOnEnterAction executes an engine-owned on_enter action
// once for this step-entry. The marker CAS prevents duplicate execution.
func (s *Service) dispatchEngineOwnedOnEnterAction(
ctx context.Context, taskID string, step *wfmodels.WorkflowStep, action wfmodels.OnEnterAction, position int, entryID int64,
) (abandon, failed bool, cause string) {
if entryID == 0 {
return false, false, ""
}
compiled, ok := engine.CompileOnEnterAction(action)
if !ok {
return false, false, ""
}
callback := s.engineOnEnterCallback(action.Type)
if callback == nil {
return false, false, ""
}
operationID := fmt.Sprintf("step_entry:%d:%d", entryID, position)
claimed, err := s.repo.ClaimStepEntryMarker(ctx, entryID, position, string(action.Type), operationID, time.Now())
if err != nil {
s.logger.Error("processOnEnter: claim step entry marker failed",
zap.Error(err), zap.String("task_id", taskID), zap.String("step_id", step.ID),
zap.Int64("entry_id", entryID), zap.Int("position", position))
return false, true, err.Error()
}
if !claimed {
priorState, priorCause, found, stateErr := s.repo.GetStepEntryMarkerState(ctx, entryID, position)
if stateErr != nil || !found {
return false, false, ""
}
switch priorState {
case stepentry.MarkerFailed:
return false, true, priorCause
case stepentry.MarkerInProgress:
return true, false, ""
default:
return false, false, ""
}
}
if action.Type == wfmodels.OnEnterClearDecisions {
if handled, atomicFailed, atomicCause := s.dispatchClearDecisionsAtomic(ctx, taskID, step, entryID, position); handled {
return false, atomicFailed, atomicCause
}
}
in := engine.ActionInput{
Trigger: engine.TriggerOnEnter,
State: engine.MachineState{TaskID: taskID, CurrentStepID: step.ID, WorkflowID: step.WorkflowID},
Step: engine.StepSpec{ID: step.ID, WorkflowID: step.WorkflowID, Name: step.Name, Position: step.Position},
Action: compiled,
OperationID: operationID,
}
state, execCause := stepentry.MarkerDone, ""
if _, execErr := callback.Execute(ctx, in); execErr != nil {
state, execCause = stepentry.MarkerFailed, execErr.Error()
s.logger.Error("processOnEnter: engine-owned on_enter action failed",
zap.Error(execErr), zap.String("task_id", taskID), zap.String("step_id", step.ID),
zap.String("action_type", string(action.Type)))
}
if err := s.repo.CompleteStepEntryMarker(ctx, entryID, position, state, execCause, time.Now()); err != nil {
s.logger.Error("processOnEnter: complete step entry marker failed",
zap.Error(err), zap.String("task_id", taskID), zap.String("step_id", step.ID),
zap.Int64("entry_id", entryID), zap.Int("position", position))
}
return false, state == stepentry.MarkerFailed, execCause
}
// dispatchClearDecisionsAtomic executes clear_decisions through
// Repository.ClearStepDecisionsAndCompleteMarker (AC-B6) when it is safe to
// do so: only when engineDecisions is confirmed to be
// *workflowadapters.DecisionAdapter, the type SetEngineDecisionStore is
// wired with at the one production construction site
// (backendapp/main.go:1501, NewDecisionAdapter(repos.Workflow) —
// repos.Workflow is always the real workflow repository, sharing s.repo's
// writer *sqlx.DB per backendapp/storage.go). Test doubles
// (fakeDecisionStore, failingDecisionStore in step_entry_dispatch_test.go)
// are not that type — no test constructs a DecisionAdapter — so handled is
// false for them and the caller falls back to the pre-existing, non-atomic
// callback.Execute + CompleteStepEntryMarker sequence, preserving every
// existing AC-C2/AC-D3/AC-D4 test's error-injection behavior unchanged.
//
// When handled is true, failed/cause report the outcome exactly like
// dispatchEngineOwnedOnEnterAction's own return values. On error, the
// transaction rolled back — neither the delete nor the marker update
// committed, so the marker is left in_progress, which is precisely AC-B6's
// invariant (in_progress proves the delete did not commit). No compensating
// write is attempted; returning failed=true is enough to trigger AC-C2's
// abort in dispatchOnEnterActions.
func (s *Service) dispatchClearDecisionsAtomic(
ctx context.Context, taskID string, step *wfmodels.WorkflowStep, entryID int64, position int,
) (handled, failed bool, cause string) {
if _, ok := s.engineDecisions.(*workflowadapters.DecisionAdapter); !ok {
return false, false, ""
}
if _, err := s.repo.ClearStepDecisionsAndCompleteMarker(ctx, taskID, step.ID, entryID, position, time.Now()); err != nil {
s.logger.Error("processOnEnter: atomic clear_decisions failed",
zap.Error(err), zap.String("task_id", taskID), zap.String("step_id", step.ID),
zap.Int64("entry_id", entryID), zap.Int("position", position))
return true, true, err.Error()
}
return true, false, ""
}
// engineOnEnterCallback returns the Phase 2 callback for an engine-owned
// on_enter action kind. The returned callback's Execute reports its own
// ErrActionNotYetWired when the required adapter isn't wired (kanban-only
// deployments) — that surfaces as a failed marker with a clear cause rather
// than a silent no-op, matching AC-A6's "never discard" intent.
func (s *Service) engineOnEnterCallback(t wfmodels.OnEnterActionType) engine.ActionCallback {
switch t {
case wfmodels.OnEnterClearDecisions:
return engine.ClearDecisionsCallback{Decisions: s.engineDecisions}
case wfmodels.OnEnterQueueRunForEachParticipant:
return engine.QueueRunForEachParticipantCallback{Adapter: s.engineRunQueue, Participants: s.engineParticipants}
default:
return nil
}
}
// applyPendingMove applies a deferred move_task_kandev call now that the agent's
@@ -4252,6 +4473,12 @@ func (s *Service) processOnTurnCompleteViaEngineWithCause(
return false
}
unlock, task, proceed := s.acquireTurnCompletionCriticalSection(ctx, taskID, session, task)
defer unlock()
if !proceed {
return false
}
if s.workflowEngine == nil {
return s.processOnTurnCompleteWithCause(ctx, task, session, cause)
}
@@ -4294,6 +4521,91 @@ func (s *Service) processOnTurnCompleteViaEngineWithCause(
return s.applyEngineTransition(ctx, taskID, session, result, engine.TriggerOnTurnComplete, task.Description, true)
}
// acquireTurnCompletionCriticalSection serializes on_turn_complete
// processing per session (see turnCompletionLocks' field comment for the
// double-allocation/double-dispatch race this closes) and detects a
// duplicate call that lost the race for the lock. proceed is false when
// this call must stop and treat itself as a redundant duplicate; the
// caller must still invoke unlock regardless of proceed. session == nil
// callers (none exist today, but the type allows it) skip locking
// entirely, matching pre-fix behavior.
//
// Two independent duplicate signals are checked, because neither alone
// covers every case:
//
// 1. Lock contention (acquireTurnCompletionLock's contended return).
// Several independent event sources (normal agent turn completion,
// agent-exit, step_complete_kandev's out-of-band signal, user
// cancellation) can each call this for the same session; if this call
// had to wait for another one already in flight for the same session,
// that other call is processing (or has just finished processing) the
// same physical turn, so this one backs off unconditionally — no DB
// read is needed or even safe to reason from, since a call that lost
// the lock race has no reliable way to tell whether the winner's
// commit is what it would observe. This is the only signal that
// reliably catches a duplicate whose entire pre-lock read happens
// *after* the winner already committed: e.g. two concurrent normal
// agent-turn-completion signals for the same physical turn (one via
// the ready path, one via agent-exit), where the second caller's very
// first DB read can already reflect the first caller's full commit,
// making any comparison of "my early read" vs. "my read under the
// lock" pass trivially even though the two calls are genuine
// duplicates. Contention can only be true when two calls for the same
// session truly overlap in time, so it never misfires against a
// legitimate call that arrives well after a prior one has finished
// (cancellation reconciliation, onStepCompletionSignaled's retry,
// clarification-dismissal) — those find the lock free.
// 2. The WorkflowStepID comparison (observedTask vs. a fresh read taken
// after acquiring the lock without contention). This catches a
// duplicate whose pre-lock snapshot predates the winner's commit: the
// step moved out from under it while it waited for the (uncontended,
// from this call's perspective) lock.
// 3. The session-generation comparison (turnCompletionConsumedGeneration):
// closes the one gap the first two leave open. When two calls for the
// same session are fully sequential — no true overlap, so contention
// never fires — the second caller's own pre-lock GetTask already
// reflects the first caller's commit, so signal 2 also passes
// trivially: current and observedTask agree, just both already at the
// winner's destination. Nothing in either DB read distinguishes that
// from a legitimate, later call that happens to arrive once the
// session already sits at that step. What does distinguish them is
// the caller-supplied session snapshot's UpdatedAt: a genuinely new
// call only exists because something touched the session after the
// winner's transition committed (a new turn starting, cancellation
// reconciliation, ...), which bumps UpdatedAt past the generation the
// winner recorded. A duplicate redelivery carries the same (or an
// older) snapshot it was constructed from before that commit, so its
// UpdatedAt is not strictly newer, and it is rejected here instead of
// being evaluated against the step the winner already moved to.
func (s *Service) acquireTurnCompletionCriticalSection(
ctx context.Context, taskID string, session *models.TaskSession, observedTask *models.Task,
) (unlock func(), fresh *models.Task, proceed bool) {
if session == nil {
return func() {}, observedTask, true
}
unlock, contended := s.acquireTurnCompletionLock(session.ID)
if contended {
return unlock, observedTask, false
}
if lastConsumed, ok := s.turnCompletionConsumedGeneration.Load(session.ID); ok {
if !session.UpdatedAt.After(lastConsumed.(time.Time)) {
return unlock, observedTask, false
}
}
current, err := s.repo.GetTask(ctx, taskID)
if err != nil {
// Best-effort re-check: fall back to the already-validated snapshot
// rather than fail a call that succeeded its primary read.
s.turnCompletionConsumedGeneration.Store(session.ID, session.UpdatedAt)
return unlock, observedTask, true
}
if current.WorkflowStepID != observedTask.WorkflowStepID {
return unlock, current, false
}
s.turnCompletionConsumedGeneration.Store(session.ID, session.UpdatedAt)
return unlock, current, true
}
func (s *Service) prepareEngineTurnCompletion(
ctx context.Context,
taskID string,
@@ -4527,7 +4839,18 @@ func (s *Service) applyEngineTransitionWithCommit(
s.processOnExit(ctx, taskID, session, fromStep)
}
applied, err := commit(ctx)
// A ResultHolder is only attached when this transition will actually
// trigger on_enter (triggerOnEnter) — an on_turn_start transition never
// reaches launchProcessOnEnter below, so allocating a step-entry it will
// never dispatch would be a needless write. See applyTransition's
// matching gate in workflow_store.go.
var stepEntry *stepentry.AllocationResult
applyCtx := ctx
if triggerOnEnter {
stepEntry = &stepentry.AllocationResult{}
applyCtx = stepentry.WithResultHolder(applyCtx, stepEntry)
}
applied, err := commit(applyCtx)
if err != nil {
s.logger.Error("failed to apply engine transition",
zap.String("task_id", taskID),
@@ -4629,7 +4952,11 @@ func (s *Service) applyEngineTransitionWithCommit(
// ResetAgentContext → sendStreamRequest, which blocks G_reader waiting for a response
// that can only be delivered by G_reader reading from the same WebSocket — a deadlock.
// The DB transition is already persisted above, so it's safe to process on_enter async.
s.launchProcessOnEnter(context.WithoutCancel(ctx), taskID, session, targetStep, taskDescription)
var stepEntryID int64
if stepEntry != nil {
stepEntryID = stepEntry.EntryID
}
s.launchProcessOnEnter(context.WithoutCancel(ctx), taskID, session, targetStep, taskDescription, stepEntryID)
return true
}
@@ -4639,6 +4966,7 @@ func (s *Service) launchProcessOnEnter(
session *models.TaskSession,
targetStep *wfmodels.WorkflowStep,
taskDescription string,
entryID int64,
) {
go func() {
defer func() {
@@ -4646,7 +4974,7 @@ func (s *Service) launchProcessOnEnter(
s.onProcessOnEnterComplete()
}
}()
s.processOnEnter(ctx, taskID, session, targetStep, taskDescription)
s.processOnEnter(ctx, taskID, session, targetStep, taskDescription, entryID)
}()
}
@@ -49,7 +49,7 @@ func TestProcessOnEnterResetAgentContext_ClearsLazyResumeTokenWithoutLiveExecuti
t.Fatalf("load session: %v", err)
}
svc.processOnEnter(ctx, "task-lazy-resume", session, step, "review task")
svc.processOnEnter(ctx, "task-lazy-resume", session, step, "review task", 0)
if len(agentManager.restartProcessCalls) != 0 {
t.Fatalf("expected no reset against a missing live execution, got %d calls", len(agentManager.restartProcessCalls))
@@ -0,0 +1,83 @@
package orchestrator
import (
"context"
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/models"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
)
// TestProcessOnEnter_UnrecognizedActionType_WarnsAndContinuesDispatch covers
// AC-A6 — the bug report's own headline acceptance bullet: "An on_enter
// action the handler does not recognise emits a WARNING naming the step and
// action type. The silent discard is what made this take a full afternoon
// to find." Build round 1 (commit d4c3dd42a) added the default-branch
// warning in dispatchOnEnterActions but shipped with zero test coverage for
// it; Testing round 1 verified the behavior with an uncommitted throwaway
// probe and routed the card back for exactly this permanent test.
func TestProcessOnEnter_UnrecognizedActionType_WarnsAndContinuesDispatch(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "task-a6", "session-a6", "step-a6")
session, err := repo.GetTaskSession(ctx, "session-a6")
if err != nil {
t.Fatalf("load session: %v", err)
}
agent := &mockAgentManager{isAgentRunning: true}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agent)
core, logs := observer.New(zapcore.WarnLevel)
log, err := logger.NewFromZap(zap.New(core))
if err != nil {
t.Fatalf("build observed logger: %v", err)
}
svc.logger = log
// A genuinely unrecognized action type followed by a real one: the
// warning must not abort the loop, so set_session_mode must still run
// and persist.
step := &wfmodels.WorkflowStep{
ID: "step-a6",
WorkflowID: "wf-a6",
Name: "AC-A6 Step",
Events: wfmodels.StepEvents{OnEnter: []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterActionType("totally_bogus_action")},
{Type: wfmodels.OnEnterSetSessionMode, Config: map[string]interface{}{"mode": "acceptEdits"}},
}},
}
svc.processOnEnter(ctx, "task-a6", session, step, "", 0)
var warnings []observer.LoggedEntry
for _, e := range logs.All() {
if e.Message == "processOnEnter: unrecognized on_enter action type" {
warnings = append(warnings, e)
}
}
if len(warnings) != 1 {
t.Fatalf("got %d WARNING(s) for the unrecognized action, want exactly 1 (all entries: %+v)", len(warnings), logs.All())
}
fields := warnings[0].ContextMap()
if fields["step_id"] != "step-a6" {
t.Errorf("warning step_id = %v, want %q", fields["step_id"], "step-a6")
}
if fields["action_type"] != "totally_bogus_action" {
t.Errorf("warning action_type = %v, want %q", fields["action_type"], "totally_bogus_action")
}
reloaded, err := repo.GetTaskSession(ctx, "session-a6")
if err != nil {
t.Fatalf("reload session: %v", err)
}
if got := reloaded.Metadata[models.SessionMetaKeySessionMode]; got != "acceptEdits" {
t.Errorf("set_session_mode after the unrecognized action = %v, want %q — the warning must not abort dispatch of subsequent actions", got, "acceptEdits")
}
}
@@ -1416,7 +1416,7 @@ func TestProcessOnEnter_ProfileSwitch(t *testing.T) {
scheduler: sched,
}
svc.processOnEnter(ctx, "t1", session, step, "desc")
svc.processOnEnter(ctx, "t1", session, step, "desc", 0)
// The old session should be completed
oldSession, err := repo.GetTaskSession(ctx, "s1")
@@ -1484,7 +1484,7 @@ func TestProcessOnEnter_ProfileSwitch(t *testing.T) {
sg.steps["step1"] = step
svc := createTestService(repo, sg, newMockTaskRepo())
svc.processOnEnter(ctx, "t1", session, step, "desc")
svc.processOnEnter(ctx, "t1", session, step, "desc", 0)
// Session should remain running (not completed)
updatedSession, err := repo.GetTaskSession(ctx, "s1")
@@ -1548,7 +1548,7 @@ func TestProcessOnEnter_ProfileSwitch(t *testing.T) {
agentMgr := &mockAgentManager{isPassthrough: true}
svc := createTestServiceWithAgent(repo, sg, newMockTaskRepo(), agentMgr)
svc.processOnEnter(ctx, "t1", session, step, "desc")
svc.processOnEnter(ctx, "t1", session, step, "desc", 0)
// Session should NOT be completed (passthrough skips profile switch)
updatedSession, err := repo.GetTaskSession(ctx, "s1")
@@ -1596,7 +1596,7 @@ func TestProcessOnEnter_ProfileSwitch(t *testing.T) {
sg.steps["step1"] = step
svc := createTestService(repo, sg, newMockTaskRepo())
svc.processOnEnter(ctx, "t1", session, step, "desc")
svc.processOnEnter(ctx, "t1", session, step, "desc", 0)
// Session should remain running
sessions, err := repo.ListTaskSessions(ctx, "t1")
@@ -1657,7 +1657,7 @@ func TestProcessOnEnter_ProfileSwitch(t *testing.T) {
sg.steps["step1"] = step
svc := createTestService(repo, sg, newMockTaskRepo())
svc.processOnEnter(ctx, "t1", session, step, "desc")
svc.processOnEnter(ctx, "t1", session, step, "desc", 0)
// Critical: no new profile-a session should be spawned, and the
// user-chosen profile-b session must NOT be marked COMPLETED.
@@ -1744,7 +1744,7 @@ func TestProcessOnEnter_ProfileSwitch(t *testing.T) {
scheduler: sched,
}
svc.processOnEnter(ctx, "t1", session, step, "desc")
svc.processOnEnter(ctx, "t1", session, step, "desc", 0)
updated, err := repo.GetTaskSession(ctx, "s1")
if err != nil {
@@ -459,7 +459,7 @@ func testProcessOnEnterTerminalizedAfterPromptClaim(t *testing.T, terminalState
done := make(chan struct{})
go func() {
svc.processOnEnter(ctx, source.TaskID, source, step, "Test")
svc.processOnEnter(ctx, source.TaskID, source, step, "Test", 0)
close(done)
}()
@@ -756,7 +756,7 @@ func testProcessOnEnterImplicitProfileSwitchTerminalizedGuard(
done := make(chan struct{})
go func() {
svc.processOnEnter(ctx, source.TaskID, source, step, "Test")
svc.processOnEnter(ctx, source.TaskID, source, step, "Test", 0)
close(done)
}()
@@ -31,7 +31,7 @@ func TestProcessOnEnterConfigureSessionAppliesMatchingOriginalSession(t *testing
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agent)
step := configureSessionStep("step-config", "codex", "set", "gpt-5.6-luna", map[string]string{"reasoning_effort": "max"})
svc.processOnEnter(ctx, "task-config", session, step, "")
svc.processOnEnter(ctx, "task-config", session, step, "", 0)
if len(agent.setSessionModelCalls) != 1 || agent.setSessionModelCalls[0].ModelID != "gpt-5.6-luna" {
t.Fatalf("model calls = %#v, want one luna switch", agent.setSessionModelCalls)
@@ -70,7 +70,7 @@ func TestProcessOnEnterConfigureSessionSkipsNonMatchingAgent(t *testing.T) {
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agent)
step := configureSessionStep("step-config-skip", "codex", "set", "gpt-5.6-luna", map[string]string{"reasoning_effort": "max"})
svc.processOnEnter(ctx, "task-config-skip", session, step, "")
svc.processOnEnter(ctx, "task-config-skip", session, step, "", 0)
if len(agent.setSessionModelCalls) != 0 || len(agent.setSessionConfigCalls) != 0 {
t.Fatalf("non-matching agent was configured: model=%#v config=%#v", agent.setSessionModelCalls, agent.setSessionConfigCalls)
@@ -104,7 +104,7 @@ func TestProcessOnEnterConfigureSessionRestoreOriginal(t *testing.T) {
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agent)
step := configureSessionStep("step-config-restore", "claude", "restore_original", "", nil)
svc.processOnEnter(ctx, "task-config-restore", session, step, "")
svc.processOnEnter(ctx, "task-config-restore", session, step, "", 0)
if len(agent.setSessionModelCalls) != 1 || agent.setSessionModelCalls[0].ModelID != "claude-sonnet-original" {
t.Fatalf("restore model calls = %#v", agent.setSessionModelCalls)
@@ -175,7 +175,7 @@ func TestProcessOnEnterConfigureSessionMatchesAgentDisplayName(t *testing.T) {
svc.messageCreator = messages
step := configureSessionStep("step-config-display", "Claude", "set", "opus[1m]", nil)
svc.processOnEnter(ctx, "task-config-display", session, step, "")
svc.processOnEnter(ctx, "task-config-display", session, step, "", 0)
if len(agent.setSessionModelCalls) != 1 || agent.setSessionModelCalls[0].ModelID != "opus[1m]" {
t.Fatalf("model calls = %#v, want one opus[1m] switch", agent.setSessionModelCalls)
@@ -217,7 +217,7 @@ func TestProcessOnEnterConfigureSessionUnknownAgentFamilyWarns(t *testing.T) {
svc.messageCreator = messages
step := configureSessionStep("step-config-unknown", "grok-9000", "set", "opus[1m]", nil)
svc.processOnEnter(ctx, "task-config-unknown", session, step, "")
svc.processOnEnter(ctx, "task-config-unknown", session, step, "", 0)
if len(agent.setSessionModelCalls) != 0 {
t.Fatalf("unknown family was configured: %#v", agent.setSessionModelCalls)
@@ -251,7 +251,7 @@ func TestProcessOnEnterConfigureSessionKnownOtherAgentStaysSilent(t *testing.T)
svc.messageCreator = messages
step := configureSessionStep("step-config-other", "Codex", "set", "gpt-5.6-luna", nil)
svc.processOnEnter(ctx, "task-config-other", session, step, "")
svc.processOnEnter(ctx, "task-config-other", session, step, "", 0)
if len(agent.setSessionModelCalls) != 0 {
t.Fatalf("non-matching agent was configured: %#v", agent.setSessionModelCalls)
@@ -324,7 +324,7 @@ func TestProcessOnEnterConfigureSessionWithoutResolverRequiresExactAgentName(t *
svc.agentFamilyResolver = nil
svc.processOnEnter(ctx, "task-config-noresolver", session,
configureSessionStep("step-config-noresolver", "Claude", "set", "opus[1m]", nil), "")
configureSessionStep("step-config-noresolver", "Claude", "set", "opus[1m]", nil), "", 0)
if len(agent.setSessionModelCalls) != 0 {
t.Fatalf("display name matched without a resolver: %#v", agent.setSessionModelCalls)
}
@@ -335,7 +335,7 @@ func TestProcessOnEnterConfigureSessionWithoutResolverRequiresExactAgentName(t *
}
svc.processOnEnter(ctx, "task-config-noresolver", session,
configureSessionStep("step-config-noresolver", "claude-acp", "set", "opus[1m]", nil), "")
configureSessionStep("step-config-noresolver", "claude-acp", "set", "opus[1m]", nil), "", 0)
if len(agent.setSessionModelCalls) != 1 || agent.setSessionModelCalls[0].ModelID != "opus[1m]" {
t.Fatalf("exact agent name did not match without a resolver: %#v", agent.setSessionModelCalls)
}
@@ -461,7 +461,7 @@ func TestProcessOnEnterConfigureSessionAmbiguousAgentFamilyWarns(t *testing.T) {
svc.agentFamilyResolver = shadowed
svc.processOnEnter(ctx, "task-ambiguous", session,
configureSessionStep("step-ambiguous", "Claude", "set", "opus[1m]", nil), "")
configureSessionStep("step-ambiguous", "Claude", "set", "opus[1m]", nil), "", 0)
if len(agent.setSessionModelCalls) != 0 {
t.Fatalf("ambiguous family was applied to an agent: %#v", agent.setSessionModelCalls)
@@ -503,7 +503,7 @@ func TestProcessOnEnterConfigureSessionUnrelatedAmbiguousRuleStillApplies(t *tes
svc.processOnEnter(ctx, "task-unrelated", session, multiRuleConfigureSessionStep("step-unrelated",
configureSessionRule{agentName: "Codex", model: "gpt-5.6-luna"},
configureSessionRule{agentName: "Claude", model: "opus[1m]"},
), "")
), "", 0)
if len(agent.setSessionModelCalls) != 1 || agent.setSessionModelCalls[0].ModelID != "gpt-5.6-luna" {
t.Fatalf("model calls = %#v, want one gpt-5.6-luna switch", agent.setSessionModelCalls)
@@ -567,7 +567,7 @@ func TestProcessOnEnterConfigureSessionRelevantAmbiguityRefusesDespiteExactRule(
svc.agentFamilyResolver = shadowed
svc.processOnEnter(ctx, "task-relevant", session,
multiRuleConfigureSessionStep("step-relevant", tc.rules...), "")
multiRuleConfigureSessionStep("step-relevant", tc.rules...), "", 0)
if len(agent.setSessionModelCalls) != 0 {
t.Fatalf("relevant ambiguity applied a model: %#v", agent.setSessionModelCalls)
@@ -610,7 +610,7 @@ func TestProcessOnEnterConfigureSessionAmbiguousSessionFamilyRefuses(t *testing.
// The rule names one agent unambiguously; the session is the ambiguous side.
svc.processOnEnter(ctx, "task-session-ambiguous", session,
configureSessionStep("step-session-ambiguous", "claude-acp", "set", "opus[1m]", nil), "")
configureSessionStep("step-session-ambiguous", "claude-acp", "set", "opus[1m]", nil), "", 0)
if len(agent.setSessionModelCalls) != 0 {
t.Fatalf("ambiguous session family applied a model: %#v", agent.setSessionModelCalls)
@@ -640,7 +640,7 @@ func TestProcessOnEnterConfigureSessionConflictingRulesWarn(t *testing.T) {
svc.processOnEnter(ctx, "task-conflict", session, multiRuleConfigureSessionStep("step-conflict",
configureSessionRule{agentName: "Claude", model: "opus[1m]"},
configureSessionRule{agentName: "claude-acp", model: "sonnet"},
), "")
), "", 0)
if len(agent.setSessionModelCalls) != 0 {
t.Fatalf("conflicting rules applied a model: %#v", agent.setSessionModelCalls)
@@ -669,7 +669,7 @@ func TestProcessOnEnterConfigureSessionDistinctFamiliesStillApply(t *testing.T)
configureSessionRule{agentName: "Codex", model: "gpt-5.6-luna"},
configureSessionRule{agentName: "Claude", model: "opus[1m]"},
configureSessionRule{agentName: "Gemini", model: "gemini-pro"},
), "")
), "", 0)
if len(agent.setSessionModelCalls) != 1 || agent.setSessionModelCalls[0].ModelID != "opus[1m]" {
t.Fatalf("model calls = %#v, want one opus[1m] switch", agent.setSessionModelCalls)
@@ -340,7 +340,7 @@ func TestProcessOnEnterSetSessionMode(t *testing.T) {
}
session, _ := repo.GetTaskSession(ctx, "s1")
svc.processOnEnter(ctx, "t1", session, step, "test task")
svc.processOnEnter(ctx, "t1", session, step, "test task", 0)
updated, _ := repo.GetTaskSession(ctx, "s1")
if got, _ := updated.Metadata[models.SessionMetaKeySessionMode].(string); got != "acceptEdits" {
@@ -374,7 +374,7 @@ func TestProcessOnEnter(t *testing.T) {
}
session, _ := repo.GetTaskSession(ctx, "s1")
svc.processOnEnter(ctx, "t1", session, step, "test task")
svc.processOnEnter(ctx, "t1", session, step, "test task", 0)
session, _ = repo.GetTaskSession(ctx, "s1")
if session.Metadata == nil {
@@ -403,7 +403,7 @@ func TestProcessOnEnter(t *testing.T) {
}
session, _ = repo.GetTaskSession(ctx, "s1")
svc.processOnEnter(ctx, "t1", session, step, "test task")
svc.processOnEnter(ctx, "t1", session, step, "test task", 0)
// Plan mode should persist — only explicit on_exit/on_turn_complete
// disable_plan_mode actions should clear it.
@@ -445,7 +445,7 @@ func TestProcessOnEnter(t *testing.T) {
}
session, _ = repo.GetTaskSession(ctx, "s1")
svc.processOnEnter(ctx, "t1", session, reviewStep, "task description")
svc.processOnEnter(ctx, "t1", session, reviewStep, "task description", 0)
select {
case <-agentMgr.promptDone:
@@ -492,7 +492,7 @@ func TestProcessOnEnter(t *testing.T) {
}
session, _ := repo.GetTaskSession(ctx, "s1")
svc.processOnEnter(ctx, "t1", session, step, "queued prompt content")
svc.processOnEnter(ctx, "t1", session, step, "queued prompt content", 0)
deadline := time.Now().Add(2 * time.Second)
for {
@@ -744,7 +744,7 @@ func TestProcessOnEnterPassthrough(t *testing.T) {
}
session, _ := repo.GetTaskSession(ctx, "s1")
svc.processOnEnter(ctx, "t1", session, step, "test task")
svc.processOnEnter(ctx, "t1", session, step, "test task", 0)
session, _ = repo.GetTaskSession(ctx, "s1")
if session.Metadata != nil {
@@ -771,7 +771,7 @@ func TestProcessOnEnterPassthrough(t *testing.T) {
}
session, _ = repo.GetTaskSession(ctx, "s1")
svc.processOnEnter(ctx, "t1", session, step, "test task")
svc.processOnEnter(ctx, "t1", session, step, "test task", 0)
// plan_mode should still be set since passthrough sessions skip plan mode management
updated, _ := repo.GetTaskSession(ctx, "s1")
@@ -849,7 +849,7 @@ func TestProcessOnEnterResetAgentContext(t *testing.T) {
}
session, _ = repo.GetTaskSession(ctx, "s1")
svc.processOnEnter(ctx, "t1", session, step, "review task")
svc.processOnEnter(ctx, "t1", session, step, "review task", 0)
published := eventBus.published()
if len(published) < 2 {
@@ -960,7 +960,7 @@ func TestProcessOnEnterResetAgentContext(t *testing.T) {
}
session, _ = repo.GetTaskSession(ctx, "s1")
svc.processOnEnter(ctx, "t1", session, step, "review task")
svc.processOnEnter(ctx, "t1", session, step, "review task", 0)
// Verify RestartAgentProcess was called with the correct execution ID
if len(agentMgr.restartProcessCalls) != 1 {
@@ -996,7 +996,7 @@ func TestProcessOnEnterResetAgentContext(t *testing.T) {
}
session, _ := repo.GetTaskSession(ctx, "s1")
svc.processOnEnter(ctx, "t1", session, step, "review task")
svc.processOnEnter(ctx, "t1", session, step, "review task", 0)
// Verify RestartAgentProcess was NOT called (no execution ID)
if len(agentMgr.restartProcessCalls) != 0 {
@@ -1135,7 +1135,7 @@ func TestProcessOnEnterResetAgentContext(t *testing.T) {
}
session, _ = repo.GetTaskSession(ctx, "s1")
svc.processOnEnter(ctx, "t1", session, step, "Run the work")
svc.processOnEnter(ctx, "t1", session, step, "Run the work", 0)
select {
case <-startAgentProcessCalled:
case <-time.After(time.Second):
@@ -1177,7 +1177,7 @@ func TestProcessOnEnterResetAgentContext(t *testing.T) {
}
session, _ = repo.GetTaskSession(ctx, "s1")
svc.processOnEnter(ctx, "t1", session, step, "review task")
svc.processOnEnter(ctx, "t1", session, step, "review task", 0)
// Verify RestartAgentProcess was called even for passthrough sessions
if len(agentMgr.restartProcessCalls) != 1 {
@@ -1211,7 +1211,7 @@ func TestProcessOnEnterResetAgentContext(t *testing.T) {
}
session, _ = repo.GetTaskSession(ctx, "s1")
svc.processOnEnter(ctx, "t1", session, step, "review task")
svc.processOnEnter(ctx, "t1", session, step, "review task", 0)
updated, _ := repo.GetTaskSession(ctx, "s1")
if updated.State != models.TaskSessionStateWaitingForInput {
@@ -1255,7 +1255,7 @@ func TestProcessOnEnterResetAgentContext(t *testing.T) {
t.Fatalf("precondition: seed should start session as RUNNING, got %q", session.State)
}
svc.processOnEnter(ctx, "t1", session, step, "review task")
svc.processOnEnter(ctx, "t1", session, step, "review task", 0)
// In-memory session must also be updated — queueAutoStartPromptIfRunning
// (further down in processOnEnter when auto_start_agent is present)
@@ -1304,7 +1304,7 @@ func TestProcessOnEnterResetAgentContext(t *testing.T) {
}
session, _ = repo.GetTaskSession(ctx, "s1")
svc.processOnEnter(ctx, "t1", session, step, "review task")
svc.processOnEnter(ctx, "t1", session, step, "review task", 0)
// Positive assertion: pin the expected state to RUNNING so any other
// unintended mutation (COMPLETED, FAILED, etc.) also fails the test,
@@ -37,6 +37,7 @@ import (
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/internal/workflow/engine"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/workflow/stepentry"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -348,6 +349,22 @@ type sessionExecutorStore interface {
GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error)
CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error
UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error
// Step-entry CAS markers (see internal/workflow/stepentry) — claim/complete
// an engine-owned on_enter action at most once per step-entry.
ClaimStepEntryMarker(ctx context.Context, entryID int64, position int, kind, operationID string, claimedAt time.Time) (bool, error)
CompleteStepEntryMarker(ctx context.Context, entryID int64, position int, state stepentry.MarkerState, cause string, completedAt time.Time) error
// GetStepEntryMarkerState reads back a marker's terminal (or in-progress)
// state after a lost CAS claim, so the caller can tell "already failed"
// apart from "already done" or "still in progress elsewhere."
GetStepEntryMarkerState(ctx context.Context, entryID int64, position int) (state stepentry.MarkerState, cause string, found bool, err error)
// ClearStepDecisionsAndCompleteMarker satisfies AC-B6: clears every
// decision for (taskID, stepID) and completes the clear_decisions
// marker in one database transaction, so a crash between the two can
// never leave the marker showing in_progress after the delete already
// committed. Only usable when the DecisionStore backing clear_decisions
// shares this repository's writer DB — see the capability check at
// dispatchClearDecisionsAtomic's call site.
ClearStepDecisionsAndCompleteMarker(ctx context.Context, taskID, stepID string, entryID int64, position int, now time.Time) (int64, error)
}
// ClaimTaskTitleSession claims the first-turn generated-title handoff for a
@@ -907,6 +924,33 @@ type Service struct {
// Entries are not deleted: deleting a lock can let a new caller create a
// second mutex while an existing waiter still owns the old one.
sessionLifecycleLocks sync.Map // map[sessionID]*sync.Mutex
// turnCompletionLocks serializes processOnTurnCompleteViaEngineWithCause
// per session. Several independent event sources (normal agent turn
// completion, agent-exit, step_complete_kandev's out-of-band signal,
// user cancellation) can each call it for the same session; without
// serialization, two concurrent calls can both read the same
// pre-transition engine state and each call applyEngineTransition,
// allocating two independent workflow_step_entries rows for what should
// be a single logical transition and double-dispatching that entry's
// on_enter actions (clear_decisions, queue_run_for_each_participant).
// Entries are not deleted — see sessionLifecycleLocks above for why.
turnCompletionLocks sync.Map // map[sessionID]*sync.Mutex
// turnCompletionConsumedGeneration records, per session, the
// TaskSession.UpdatedAt of the most recent session snapshot that was
// already let past acquireTurnCompletionCriticalSection. It closes the
// gap turnCompletionLocks' contention signal cannot: two calls whose
// entire lifetimes are fully sequential (the second's own pre-lock
// GetTask already reflects the first's commit, so the WorkflowStepID
// comparison passes trivially) never contend on the mutex at all. A
// caller's session argument only carries a newer UpdatedAt than the
// stored generation when something legitimately touched the session
// (a new turn starting, cancellation reconciliation, ...) after the
// last transition was consumed; a stale or identical snapshot — the
// hallmark of a duplicate delivery for the same physical turn — is
// rejected instead of being re-evaluated against the step the winner
// already moved to. See acquireTurnCompletionCriticalSection.
// Entries are not deleted — see sessionLifecycleLocks above for why.
turnCompletionConsumedGeneration sync.Map // map[sessionID]time.Time
// contextWindowGuards serializes live context usage writes and gives each
// session a generation boundary that reset_agent_context can invalidate.
contextWindowGuards sync.Map
@@ -2269,6 +2313,32 @@ func (s *Service) acquireSessionLifecycleLock(sessionID string) func() {
return lock.Unlock
}
// acquireTurnCompletionLock serializes on_turn_complete processing for a
// single session — see turnCompletionLocks' field comment for the race it
// closes. A caller with no session ID (defensive callers pass "" rather than
// skip the lock call) gets a no-op unlock and contended=false.
//
// contended reports whether another caller already held this session's lock
// at the moment this call tried to acquire it — i.e. another
// processOnTurnCompleteViaEngineWithCause call for the same session is (or
// very recently was) actively executing right now. That is a direct,
// timing-independent duplicate signal: unlike comparing two DB reads taken
// at different times (which a sufficiently late duplicate can pass
// trivially — see acquireTurnCompletionCriticalSection), lock contention can
// only be true when two calls for the same session genuinely overlapped.
func (s *Service) acquireTurnCompletionLock(sessionID string) (unlock func(), contended bool) {
if sessionID == "" {
return func() {}, false
}
value, _ := s.turnCompletionLocks.LoadOrStore(sessionID, &sync.Mutex{})
lock := value.(*sync.Mutex)
if lock.TryLock() {
return lock.Unlock, false
}
lock.Lock()
return lock.Unlock, true
}
func (s *Service) isSessionResetInProgress(sessionID string) bool {
if sessionID == "" {
return false
@@ -0,0 +1,155 @@
package orchestrator
// step_entry_dispatch_atomic_test.go covers AC-B6: when engineDecisions is
// wired the way production actually wires it — workflowadapters.NewDecisionAdapter
// over the real workflow repository, sharing the same writer DB as the task
// repository (backendapp/main.go:1501, backendapp/storage.go) — clear_decisions
// must go through dispatchClearDecisionsAtomic's one-transaction path, not the
// generic callback.Execute + CompleteStepEntryMarker sequence the other
// step_entry_dispatch_test.go fixtures exercise with a fakeDecisionStore.
//
// This test cannot observe the transaction boundary directly (that is
// step_entries_test.go's job, at the repository layer), but it does prove the
// capability-detection wiring actually engages against a real
// *workflowadapters.DecisionAdapter — a wrong type assertion, a mismatched
// entryID/position, or a step_id mixup would surface here as decisions never
// clearing or the marker never completing, exactly like it would in
// production.
import (
"context"
"path/filepath"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/task/repository"
workflowadapters "github.com/kandev/kandev/internal/workflow/adapters"
"github.com/kandev/kandev/internal/workflow/engine"
workflowrepository "github.com/kandev/kandev/internal/workflow/repository"
"github.com/kandev/kandev/internal/workflow/stepentry"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// newSharedDBReviewLoopFixture is newReviewLoopFixture's sibling: it wires
// svc.engineDecisions to a real *workflowadapters.DecisionAdapter backed by a
// real *workflowrepository.Repository sharing the task repository's writer
// *sqlx.DB, matching production's backendapp/storage.go wiring exactly.
func newSharedDBReviewLoopFixture(t *testing.T) *reviewLoopFixture {
t.Helper()
sg, nameToID := buildWorkflowFromJSON(t, reviewLoopWorkflowJSON)
tmpDir := t.TempDir()
dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("open test database: %v", err)
}
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
t.Cleanup(func() { _ = sqlxDB.Close() })
taskRepo, cleanup, err := repository.Provide(sqlxDB, sqlxDB, nil)
if err != nil {
t.Fatalf("create task repository: %v", err)
}
t.Cleanup(func() { _ = cleanup() })
workflowRepo, err := workflowrepository.NewWithDB(sqlxDB, sqlxDB, nil)
if err != nil {
t.Fatalf("create workflow repository: %v", err)
}
seedSession(t, taskRepo, "t1", "s1", nameToID["Work"])
setSessionExecID(t, taskRepo, "s1", "exec-1")
agentMgr := &mockAgentManager{repoForExecutionLookup: taskRepo, isAgentRunning: true}
svc := createEngineService(t, taskRepo, sg, agentMgr)
runQueue := &stepEntryFakeRunQueue{}
participants := &fakeParticipantStore{participants: []engine.ParticipantInfo{
{ID: "participant-1", StepID: nameToID["Review"], Role: "reviewer", AgentProfileID: "agent-reviewer-1"},
}}
svc.SetEngineRunQueue(runQueue)
svc.SetEngineParticipantStore(participants)
svc.SetEngineDecisionStore(workflowadapters.NewDecisionAdapter(workflowRepo))
mockRepo := svc.taskRepo.(*mockTaskRepo)
seedMockTaskState(mockRepo, "t1", v1.TaskStateInProgress)
return &reviewLoopFixture{
svc: svc, repo: taskRepo, agentMgr: agentMgr,
runQueue: runQueue, nameToID: nameToID, taskRepoMk: mockRepo,
}
}
// TestProcessOnEnter_ClearDecisions_RealDecisionAdapter_ClearsAndCompletesAtomically
// covers AC-B6 end to end: with engineDecisions wired as the real production
// adapter, an existing decision from a prior round is cleared and the
// clear_decisions marker lands done — the same observable outcome the
// fallback path produces, but now driven through
// Repository.ClearStepDecisionsAndCompleteMarker's single transaction rather
// than two independent calls.
func TestProcessOnEnter_ClearDecisions_RealDecisionAdapter_ClearsAndCompletesAtomically(t *testing.T) {
ctx := context.Background()
f := newSharedDBReviewLoopFixture(t)
reviewStepID := f.nameToID["Review"]
decisions := workflowadapters.NewDecisionAdapter(mustWorkflowRepoFromDecisionStore(t, f.svc))
if err := decisions.RecordStepDecision(ctx, engine.DecisionInfo{
ID: "dec-stale", TaskID: "t1", StepID: reviewStepID, ParticipantID: "participant-0", Decision: "approve",
}); err != nil {
t.Fatalf("seed stale decision: %v", err)
}
if !f.fireOnTurnComplete(t, ctx) {
t.Fatalf("expected a transition from Work -> Review, got none")
}
assertStepByName(t, ctx, f.repo, "s1", "Review", f.nameToID)
if got := f.runQueue.callCount(); got != 1 {
t.Fatalf("queued run count = %d, want exactly 1", got)
}
remaining, err := decisions.ListStepDecisions(ctx, "t1", reviewStepID)
if err != nil {
t.Fatalf("ListStepDecisions: %v", err)
}
if len(remaining) != 0 {
t.Fatalf("remaining decisions = %d, want 0 (AC-B6 atomic clear_decisions)", len(remaining))
}
// The run-queued assertion above only proves dispatchOnEnterActions
// continued past clear_decisions — it does not directly prove
// ClearStepDecisionsAndCompleteMarker's own marker-write half of its one
// transaction actually committed "done" rather than, say, leaving the
// marker in_progress while a downstream code path incorrectly continued
// anyway. entryID 1: the fresh per-test database's very first allocated
// workflow_step_entries row (same reasoning as
// step_entry_dispatch_cas_loser_test.go's sibling tests); position 0 is
// clear_decisions, the first action reviewLoopWorkflowJSON's Review step
// declares.
const entryID, clearDecisionsPosition = int64(1), 0
state, cause, found, err := f.repo.GetStepEntryMarkerState(ctx, entryID, clearDecisionsPosition)
if err != nil {
t.Fatalf("GetStepEntryMarkerState: %v", err)
}
if !found {
t.Fatalf("expected a clear_decisions marker to exist for entry %d position %d", entryID, clearDecisionsPosition)
}
if state != stepentry.MarkerDone {
t.Fatalf("clear_decisions marker state = %q (cause=%q), want %q (AC-B6: the atomic path must complete the marker inside the same transaction as the delete)", state, cause, stepentry.MarkerDone)
}
}
// mustWorkflowRepoFromDecisionStore extracts the *workflowadapters.DecisionAdapter
// wired by newSharedDBReviewLoopFixture, so the test can seed/read decisions
// through the identical repository instance the atomic path writes through.
func mustWorkflowRepoFromDecisionStore(t *testing.T, svc *Service) workflowadapters.WorkflowRepo {
t.Helper()
adapter, ok := svc.engineDecisions.(*workflowadapters.DecisionAdapter)
if !ok {
t.Fatalf("svc.engineDecisions is %T, want *workflowadapters.DecisionAdapter", svc.engineDecisions)
}
return adapter.Repo
}
@@ -0,0 +1,255 @@
package orchestrator
// step_entry_dispatch_cas_loser_test.go covers two MEDIUM/P2 findings from
// this Build round's Review, both rooted in
// dispatchEngineOwnedOnEnterAction's !claimed branch:
//
// 1. It unconditionally returned "not failed", so a retry of a step-entry
// whose clear_decisions had already recorded a terminal failure would
// silently fall through to dispatching queue_run_for_each_participant —
// the exact fall-through AC-C2's break exists to close for a fresh
// failure, just reachable a second time through the CAS-loser path
// instead.
// 2. It also returned "not failed" — indistinguishable from "already
// done" — when the existing marker was still in_progress, i.e. a *live*
// concurrent dispatch of the same step entry hadn't finished yet.
// dispatchOnEnterActions only aborted on failed==true for
// clear_decisions specifically, so the loser of that live claim would
// skip ahead to queue_run_for_each_participant instead of abandoning
// the entry — violating docs/specs/workflow-on-enter-action-dispatch/
// spec.md's AC-F1 ("A dispatcher that loses a claim stops. It does not
// skip ahead.") and risking the exact hazard AC-F1's rationale names:
// enqueueing the fan-out before the winner's clear_decisions delete
// (AC-A3) commits.
//
// See GetStepEntryMarkerState and its call site in
// dispatchEngineOwnedOnEnterAction for both.
import (
"context"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/workflow/stepentry"
)
// TestProcessOnEnter_ClearDecisionsPriorFailure_BlocksRetryFromDispatchingSubsequentActions
// drives a step entry's on_enter dispatch twice: once with a failing
// decisions store (mirroring AC-C2's failure test, which records the
// clear_decisions marker as terminally failed for this entry), then again
// with a passing store — modelling a retry after whatever caused the first
// failure was fixed, but for the *same* step entry. The retry must still
// see clear_decisions as failed (from the stored marker state, since its
// CAS claim is lost to the earlier row) and must not proceed to dispatch
// queue_run_for_each_participant.
func TestProcessOnEnter_ClearDecisionsPriorFailure_BlocksRetryFromDispatchingSubsequentActions(t *testing.T) {
ctx := context.Background()
f := newReviewLoopFixture(t)
f.svc.SetEngineDecisionStore(&failingDecisionStore{})
if !f.fireOnTurnComplete(t, ctx) {
t.Fatalf("expected a transition from Work -> Review, got none")
}
if got := f.runQueue.callCount(); got != 0 {
t.Fatalf("queued run count after clear_decisions failure = %d, want 0 (AC-C2)", got)
}
// Swap in a passing decisions store, as if the underlying problem that
// caused the first clear_decisions attempt to fail were now resolved.
f.svc.SetEngineDecisionStore(&fakeDecisionStore{})
session, err := f.repo.GetTaskSession(ctx, "s1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
sg, nameToID := buildWorkflowFromJSON(t, reviewLoopWorkflowJSON)
reviewStep, ok := sg.steps[nameToID["Review"]]
if !ok {
t.Fatalf("Review step not found in rebuilt workflow")
}
// entryID 1: the fresh per-test database's very first allocated
// workflow_step_entries row, created by fireOnTurnComplete's Work ->
// Review transition above (no other entry allocation happens before
// it in this fixture).
const retryEntryID = int64(1)
dispatchResult := f.svc.dispatchOnEnterActions(ctx, "t1", session, reviewStep, retryEntryID, false, false)
if dispatchResult.hasAutoStart {
t.Errorf("hasAutoStart = true, want false (Review declares no auto_start_agent)")
}
if got := f.runQueue.callCount(); got != 0 {
t.Fatalf("queued run count after retry = %d, want still 0 (clear_decisions already failed terminally for this entry)", got)
}
}
// blockingDecisionStore wraps fakeDecisionStore but parks ClearStepDecisions
// on a channel the test controls, so another goroutine can observe this
// entry's clear_decisions marker verifiably in_progress — a live claim, not
// a hypothetical race — before the test lets it proceed to completion.
type blockingDecisionStore struct {
fakeDecisionStore
proceed chan struct{}
}
func (f *blockingDecisionStore) ClearStepDecisions(ctx context.Context, taskID, stepID string) (int64, error) {
<-f.proceed
return f.fakeDecisionStore.ClearStepDecisions(ctx, taskID, stepID)
}
// TestProcessOnEnter_ConcurrentDispatchLosesLiveClaim_AbandonsEntryWithoutSkippingAhead
// drives two genuinely concurrent dispatches of the *same* step entry. The
// winner is parked mid-flight inside ClearStepDecisions, so its position-0
// marker is confirmed in_progress (polled, not assumed) before the second,
// would-be-loser dispatchOnEnterActions call races in on the same entryID.
// The loser must abandon the entry — no run queued, no WARNING/ERROR log
// (AC-F1: losing a claim is a normal outcome) — rather than skip ahead to
// queue_run_for_each_participant. Once the winner is released, it alone
// completes both actions.
func TestProcessOnEnter_ConcurrentDispatchLosesLiveClaim_AbandonsEntryWithoutSkippingAhead(t *testing.T) {
ctx := context.Background()
f := newReviewLoopFixture(t)
store := &blockingDecisionStore{proceed: make(chan struct{})}
f.svc.SetEngineDecisionStore(store)
core, logs := observer.New(zapcore.DebugLevel)
log, err := logger.NewFromZap(zap.New(core))
if err != nil {
t.Fatalf("build observed logger: %v", err)
}
f.svc.logger = log
setSessionState(t, ctx, f.repo, "s1", models.TaskSessionStateRunning)
onEnterDone := make(chan struct{})
f.svc.onProcessOnEnterComplete = func() { close(onEnterDone) }
session, err := f.repo.GetTaskSession(ctx, "s1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
go f.svc.processOnTurnCompleteViaEngine(ctx, "t1", session)
// entryID 1: the fresh per-test database's very first allocated
// workflow_step_entries row (same reasoning as the sibling test above).
const entryID = int64(1)
const clearDecisionsPosition = 0
deadline := time.Now().Add(2 * time.Second)
for {
state, _, found, stateErr := f.repo.GetStepEntryMarkerState(ctx, entryID, clearDecisionsPosition)
if stateErr != nil {
t.Fatalf("GetStepEntryMarkerState: %v", stateErr)
}
if found && state == stepentry.MarkerInProgress {
break
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for position %d to become in_progress (found=%v state=%v)", clearDecisionsPosition, found, state)
}
time.Sleep(time.Millisecond)
}
sg, nameToID := buildWorkflowFromJSON(t, reviewLoopWorkflowJSON)
reviewStep, ok := sg.steps[nameToID["Review"]]
if !ok {
t.Fatalf("Review step not found in rebuilt workflow")
}
// The concurrent, would-be-loser dispatch: same entry, same session,
// racing while the winner above is still parked inside clear_decisions.
dispatchResult := f.svc.dispatchOnEnterActions(ctx, "t1", session, reviewStep, entryID, false, false)
if dispatchResult.hasAutoStart {
t.Errorf("hasAutoStart = true, want false (Review declares no auto_start_agent)")
}
if got := f.runQueue.callCount(); got != 0 {
t.Fatalf("queued run count after losing a live claim = %d, want 0 (AC-F1: loser must not skip ahead to queue_run_for_each_participant)", got)
}
for _, e := range logs.All() {
if e.Level >= zapcore.WarnLevel {
t.Errorf("unexpected %s log for a normal claim loss (AC-F1: not a fault): %s %+v", e.Level, e.Message, e.ContextMap())
}
}
close(store.proceed)
select {
case <-onEnterDone:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for winner's on_enter processing to finish")
}
if got := f.runQueue.callCount(); got != 1 {
t.Fatalf("queued run count after winner finished = %d, want exactly 1 (winner alone dispatches queue_run_for_each_participant)", got)
}
store.mu.Lock()
clearCalls := store.clearCalls
store.mu.Unlock()
if clearCalls != 1 {
t.Errorf("clear_decisions calls = %d, want exactly 1 (loser must not have re-executed it)", clearCalls)
}
}
// TestProcessOnEnter_RedispatchAfterEntryAlreadyDone_IsANoOp covers the third
// !claimed outcome GetStepEntryMarkerState can report — MarkerDone — which
// neither sibling test above exercises: the first test's retry sees
// MarkerFailed (a prior clear_decisions error), and the concurrent test's
// loser sees MarkerInProgress (a live peer). Here every position for the
// entry has already finished successfully via a normal, uncontested
// fireOnTurnComplete; a second, later dispatchOnEnterActions call for that
// same entryID (e.g. a duplicate trigger delivery, or any caller re-driving
// on_enter for a step entry the engine already fully processed) must fall
// through both positions as already-done and produce no new side effects,
// per the "MarkerDone: ... Safe, and required, to continue past it" branch
// in dispatchEngineOwnedOnEnterAction.
func TestProcessOnEnter_RedispatchAfterEntryAlreadyDone_IsANoOp(t *testing.T) {
ctx := context.Background()
f := newReviewLoopFixture(t)
if !f.fireOnTurnComplete(t, ctx) {
t.Fatalf("expected a transition from Work -> Review, got none")
}
if got := f.runQueue.callCount(); got != 1 {
t.Fatalf("queued run count after the original dispatch = %d, want exactly 1", got)
}
f.decisions.mu.Lock()
clearCallsAfterOriginal := f.decisions.clearCalls
f.decisions.mu.Unlock()
if clearCallsAfterOriginal != 1 {
t.Fatalf("clear_decisions calls after the original dispatch = %d, want exactly 1", clearCallsAfterOriginal)
}
session, err := f.repo.GetTaskSession(ctx, "s1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
sg, nameToID := buildWorkflowFromJSON(t, reviewLoopWorkflowJSON)
reviewStep, ok := sg.steps[nameToID["Review"]]
if !ok {
t.Fatalf("Review step not found in rebuilt workflow")
}
// entryID 1: same fully-completed entry the fireOnTurnComplete call above
// just processed; both of its on_enter positions are now MarkerDone.
const entryID = int64(1)
dispatchResult := f.svc.dispatchOnEnterActions(ctx, "t1", session, reviewStep, entryID, false, false)
if dispatchResult.hasAutoStart {
t.Errorf("hasAutoStart = true, want false (Review declares no auto_start_agent)")
}
if got := f.runQueue.callCount(); got != 1 {
t.Fatalf("queued run count after redispatching an already-done entry = %d, want still 1 (no re-execution)", got)
}
f.decisions.mu.Lock()
clearCallsAfterRedispatch := f.decisions.clearCalls
f.decisions.mu.Unlock()
if clearCallsAfterRedispatch != 1 {
t.Errorf("clear_decisions calls after redispatching an already-done entry = %d, want still 1 (no re-execution)", clearCallsAfterRedispatch)
}
}
@@ -0,0 +1,90 @@
package orchestrator
// step_entry_dispatch_concurrent_test.go covers the HIGH duplicate-entry
// finding from this Build round's Review: two independent event sources
// (agent turn completion, agent-exit, the step_complete_kandev signal, user
// cancellation) can each call processOnTurnCompleteViaEngine for the same
// session. Without serialization, two concurrent calls could both read the
// same pre-transition step, both call applyEngineTransition, and each
// allocate its own workflow_step_entries row for what should be a single
// logical transition — double-dispatching that entry's on_enter actions
// (clear_decisions, queue_run_for_each_participant). See
// acquireTurnCompletionCriticalSection and turnCompletionLocks' field
// comment in service.go for the fix.
import (
"context"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
)
// TestProcessOnTurnComplete_ConcurrentDuplicateCalls_OnlyOneTransitionApplies
// fires two concurrent processOnTurnCompleteViaEngine calls for the same
// session, both observing the same pre-transition step (Work). Exactly one
// must win: apply the Work -> Review transition and dispatch Review's
// on_enter actions exactly once. The other must recognize (after losing the
// race for the per-session lock and re-reading fresh task state) that
// another caller already handled this turn, and return false without
// re-evaluating on_turn_complete against Review's own declaration — which
// would otherwise spuriously fire Review -> Work for a turn nobody in
// Review actually completed.
func TestProcessOnTurnComplete_ConcurrentDuplicateCalls_OnlyOneTransitionApplies(t *testing.T) {
ctx := context.Background()
f := newReviewLoopFixture(t)
setSessionState(t, ctx, f.repo, "s1", models.TaskSessionStateRunning)
onEnterDone := make(chan struct{}, 2)
f.svc.onProcessOnEnterComplete = func() { onEnterDone <- struct{}{} }
session, err := f.repo.GetTaskSession(ctx, "s1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
const callers = 2
results := make([]bool, callers)
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < callers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
results[i] = f.svc.processOnTurnCompleteViaEngine(ctx, "t1", session)
}(i)
}
close(start)
wg.Wait()
select {
case <-onEnterDone:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for on_enter processing")
}
transitionedCount := 0
for _, r := range results {
if r {
transitionedCount++
}
}
if transitionedCount != 1 {
t.Fatalf("transitioned count = %d, want exactly 1 of %d concurrent calls", transitionedCount, callers)
}
assertStepByName(t, ctx, f.repo, "s1", "Review", f.nameToID)
if got := f.runQueue.callCount(); got != 1 {
t.Fatalf("queued run count = %d, want exactly 1 (no double-dispatch)", got)
}
f.decisions.mu.Lock()
clearCalls := f.decisions.clearCalls
f.decisions.mu.Unlock()
if clearCalls != 1 {
t.Fatalf("clear_decisions calls = %d, want exactly 1 (no double-dispatch)", clearCalls)
}
}
@@ -0,0 +1,351 @@
package orchestrator
import (
"context"
"errors"
"sync"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/workflow/engine"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// step_entry_dispatch_test.go covers the defect this Build round fixes: the
// Office Default workflow's Review/Approval steps declare
// on_enter: [clear_decisions, queue_run_for_each_participant], but the
// legacy processOnEnter switch silently discarded both action types (no
// case, no default), so no reviewer/approver was ever woken. These tests
// drive the real E1 entry path (engine-driven on_turn_complete →
// applyEngineTransition → launchProcessOnEnter → processOnEnter) end to end
// against the real sqlite repo, so step-entry allocation and CAS-marker
// claiming are exercised for real, not stubbed.
// reviewLoopWorkflowJSON models a two-step Work/Review loop: Work's
// on_turn_complete advances into Review; Review declares the two
// engine-owned on_enter actions under test and its own on_turn_complete
// sends the task back to Work (modelling a rejection round for AC-D4).
const reviewLoopWorkflowJSON = `{
"version": 1,
"type": "kandev_workflow",
"workflows": [
{
"name": "ReviewLoop",
"steps": [
{
"name": "Work",
"position": 0,
"color": "bg-blue-500",
"events": {
"on_turn_complete": [{"type": "move_to_step", "config": {"step_position": 1}}]
},
"is_start_step": true,
"allow_manual_move": true
},
{
"name": "Review",
"position": 1,
"color": "bg-yellow-500",
"events": {
"on_enter": [
{"type": "clear_decisions"},
{"type": "queue_run_for_each_participant", "config": {"role": "reviewer"}}
],
"on_turn_complete": [{"type": "move_to_step", "config": {"step_position": 0}}]
},
"allow_manual_move": true
}
]
}
]
}`
// stepEntryFakeRunQueue records every QueueRun call. Safe for concurrent use —
// processOnEnter's dispatch runs on a goroutine.
type stepEntryFakeRunQueue struct {
mu sync.Mutex
calls []engine.QueueRunRequest
}
func (f *stepEntryFakeRunQueue) QueueRun(_ context.Context, req engine.QueueRunRequest) (engine.QueueOutcome, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, req)
return "", nil
}
func (f *stepEntryFakeRunQueue) callCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.calls)
}
// fakeParticipantStore returns a fixed participant list regardless of
// stepID/taskID — sufficient for a single-step-under-test scenario.
type fakeParticipantStore struct {
participants []engine.ParticipantInfo
}
func (f *fakeParticipantStore) ListStepParticipants(_ context.Context, _, _ string) ([]engine.ParticipantInfo, error) {
return f.participants, nil
}
func (f *fakeParticipantStore) ListTaskParticipants(_ context.Context, _ string) ([]engine.ParticipantInfo, error) {
return f.participants, nil
}
// fakeDecisionStore is a minimal DecisionStore recording ClearStepDecisions
// calls; ListStepDecisions/RecordStepDecision are unused by this test.
type fakeDecisionStore struct {
mu sync.Mutex
clearCalls int
}
func (f *fakeDecisionStore) ListStepDecisions(_ context.Context, _, _ string) ([]engine.DecisionInfo, error) {
return nil, nil
}
func (f *fakeDecisionStore) RecordStepDecision(_ context.Context, _ engine.DecisionInfo) error {
return nil
}
func (f *fakeDecisionStore) ClearStepDecisions(_ context.Context, _, _ string) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.clearCalls++
return 0, nil
}
// reviewLoopFixture bundles the wiring shared by the AC-D3/AC-D4 tests.
type reviewLoopFixture struct {
svc *Service
repo sessionExecutorStore
agentMgr *mockAgentManager
runQueue *stepEntryFakeRunQueue
decisions *fakeDecisionStore
nameToID map[string]string
taskRepoMk *mockTaskRepo
}
func newReviewLoopFixture(t *testing.T) *reviewLoopFixture {
t.Helper()
sg, nameToID := buildWorkflowFromJSON(t, reviewLoopWorkflowJSON)
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", nameToID["Work"])
setSessionExecID(t, repo, "s1", "exec-1")
agentMgr := &mockAgentManager{repoForExecutionLookup: repo, isAgentRunning: true}
svc := createEngineService(t, repo, sg, agentMgr)
runQueue := &stepEntryFakeRunQueue{}
participants := &fakeParticipantStore{participants: []engine.ParticipantInfo{
{ID: "participant-1", StepID: nameToID["Review"], Role: "reviewer", AgentProfileID: "agent-reviewer-1"},
}}
decisions := &fakeDecisionStore{}
svc.SetEngineRunQueue(runQueue)
svc.SetEngineParticipantStore(participants)
svc.SetEngineDecisionStore(decisions)
taskRepo := svc.taskRepo.(*mockTaskRepo)
seedMockTaskState(taskRepo, "t1", v1.TaskStateInProgress)
return &reviewLoopFixture{
svc: svc, repo: repo, agentMgr: agentMgr,
runQueue: runQueue, decisions: decisions,
nameToID: nameToID, taskRepoMk: taskRepo,
}
}
// fireOnTurnComplete sets the session RUNNING, fires on_turn_complete, and
// blocks until processOnEnter's goroutine has finished — so assertions right
// after this call see the dispatch's side effects.
func (f *reviewLoopFixture) fireOnTurnComplete(t *testing.T, ctx context.Context) bool {
t.Helper()
setSessionState(t, ctx, f.repo, "s1", models.TaskSessionStateRunning)
onEnterDone := make(chan struct{})
f.svc.onProcessOnEnterComplete = func() { close(onEnterDone) }
session, err := f.repo.GetTaskSession(ctx, "s1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
transitioned := f.svc.processOnTurnCompleteViaEngine(ctx, "t1", session)
select {
case <-onEnterDone:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for on_enter processing")
}
return transitioned
}
// TestProcessOnEnter_QueueRunForEachParticipant_SingleReviewerEntry_QueuesExactlyOneRun
// covers AC-D3: a single reviewer entering Review via an ordinary
// on_turn_complete transition produces exactly one queued run, and
// clear_decisions runs alongside it.
func TestProcessOnEnter_QueueRunForEachParticipant_SingleReviewerEntry_QueuesExactlyOneRun(t *testing.T) {
ctx := context.Background()
f := newReviewLoopFixture(t)
transitioned := f.fireOnTurnComplete(t, ctx)
if !transitioned {
t.Fatalf("expected a transition from Work -> Review, got none")
}
assertStepByName(t, ctx, f.repo, "s1", "Review", f.nameToID)
if got := f.runQueue.callCount(); got != 1 {
t.Fatalf("queued run count = %d, want exactly 1 (AC-D3)", got)
}
if got := f.runQueue.calls[0].AgentProfileID; got != "agent-reviewer-1" {
t.Errorf("queued run agent = %q, want %q", got, "agent-reviewer-1")
}
f.decisions.mu.Lock()
clearCalls := f.decisions.clearCalls
f.decisions.mu.Unlock()
if clearCalls != 1 {
t.Errorf("clear_decisions calls = %d, want 1", clearCalls)
}
}
// TestProcessOnEnter_QueueRunForEachParticipant_SecondEntryAfterRejection_QueuesAnotherRun
// covers AC-D4: after a rejection round sends the task back to Work and it
// re-enters Review, the step-entry CAS marker for the *new* entry has never
// been claimed, so a further run is queued — the at-most-once guarantee is
// per step-entry, not a blanket suppression of repeat dispatch.
func TestProcessOnEnter_QueueRunForEachParticipant_SecondEntryAfterRejection_QueuesAnotherRun(t *testing.T) {
ctx := context.Background()
f := newReviewLoopFixture(t)
// Work -> Review (first entry).
if !f.fireOnTurnComplete(t, ctx) {
t.Fatalf("expected a transition from Work -> Review, got none")
}
if got := f.runQueue.callCount(); got != 1 {
t.Fatalf("queued run count after first entry = %d, want 1", got)
}
// Review -> Work (rejection round).
if !f.fireOnTurnComplete(t, ctx) {
t.Fatalf("expected a transition from Review -> Work, got none")
}
assertStepByName(t, ctx, f.repo, "s1", "Work", f.nameToID)
if got := f.runQueue.callCount(); got != 1 {
t.Fatalf("queued run count after rejection = %d, want still 1 (Work declares no queue_run_for_each_participant)", got)
}
// Work -> Review (second entry).
if !f.fireOnTurnComplete(t, ctx) {
t.Fatalf("expected a transition from Work -> Review, got none")
}
assertStepByName(t, ctx, f.repo, "s1", "Review", f.nameToID)
if got := f.runQueue.callCount(); got != 2 {
t.Fatalf("queued run count after second Review entry = %d, want 2 (AC-D4: not suppressed)", got)
}
}
// failingDecisionStore wraps fakeDecisionStore but always fails
// ClearStepDecisions, for AC-C2 below.
type failingDecisionStore struct {
fakeDecisionStore
}
func (f *failingDecisionStore) ClearStepDecisions(_ context.Context, _, _ string) (int64, error) {
f.mu.Lock()
f.clearCalls++
f.mu.Unlock()
return 0, errors.New("boom: clear_decisions failure")
}
// TestProcessOnEnter_ClearDecisionsFailure_BlocksSubsequentOnEnterActions
// covers AC-C2: "IF clear_decisions fails, THEN the system shall not
// execute any remaining on_enter action for that step entry." Review's
// on_enter declares queue_run_for_each_participant immediately after
// clear_decisions (the exact order the Office Default workflow uses) — a
// stale decision from a previous round combined with a fresh participant
// fan-out could otherwise satisfy wait_for_quorum without every
// current-round reviewer actually voting.
func TestProcessOnEnter_ClearDecisionsFailure_BlocksSubsequentOnEnterActions(t *testing.T) {
ctx := context.Background()
f := newReviewLoopFixture(t)
f.svc.SetEngineDecisionStore(&failingDecisionStore{})
core, logs := observer.New(zapcore.ErrorLevel)
log, err := logger.NewFromZap(zap.New(core))
if err != nil {
t.Fatalf("build observed logger: %v", err)
}
f.svc.logger = log
if !f.fireOnTurnComplete(t, ctx) {
t.Fatalf("expected a transition from Work -> Review, got none")
}
if got := f.runQueue.callCount(); got != 0 {
t.Fatalf("queued run count after clear_decisions failure = %d, want 0 (AC-C2)", got)
}
var acC2Errors []observer.LoggedEntry
for _, e := range logs.All() {
if e.Message == "processOnEnter: clear_decisions failed, aborting remaining on_enter actions for step entry" {
acC2Errors = append(acC2Errors, e)
}
}
if len(acC2Errors) != 1 {
t.Fatalf("got %d AC-C2 ERROR log(s), want exactly 1 (all entries: %+v)", len(acC2Errors), logs.All())
}
fields := acC2Errors[0].ContextMap()
if fields["workflow_id"] != "wf1" {
t.Errorf("AC-C2 error workflow_id = %v, want %q", fields["workflow_id"], "wf1")
}
if fields["step_id"] != f.nameToID["Review"] {
t.Errorf("AC-C2 error step_id = %v, want %q", fields["step_id"], f.nameToID["Review"])
}
if fields["task_id"] != "t1" {
t.Errorf("AC-C2 error task_id = %v, want %q", fields["task_id"], "t1")
}
if cause, _ := fields["cause"].(string); cause == "" {
t.Errorf("AC-C2 error missing cause field: %+v", fields)
}
}
// TestProcessOnEnter_ClearDecisionsFailure_DoesNotLaunchEarlierAutoStart
// covers AC-C2 when auto_start_agent appears before clear_decisions. The
// auto-start action keeps its fixed final execution position, so a failed
// clear must abort the launch as well as later engine-owned actions.
func TestProcessOnEnter_ClearDecisionsFailure_DoesNotLaunchEarlierAutoStart(t *testing.T) {
ctx := context.Background()
f := newReviewLoopFixture(t)
f.svc.SetEngineDecisionStore(&failingDecisionStore{})
stepGetter, ok := f.svc.workflowStepGetter.(*mockStepGetter)
if !ok {
t.Fatalf("workflow step getter type = %T, want *mockStepGetter", f.svc.workflowStepGetter)
}
reviewStep := stepGetter.steps[f.nameToID["Review"]]
reviewStep.Prompt = "review prompt"
reviewStep.Events.OnEnter = []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterAutoStartAgent},
{Type: wfmodels.OnEnterClearDecisions},
}
if !f.fireOnTurnComplete(t, ctx) {
t.Fatalf("expected a transition from Work -> Review, got none")
}
f.agentMgr.mu.Lock()
promptCount := len(f.agentMgr.capturedPrompts)
f.agentMgr.mu.Unlock()
if promptCount != 0 {
t.Fatalf("auto-start prompt count after clear_decisions failure = %d, want 0", promptCount)
}
}
@@ -15,6 +15,7 @@ import (
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/workflow/engine"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/workflow/stepentry"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -308,6 +309,21 @@ func (s *workflowStore) applyTransition(ctx context.Context, taskID, sessionID,
if !steptelemetry.HasTrigger(transitionCtx) {
transitionCtx = engineTransitionAttribution(transitionCtx, sessionID, trigger)
}
// Only attach a PendingAllocation when the caller already opted in by
// wrapping ctx with a ResultHolder (applyEngineTransition does this for
// the engine-driven on_turn_complete path). Attaching it unconditionally
// would allocate workflow_step_entries rows for every transition into a
// step with engine-owned on_enter actions, including the callers (manual
// move, deferred move) that don't yet dispatch through them this Build
// round — see docs/specs/workflow-on-enter-action-dispatch/spec.md and
// the task plan's scope note for why those entry paths are deferred.
if targetStep != nil {
if _, wantsAllocation := stepentry.ResultHolderFromContext(ctx); wantsAllocation {
if pending, ok := stepentry.BuildPendingAllocation(targetStep.ID, targetStep.Events.OnEnter); ok {
transitionCtx = stepentry.WithPendingAllocation(transitionCtx, pending)
}
}
}
if err := s.updateTransitionTask(transitionCtx, task, targetStep); err != nil {
return fmt.Errorf("update task workflow step: %w", err)
}
@@ -24,6 +24,7 @@ func (r *Repository) initSchema() error {
r.initSessionSchema,
r.initDynamicRoutingSchema,
r.initStepTransitionsSchema,
r.initStepEntriesSchema,
r.initTaskUsageEventsSchema,
r.initAttachmentsSchema,
r.initTaskResourceCleanupSchema,
@@ -825,10 +826,7 @@ func (r *Repository) initSubagentContextSchema() error {
// workflows get deleted, and the historical fact that a card was in a
// now-deleted step must survive that deletion.
func (r *Repository) initStepTransitionsSchema() error {
idCol := "id INTEGER PRIMARY KEY AUTOINCREMENT"
if dialect.IsPostgres(r.db.DriverName()) {
idCol = "id BIGSERIAL PRIMARY KEY"
}
idCol := dialect.AutoIncrementIDColumn(r.db.DriverName())
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS task_step_transitions (
` + idCol + `,
@@ -0,0 +1,272 @@
package sqlite
// workflow_step_entries / workflow_step_entry_markers back the step-entry
// allocation and per-action CAS markers described in
// docs/specs/workflow-on-enter-action-dispatch/spec.md. This Build round
// wires allocation into exactly one chokepoint (updateTaskTx, shared by
// UpdateTask and UpdateTaskWithWorkflowStepAdmission(AndState)) via the
// context-carried stepentry.PendingAllocation/AllocationResult contract —
// see internal/workflow/stepentry for why a context value was chosen over
// changing ApplyTransition's or the admission methods' signatures.
//
// Scope note: this is a deliberately simplified, single-writer-per-entry CAS
// model, not the spec's full multi-process epoch/staleness reconciliation.
// A marker only ever has one row; claiming is "insert from absent", and a
// UNIQUE-constraint conflict on that insert means "already claimed or
// handled — skip", not "steal the marker from a stale epoch". Full
// epoch/staleness reconciliation under real contention and the AC-B9
// startup recovery scan are explicitly deferred to a later Build round.
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5/pgconn"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/workflow/stepentry"
)
// StepEntryMarkerState is a marker's terminal (or in-progress) state. It is
// an alias for stepentry.MarkerState so orchestrator-side dispatch callers
// can pass the same constants without importing this repository package.
type StepEntryMarkerState = stepentry.MarkerState
const (
// StepEntryMarkerInProgress is set the moment a claim succeeds.
StepEntryMarkerInProgress = stepentry.MarkerInProgress
// StepEntryMarkerDone marks a successfully executed action.
StepEntryMarkerDone = stepentry.MarkerDone
// StepEntryMarkerFailed marks an action whose callback returned an
// error; the error column records the cause.
StepEntryMarkerFailed = stepentry.MarkerFailed
)
// stepEntryMarkerUniqueMessage is the substring go-sqlite3 puts in a
// UNIQUE-constraint error for the (entry_id, position) index — see
// isExternalIDUniqueViolation in task_external_id.go for the same
// cross-dialect approach applied to a different index.
const stepEntryMarkerUniqueMessage = "UNIQUE constraint failed: workflow_step_entry_markers.entry_id, workflow_step_entry_markers.position"
const stepEntryMarkerIndexName = "uniq_workflow_step_entry_markers_entry_position"
func isStepEntryMarkerUniqueViolation(err error) bool {
if err == nil {
return false
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23505" && pgErr.ConstraintName == stepEntryMarkerIndexName
}
return strings.Contains(err.Error(), stepEntryMarkerUniqueMessage)
}
// initStepEntriesSchema creates workflow_step_entries and
// workflow_step_entry_markers. New tables, not columns added to existing
// ones, so CREATE TABLE IF NOT EXISTS in the init block is correct — see
// initStepTransitionsSchema's comment for why that rule doesn't apply here.
func (r *Repository) initStepEntriesSchema() error {
idCol := dialect.AutoIncrementIDColumn(r.db.DriverName())
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS workflow_step_entries (
` + idCol + `,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
step_id TEXT NOT NULL,
entry_seq INTEGER NOT NULL,
digest TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE(task_id, step_id, entry_seq)
);
CREATE INDEX IF NOT EXISTS idx_workflow_step_entries_task_step
ON workflow_step_entries(task_id, step_id);
CREATE TABLE IF NOT EXISTS workflow_step_entry_markers (
` + idCol + `,
entry_id INTEGER NOT NULL REFERENCES workflow_step_entries(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
kind TEXT NOT NULL,
state TEXT NOT NULL,
operation_id TEXT,
error TEXT,
claimed_at TIMESTAMP,
completed_at TIMESTAMP,
CONSTRAINT uniq_workflow_step_entry_markers_entry_position UNIQUE(entry_id, position)
);
CREATE INDEX IF NOT EXISTS idx_workflow_step_entry_markers_entry
ON workflow_step_entry_markers(entry_id);
`)
if err != nil {
return fmt.Errorf("init step entries schema: %w", err)
}
return nil
}
// allocateStepEntryIfPending reads a PendingAllocation off ctx (attached by
// the caller that resolved this transition's target step — see
// workflow_store.applyTransition) and, when present, allocates a new
// workflow_step_entries row inside tx. entry_seq is the 1-based count of
// prior entries into (task_id, step_id) for this task, so a task
// re-entering the same step after a rejection round gets a fresh entry
// rather than reusing the first one. When a ResultHolder is also attached,
// the allocated (entry_id, entry_seq) is written back through it so a
// caller several calls further up the stack can read it after commit.
//
// No-op (returns nil) when no PendingAllocation is attached — the common
// case for write paths that haven't opted into step-entry allocation this
// round (e.g. manual moves, still using the legacy processOnEnter path).
func (r *Repository) allocateStepEntryIfPending(ctx context.Context, tx stepTransitionTx, taskID string, occurredAt time.Time) error {
pending, ok := stepentry.FromContext(ctx)
if !ok {
return nil
}
var seqCount int64
err := tx.QueryRowContext(ctx, r.db.Rebind(`
SELECT COUNT(*) FROM workflow_step_entries WHERE task_id = ? AND step_id = ?
`), taskID, pending.StepID).Scan(&seqCount)
if err != nil {
return fmt.Errorf("count prior step entries: %w", err)
}
entrySeq := seqCount + 1
var entryID int64
err = tx.QueryRowContext(ctx, r.db.Rebind(`
INSERT INTO workflow_step_entries (task_id, step_id, entry_seq, digest, created_at)
VALUES (?, ?, ?, ?, ?)
RETURNING id
`), taskID, pending.StepID, entrySeq, pending.Digest, occurredAt).Scan(&entryID)
if err != nil {
return fmt.Errorf("allocate step entry: %w", err)
}
if holder, ok := stepentry.ResultHolderFromContext(ctx); ok {
holder.EntryID = entryID
holder.EntrySeq = entrySeq
}
return nil
}
// ClaimStepEntryMarker attempts to claim (entryID, position) for execution,
// transitioning it from absent (no row) to in_progress. claimed is false —
// not an error — when the marker already has a row (already claimed by a
// concurrent or prior attempt, or already terminal): the caller must treat
// that as "do not execute the action again", per AC-D3/AC-D4's at-most-once
// requirement for a given step-entry.
func (r *Repository) ClaimStepEntryMarker(ctx context.Context, entryID int64, position int, kind, operationID string, claimedAt time.Time) (bool, error) {
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO workflow_step_entry_markers (entry_id, position, kind, state, operation_id, claimed_at)
VALUES (?, ?, ?, ?, ?, ?)
`), entryID, position, kind, string(StepEntryMarkerInProgress), operationID, claimedAt)
if err != nil {
if isStepEntryMarkerUniqueViolation(err) {
return false, nil
}
return false, fmt.Errorf("claim step entry marker: %w", err)
}
return true, nil
}
// CompleteStepEntryMarker transitions a claimed marker to a terminal state
// (done/failed). It is a no-op — not an error — when the marker was never
// claimed (no row at all): there is nothing to complete, and fabricating a
// terminal row here would let a later, legitimate claim believe the action
// already ran.
func (r *Repository) CompleteStepEntryMarker(ctx context.Context, entryID int64, position int, state StepEntryMarkerState, cause string, completedAt time.Time) error {
var errCol sql.NullString
if cause != "" {
errCol = sql.NullString{String: cause, Valid: true}
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE workflow_step_entry_markers
SET state = ?, error = ?, completed_at = ?
WHERE entry_id = ? AND position = ? AND state = ?
`), string(state), errCol, completedAt, entryID, position, string(StepEntryMarkerInProgress))
if err != nil {
return fmt.Errorf("complete step entry marker: %w", err)
}
return nil
}
// GetStepEntryMarkerState reads back a marker's current state and stored
// failure cause (if any). found is false when no marker row exists for
// (entryID, position) — the marker was never claimed. Used by the CAS-loser
// path in dispatchEngineOwnedOnEnterAction: losing the claim to an existing
// row means "already claimed or handled," but that covers three different
// outcomes (still in progress elsewhere, already done, or already failed
// terminally), and only this read can tell them apart.
func (r *Repository) GetStepEntryMarkerState(ctx context.Context, entryID int64, position int) (state StepEntryMarkerState, cause string, found bool, err error) {
var row struct {
State string `db:"state"`
Error sql.NullString `db:"error"`
}
queryErr := r.db.GetContext(ctx, &row, r.db.Rebind(`
SELECT state, error FROM workflow_step_entry_markers WHERE entry_id = ? AND position = ?
`), entryID, position)
if errors.Is(queryErr, sql.ErrNoRows) {
return "", "", false, nil
}
if queryErr != nil {
return "", "", false, fmt.Errorf("get step entry marker state: %w", queryErr)
}
return StepEntryMarkerState(row.State), row.Error.String, true, nil
}
// ClearStepDecisionsAndCompleteMarker satisfies AC-B6: it deletes every
// decision for (taskID, stepID) and marks the clear_decisions position
// done, in one database transaction. A crash between the delete and the
// marker update is therefore impossible to observe as "delete committed,
// marker still in_progress" — the transaction either commits both or
// neither, which is the invariant a future AC-B9 recovery scan needs to
// safely decide whether re-running clear_decisions for a non-terminal
// marker is necessary.
//
// This duplicates the DELETE FROM workflow_step_decisions statement in
// workflow/repository.Repository.ClearStepDecisions rather than depending
// on that package: AC-B6 and AC-B7 together require the decisions table
// and the step-entry tables to be written through the same transaction,
// and workflow/repository.Repository exposes no way to join a
// caller-supplied *sql.Tx into its own writes. Both tables live in the same
// database reached through the same writer *sqlx.DB (see
// internal/backendapp/storage.go), so running both statements here, against
// this repository's own r.db, is safe — see
// orchestrator.dispatchClearDecisionsAtomic for the call site and the
// capability check that keeps this path off whenever the DecisionStore
// isn't actually backed by this database (e.g. every existing test double).
func (r *Repository) ClearStepDecisionsAndCompleteMarker(
ctx context.Context, taskID, stepID string, entryID int64, position int, now time.Time,
) (int64, error) {
if taskID == "" || stepID == "" {
return 0, errors.New("task_id and step_id are required")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("begin clear decisions transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx, r.db.Rebind(
`DELETE FROM workflow_step_decisions WHERE task_id = ? AND step_id = ?`,
), taskID, stepID)
if err != nil {
return 0, fmt.Errorf("clear step decisions: %w", err)
}
rows, _ := res.RowsAffected()
_, err = tx.ExecContext(ctx, r.db.Rebind(`
UPDATE workflow_step_entry_markers
SET state = ?, error = NULL, completed_at = ?
WHERE entry_id = ? AND position = ? AND state = ?
`), string(StepEntryMarkerDone), now, entryID, position, string(StepEntryMarkerInProgress))
if err != nil {
return 0, fmt.Errorf("complete step entry marker: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit clear decisions transaction: %w", err)
}
return rows, nil
}
@@ -0,0 +1,141 @@
package sqlite
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/testutil"
)
// TestPostgresClaimStepEntryMarker_ConcurrentRace is the PostgreSQL
// counterpart to the CAS-loser coverage in
// internal/orchestrator/step_entry_dispatch_cas_loser_test.go: it proves
// ClaimStepEntryMarker's at-most-once guarantee (AC-D3/AC-D4,
// docs/specs/workflow-on-enter-action-dispatch/spec.md) holds against
// Postgres's real UNIQUE-violation error shape
// (isStepEntryMarkerUniqueViolation's *pgconn.PgError branch), not only
// against go-sqlite3's string-matched one every orchestrator dispatch test
// in this Build round already exercises. Several real connections race a
// claim for the same (entry_id, position); exactly one must win. Skips
// unless KANDEV_TEST_POSTGRES_DSN is set.
func TestPostgresClaimStepEntryMarker_ConcurrentRace(t *testing.T) {
const concurrency = 8
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-marker-workspace", Name: "Postgres CAS marker workspace"}); err != nil {
t.Fatalf("seed workspace: %v", err)
}
if err := repo.CreateTask(ctx, &models.Task{
ID: "postgres-cas-marker-task", WorkspaceID: "postgres-cas-marker-workspace",
WorkflowID: "postgres-cas-marker-workflow", WorkflowStepID: "postgres-cas-marker-step",
Title: "CAS marker candidate",
}); err != nil {
t.Fatalf("seed task: %v", err)
}
var entryID int64
if err := db.QueryRowContext(ctx, db.Rebind(`
INSERT INTO workflow_step_entries (task_id, step_id, entry_seq, digest, created_at)
VALUES (?, ?, ?, ?, ?)
RETURNING id
`), "postgres-cas-marker-task", "postgres-cas-marker-step", 1, "digest-1", time.Now()).Scan(&entryID); err != nil {
t.Fatalf("seed step entry: %v", err)
}
start := make(chan struct{})
results := make(chan bool, concurrency)
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
claimed, claimErr := repo.ClaimStepEntryMarker(ctx, entryID, 0, "clear_decisions", fmt.Sprintf("op-%d", i), time.Now())
if claimErr != nil {
t.Errorf("concurrent claim attempt %d: %v", i, claimErr)
return
}
results <- claimed
}(i)
}
close(start)
wg.Wait()
close(results)
claimedCount := 0
for c := range results {
if c {
claimedCount++
}
}
if claimedCount != 1 {
t.Fatalf("expected exactly 1 concurrent claim to win, got %d", claimedCount)
}
state, _, found, err := repo.GetStepEntryMarkerState(ctx, entryID, 0)
if err != nil {
t.Fatalf("GetStepEntryMarkerState: %v", err)
}
if !found {
t.Fatalf("expected a marker row to exist after the race")
}
if state != StepEntryMarkerInProgress {
t.Fatalf("marker state after the race = %q, want %q (only the winner claimed it, nobody completed it)", state, StepEntryMarkerInProgress)
}
}
// TestPostgresIsStepEntryMarkerUniqueViolation_MatchesRealConstraintError
// drives the actual UNIQUE-constraint index through Postgres directly (a
// second raw INSERT for the same (entry_id, position), no ClaimStepEntryMarker
// involved) and asserts isStepEntryMarkerUniqueViolation classifies the
// resulting *pgconn.PgError the same way its already-tested SQLite string
// match does. This is what ClaimStepEntryMarker's !claimed branch relies on
// for every CAS-loser test in internal/orchestrator — without this,
// SQLite's error-shape coverage tells us nothing about the Postgres branch
// of the same function.
func TestPostgresIsStepEntryMarkerUniqueViolation_MatchesRealConstraintError(t *testing.T) {
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-marker-dup-workspace", Name: "Postgres CAS marker dup workspace"}); err != nil {
t.Fatalf("seed workspace: %v", err)
}
if err := repo.CreateTask(ctx, &models.Task{
ID: "postgres-cas-marker-dup-task", WorkspaceID: "postgres-cas-marker-dup-workspace",
WorkflowID: "postgres-cas-marker-dup-workflow", WorkflowStepID: "postgres-cas-marker-dup-step",
Title: "CAS marker dup candidate",
}); err != nil {
t.Fatalf("seed task: %v", err)
}
var entryID int64
if err := db.QueryRowContext(ctx, db.Rebind(`
INSERT INTO workflow_step_entries (task_id, step_id, entry_seq, digest, created_at)
VALUES (?, ?, ?, ?, ?)
RETURNING id
`), "postgres-cas-marker-dup-task", "postgres-cas-marker-dup-step", 1, "digest-1", time.Now()).Scan(&entryID); err != nil {
t.Fatalf("seed step entry: %v", err)
}
claimed, err := repo.ClaimStepEntryMarker(ctx, entryID, 0, "clear_decisions", "op-first", time.Now())
if err != nil || !claimed {
t.Fatalf("first claim: claimed=%v err=%v", claimed, err)
}
claimed, err = repo.ClaimStepEntryMarker(ctx, entryID, 0, "clear_decisions", "op-second", time.Now())
if err != nil {
t.Fatalf("second claim on the same (entry_id, position) returned a real error instead of claimed=false: %v", err)
}
if claimed {
t.Fatalf("second claim on the same (entry_id, position) succeeded, want claimed=false (UNIQUE violation)")
}
}
@@ -0,0 +1,514 @@
package sqlite
// Coverage for workflow_step_entries / workflow_step_entry_markers: the
// step-entry allocation hook inside updateTaskTx (via stepentry's
// context-carried PendingAllocation/AllocationResult), entry_seq
// incrementing across repeated entries into the same step, and the
// CAS claim/complete semantics markers use to guarantee at-most-once
// dispatch of an engine-owned on_enter action.
//
// See docs/specs/workflow-on-enter-action-dispatch/spec.md and this Build
// round's task plan scope note: this file exercises only the write path this
// round wires (plain UpdateTask through updateTaskTx), not the full
// multi-process epoch/staleness reconciliation the spec describes.
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/jmoiron/sqlx"
dbutil "github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/workflow/stepentry"
)
func newStepEntriesTestRepo(t *testing.T) *Repository {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "step-entries.db")
dbConn, err := dbutil.OpenSQLite(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
db := sqlx.NewDb(dbConn, "sqlite3")
t.Cleanup(func() { _ = db.Close() })
repo, err := NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("initialize schema: %v", err)
}
return repo
}
func createStepEntriesTestTask(t *testing.T, repo *Repository, taskID, workflowID, stepID string) *models.Task {
t.Helper()
task := &models.Task{
ID: taskID,
WorkspaceID: "ws-1",
WorkflowID: workflowID,
WorkflowStepID: stepID,
Title: "Test Task",
Priority: "medium",
}
if err := repo.CreateTask(context.Background(), task); err != nil {
t.Fatalf("CreateTask: %v", err)
}
return task
}
type stepEntryRow struct {
id int64
taskID string
stepID string
entrySeq int64
digest string
createdAt time.Time
}
func stepEntryRowsForTask(t *testing.T, repo *Repository, taskID string) []stepEntryRow {
t.Helper()
rows, err := repo.db.QueryContext(context.Background(), repo.db.Rebind(`
SELECT id, task_id, step_id, entry_seq, digest, created_at
FROM workflow_step_entries WHERE task_id = ? ORDER BY entry_seq ASC
`), taskID)
if err != nil {
t.Fatalf("query workflow_step_entries: %v", err)
}
defer func() { _ = rows.Close() }()
var out []stepEntryRow
for rows.Next() {
var r stepEntryRow
if err := rows.Scan(&r.id, &r.taskID, &r.stepID, &r.entrySeq, &r.digest, &r.createdAt); err != nil {
t.Fatalf("scan row: %v", err)
}
out = append(out, r)
}
return out
}
func TestUpdateTaskAllocatesStepEntryWhenPendingAllocationPresent(t *testing.T) {
repo := newStepEntriesTestRepo(t)
task := createStepEntriesTestTask(t, repo, "task-1", "wf-1", "step-a")
task.WorkflowStepID = "step-review"
holder := &stepentry.AllocationResult{}
ctx := stepentry.WithResultHolder(context.Background(), holder)
ctx = stepentry.WithPendingAllocation(ctx, stepentry.PendingAllocation{
StepID: "step-review",
Digest: "digest-1",
Positions: []stepentry.EnginePosition{
{Position: 0, Kind: "clear_decisions"},
{Position: 1, Kind: "queue_run_for_each_participant"},
},
})
if err := repo.UpdateTask(ctx, task); err != nil {
t.Fatalf("UpdateTask: %v", err)
}
if holder.EntryID == 0 {
t.Fatalf("expected holder.EntryID to be populated, got 0")
}
if holder.EntrySeq != 1 {
t.Fatalf("EntrySeq = %d, want 1", holder.EntrySeq)
}
rows := stepEntryRowsForTask(t, repo, "task-1")
if len(rows) != 1 {
t.Fatalf("rows = %d, want 1", len(rows))
}
if rows[0].stepID != "step-review" {
t.Fatalf("stepID = %q, want step-review", rows[0].stepID)
}
if rows[0].digest != "digest-1" {
t.Fatalf("digest = %q, want digest-1", rows[0].digest)
}
if rows[0].id != holder.EntryID {
t.Fatalf("row id = %d, want holder.EntryID = %d", rows[0].id, holder.EntryID)
}
}
func TestUpdateTaskWithoutPendingAllocationAllocatesNothing(t *testing.T) {
repo := newStepEntriesTestRepo(t)
task := createStepEntriesTestTask(t, repo, "task-2", "wf-1", "step-a")
task.WorkflowStepID = "step-b"
if err := repo.UpdateTask(context.Background(), task); err != nil {
t.Fatalf("UpdateTask: %v", err)
}
rows := stepEntryRowsForTask(t, repo, "task-2")
if len(rows) != 0 {
t.Fatalf("rows = %d, want 0 (no pending allocation attached to ctx)", len(rows))
}
}
func TestUpdateTaskStepEntrySeqIncrementsAcrossRepeatedEntries(t *testing.T) {
repo := newStepEntriesTestRepo(t)
task := createStepEntriesTestTask(t, repo, "task-3", "wf-1", "step-a")
enter := func(stepID string) *stepentry.AllocationResult {
task.WorkflowStepID = stepID
holder := &stepentry.AllocationResult{}
ctx := stepentry.WithResultHolder(context.Background(), holder)
ctx = stepentry.WithPendingAllocation(ctx, stepentry.PendingAllocation{
StepID: stepID,
Digest: "digest",
Positions: []stepentry.EnginePosition{
{Position: 0, Kind: "clear_decisions"},
},
})
if err := repo.UpdateTask(ctx, task); err != nil {
t.Fatalf("UpdateTask: %v", err)
}
return holder
}
first := enter("step-review")
if first.EntrySeq != 1 {
t.Fatalf("first EntrySeq = %d, want 1", first.EntrySeq)
}
// Round-trip through a different step and back into step-review — a
// second entry into the same step (e.g. after a rejection round).
enter("step-work")
second := enter("step-review")
if second.EntrySeq != 2 {
t.Fatalf("second EntrySeq = %d, want 2", second.EntrySeq)
}
if second.EntryID == first.EntryID {
t.Fatalf("expected a distinct entry row for the second entry, got the same id %d", second.EntryID)
}
rows := stepEntryRowsForTask(t, repo, "task-3")
if len(rows) != 3 {
t.Fatalf("rows = %d, want 3 (step-review, step-work, step-review)", len(rows))
}
}
func allocateOneStepEntry(t *testing.T, repo *Repository, taskID, stepID string) int64 {
t.Helper()
task := createStepEntriesTestTask(t, repo, taskID, "wf-1", "step-a")
task.WorkflowStepID = stepID
holder := &stepentry.AllocationResult{}
ctx := stepentry.WithResultHolder(context.Background(), holder)
ctx = stepentry.WithPendingAllocation(ctx, stepentry.PendingAllocation{
StepID: stepID,
Digest: "digest",
Positions: []stepentry.EnginePosition{
{Position: 0, Kind: "clear_decisions"},
{Position: 1, Kind: "queue_run_for_each_participant"},
},
})
if err := repo.UpdateTask(ctx, task); err != nil {
t.Fatalf("UpdateTask: %v", err)
}
return holder.EntryID
}
func TestClaimStepEntryMarkerFirstClaimSucceeds(t *testing.T) {
repo := newStepEntriesTestRepo(t)
entryID := allocateOneStepEntry(t, repo, "task-4", "step-review")
claimed, err := repo.ClaimStepEntryMarker(context.Background(), entryID, 0, "clear_decisions", "op-1", time.Now().UTC())
if err != nil {
t.Fatalf("ClaimStepEntryMarker: %v", err)
}
if !claimed {
t.Fatalf("expected the first claim on an absent marker to succeed")
}
}
func TestClaimStepEntryMarkerDoubleClaimIsRejected(t *testing.T) {
repo := newStepEntriesTestRepo(t)
entryID := allocateOneStepEntry(t, repo, "task-5", "step-review")
ctx := context.Background()
claimed, err := repo.ClaimStepEntryMarker(ctx, entryID, 0, "clear_decisions", "op-1", time.Now().UTC())
if err != nil {
t.Fatalf("ClaimStepEntryMarker (first): %v", err)
}
if !claimed {
t.Fatalf("expected the first claim to succeed")
}
claimedAgain, err := repo.ClaimStepEntryMarker(ctx, entryID, 0, "clear_decisions", "op-2", time.Now().UTC())
if err != nil {
t.Fatalf("ClaimStepEntryMarker (second): %v", err)
}
if claimedAgain {
t.Fatalf("expected a second claim on an already-claimed marker to be rejected, not silently succeed")
}
}
func TestClaimStepEntryMarkerDistinctPositionsClaimIndependently(t *testing.T) {
repo := newStepEntriesTestRepo(t)
entryID := allocateOneStepEntry(t, repo, "task-6", "step-review")
ctx := context.Background()
claimedA, err := repo.ClaimStepEntryMarker(ctx, entryID, 0, "clear_decisions", "op-1", time.Now().UTC())
if err != nil {
t.Fatalf("ClaimStepEntryMarker (position 0): %v", err)
}
claimedB, err := repo.ClaimStepEntryMarker(ctx, entryID, 1, "queue_run_for_each_participant", "op-2", time.Now().UTC())
if err != nil {
t.Fatalf("ClaimStepEntryMarker (position 1): %v", err)
}
if !claimedA || !claimedB {
t.Fatalf("expected both distinct positions to claim independently, got (%v, %v)", claimedA, claimedB)
}
}
func TestCompleteStepEntryMarkerDoneAfterClaim(t *testing.T) {
repo := newStepEntriesTestRepo(t)
entryID := allocateOneStepEntry(t, repo, "task-7", "step-review")
ctx := context.Background()
if _, err := repo.ClaimStepEntryMarker(ctx, entryID, 0, "clear_decisions", "op-1", time.Now().UTC()); err != nil {
t.Fatalf("ClaimStepEntryMarker: %v", err)
}
if err := repo.CompleteStepEntryMarker(ctx, entryID, 0, StepEntryMarkerDone, "", time.Now().UTC()); err != nil {
t.Fatalf("CompleteStepEntryMarker: %v", err)
}
state, markerErr := stepEntryMarkerState(t, repo, entryID, 0)
if state != string(StepEntryMarkerDone) {
t.Fatalf("state = %q, want %q", state, StepEntryMarkerDone)
}
if markerErr != "" {
t.Fatalf("marker error = %q, want empty", markerErr)
}
}
func TestCompleteStepEntryMarkerFailedRecordsCause(t *testing.T) {
repo := newStepEntriesTestRepo(t)
entryID := allocateOneStepEntry(t, repo, "task-8", "step-review")
ctx := context.Background()
if _, err := repo.ClaimStepEntryMarker(ctx, entryID, 1, "queue_run_for_each_participant", "op-1", time.Now().UTC()); err != nil {
t.Fatalf("ClaimStepEntryMarker: %v", err)
}
if err := repo.CompleteStepEntryMarker(ctx, entryID, 1, StepEntryMarkerFailed, "queue_run for participant p1: boom", time.Now().UTC()); err != nil {
t.Fatalf("CompleteStepEntryMarker: %v", err)
}
state, markerErr := stepEntryMarkerState(t, repo, entryID, 1)
if state != string(StepEntryMarkerFailed) {
t.Fatalf("state = %q, want %q", state, StepEntryMarkerFailed)
}
if markerErr != "queue_run for participant p1: boom" {
t.Fatalf("marker error = %q, want the recorded failure cause", markerErr)
}
}
func TestCompleteStepEntryMarkerWithoutClaimIsNoop(t *testing.T) {
repo := newStepEntriesTestRepo(t)
entryID := allocateOneStepEntry(t, repo, "task-9", "step-review")
ctx := context.Background()
// Completing a marker that was never claimed (state is still absent — no
// row at all) must not fabricate a terminal row: there is nothing to
// transition, and a spurious "done" row would let a later claim think
// the action already ran when it never did.
if err := repo.CompleteStepEntryMarker(ctx, entryID, 0, StepEntryMarkerDone, "", time.Now().UTC()); err != nil {
t.Fatalf("CompleteStepEntryMarker: %v", err)
}
state, _ := stepEntryMarkerState(t, repo, entryID, 0)
if state != "" {
t.Fatalf("state = %q, want empty (no row, since no claim ever happened)", state)
}
}
func TestGetStepEntryMarkerStateNoRowReturnsNotFound(t *testing.T) {
repo := newStepEntriesTestRepo(t)
entryID := allocateOneStepEntry(t, repo, "task-10", "step-review")
ctx := context.Background()
_, _, found, err := repo.GetStepEntryMarkerState(ctx, entryID, 0)
if err != nil {
t.Fatalf("GetStepEntryMarkerState: %v", err)
}
if found {
t.Fatalf("found = true, want false (no claim ever happened)")
}
}
func TestGetStepEntryMarkerStateReportsDone(t *testing.T) {
repo := newStepEntriesTestRepo(t)
entryID := allocateOneStepEntry(t, repo, "task-11", "step-review")
ctx := context.Background()
if _, err := repo.ClaimStepEntryMarker(ctx, entryID, 0, "clear_decisions", "op-1", time.Now().UTC()); err != nil {
t.Fatalf("ClaimStepEntryMarker: %v", err)
}
if err := repo.CompleteStepEntryMarker(ctx, entryID, 0, StepEntryMarkerDone, "", time.Now().UTC()); err != nil {
t.Fatalf("CompleteStepEntryMarker: %v", err)
}
state, cause, found, err := repo.GetStepEntryMarkerState(ctx, entryID, 0)
if err != nil {
t.Fatalf("GetStepEntryMarkerState: %v", err)
}
if !found {
t.Fatalf("found = false, want true")
}
if state != StepEntryMarkerDone {
t.Fatalf("state = %q, want %q", state, StepEntryMarkerDone)
}
if cause != "" {
t.Fatalf("cause = %q, want empty", cause)
}
}
func TestGetStepEntryMarkerStateReportsFailedWithCause(t *testing.T) {
repo := newStepEntriesTestRepo(t)
entryID := allocateOneStepEntry(t, repo, "task-12", "step-review")
ctx := context.Background()
if _, err := repo.ClaimStepEntryMarker(ctx, entryID, 0, "clear_decisions", "op-1", time.Now().UTC()); err != nil {
t.Fatalf("ClaimStepEntryMarker: %v", err)
}
if err := repo.CompleteStepEntryMarker(ctx, entryID, 0, StepEntryMarkerFailed, "boom", time.Now().UTC()); err != nil {
t.Fatalf("CompleteStepEntryMarker: %v", err)
}
state, cause, found, err := repo.GetStepEntryMarkerState(ctx, entryID, 0)
if err != nil {
t.Fatalf("GetStepEntryMarkerState: %v", err)
}
if !found {
t.Fatalf("found = false, want true")
}
if state != StepEntryMarkerFailed {
t.Fatalf("state = %q, want %q", state, StepEntryMarkerFailed)
}
if cause != "boom" {
t.Fatalf("cause = %q, want %q", cause, "boom")
}
}
// seedWorkflowStepDecisionsTable creates workflow_step_decisions directly
// (rather than importing internal/workflow/repository, which owns it in
// production) so ClearStepDecisionsAndCompleteMarker has something to
// delete against — see phase2_sqlite.go's decisionsSchema for the DDL this
// mirrors.
func seedWorkflowStepDecisionsTable(t *testing.T, repo *Repository) {
t.Helper()
_, err := repo.db.Exec(`
CREATE TABLE IF NOT EXISTS workflow_step_decisions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
step_id TEXT NOT NULL,
participant_id TEXT NOT NULL,
decision TEXT NOT NULL,
decided_at TIMESTAMP NOT NULL
)
`)
if err != nil {
t.Fatalf("create workflow_step_decisions: %v", err)
}
}
func insertStepDecision(t *testing.T, repo *Repository, id, taskID, stepID string) {
t.Helper()
_, err := repo.db.ExecContext(context.Background(), repo.db.Rebind(`
INSERT INTO workflow_step_decisions (id, task_id, step_id, participant_id, decision, decided_at)
VALUES (?, ?, ?, ?, ?, ?)
`), id, taskID, stepID, "participant-1", "approve", time.Now().UTC())
if err != nil {
t.Fatalf("insert workflow_step_decisions row: %v", err)
}
}
func countStepDecisions(t *testing.T, repo *Repository, taskID, stepID string) int {
t.Helper()
var n int
err := repo.db.QueryRowContext(context.Background(), repo.db.Rebind(
`SELECT COUNT(*) FROM workflow_step_decisions WHERE task_id = ? AND step_id = ?`,
), taskID, stepID).Scan(&n)
if err != nil {
t.Fatalf("count workflow_step_decisions: %v", err)
}
return n
}
func TestClearStepDecisionsAndCompleteMarkerDeletesDecisionsAndCompletesMarker(t *testing.T) {
repo := newStepEntriesTestRepo(t)
seedWorkflowStepDecisionsTable(t, repo)
entryID := allocateOneStepEntry(t, repo, "task-10", "step-review")
ctx := context.Background()
insertStepDecision(t, repo, "dec-1", "task-10", "step-review")
insertStepDecision(t, repo, "dec-2", "task-10", "step-review")
if _, err := repo.ClaimStepEntryMarker(ctx, entryID, 0, "clear_decisions", "op-1", time.Now().UTC()); err != nil {
t.Fatalf("ClaimStepEntryMarker: %v", err)
}
rows, err := repo.ClearStepDecisionsAndCompleteMarker(ctx, "task-10", "step-review", entryID, 0, time.Now().UTC())
if err != nil {
t.Fatalf("ClearStepDecisionsAndCompleteMarker: %v", err)
}
if rows != 2 {
t.Fatalf("rows = %d, want 2", rows)
}
if n := countStepDecisions(t, repo, "task-10", "step-review"); n != 0 {
t.Fatalf("remaining decisions = %d, want 0", n)
}
state, markerErr := stepEntryMarkerState(t, repo, entryID, 0)
if state != string(StepEntryMarkerDone) {
t.Fatalf("marker state = %q, want %q", state, StepEntryMarkerDone)
}
if markerErr != "" {
t.Fatalf("marker error = %q, want empty", markerErr)
}
}
func TestClearStepDecisionsAndCompleteMarkerRejectsMissingIDs(t *testing.T) {
repo := newStepEntriesTestRepo(t)
seedWorkflowStepDecisionsTable(t, repo)
entryID := allocateOneStepEntry(t, repo, "task-11", "step-review")
ctx := context.Background()
insertStepDecision(t, repo, "dec-1", "task-11", "step-review")
if _, err := repo.ClaimStepEntryMarker(ctx, entryID, 0, "clear_decisions", "op-1", time.Now().UTC()); err != nil {
t.Fatalf("ClaimStepEntryMarker: %v", err)
}
if _, err := repo.ClearStepDecisionsAndCompleteMarker(ctx, "", "step-review", entryID, 0, time.Now().UTC()); err == nil {
t.Fatalf("expected an error for an empty task_id")
}
// Nothing should have changed: the validation guard runs before BeginTx.
if n := countStepDecisions(t, repo, "task-11", "step-review"); n != 1 {
t.Fatalf("remaining decisions = %d, want 1 (untouched)", n)
}
state, _ := stepEntryMarkerState(t, repo, entryID, 0)
if state != string(StepEntryMarkerInProgress) {
t.Fatalf("marker state = %q, want %q (untouched)", state, StepEntryMarkerInProgress)
}
}
func stepEntryMarkerState(t *testing.T, repo *Repository, entryID int64, position int) (state, markerErr string) {
t.Helper()
var s, e *string
err := repo.db.QueryRowContext(context.Background(), repo.db.Rebind(`
SELECT state, error FROM workflow_step_entry_markers WHERE entry_id = ? AND position = ?
`), entryID, position).Scan(&s, &e)
if err != nil {
return "", ""
}
if s != nil {
state = *s
}
if e != nil {
markerErr = *e
}
return state, markerErr
}
@@ -597,6 +597,14 @@ func (r *Repository) updateTaskTx(ctx context.Context, tx *sql.Tx, task *models.
task.WorkflowStepTransitionID = transitionID
entryID = formatEntryID(transitionID)
// Both sides kept. pr-2907's allocateStepEntryIfPending is a write with its
// own concern; #3043's entryID is a pure format of transitionID (line above),
// so neither subsumes the other and dropping either loses real behaviour.
// The two-value return is #3043's signature, which this function now carries.
if err := r.allocateStepEntryIfPending(ctx, tx, task.ID, task.UpdatedAt); err != nil {
return "", err
}
if err := syncRunnerInTx(ctx, tx, r.db.Rebind, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID); err != nil {
return "", err
}
@@ -14,18 +14,20 @@ import (
// TestSessionShapedAndSessionIndependentKindsPartitionCompiledOnEnterKinds
// asserts the two classification maps in this file are exhaustive and
// disjoint over exactly the ActionKind values compileOnEnter (types.go) can
// emit. It parses that function's source rather than hand-listing the
// kinds, so a kind added to the compiler later without a deliberate
// classification decision fails this test instead of silently defaulting
// into double execution (dispatched from both HandleTriggerSessionShapedOnly
// and DispatchStepEntry) or silence (dispatched from neither) — see
// disjoint over exactly the ActionKind values CompileOnEnterAction (types.go)
// can emit for a single on_enter declaration — the per-kind switch
// compileOnEnter delegates to. It parses that function's source rather than
// hand-listing the kinds, so a kind added to the compiler later without a
// deliberate classification decision fails this test instead of silently
// defaulting into double execution (dispatched from both
// HandleTriggerSessionShapedOnly and DispatchStepEntry) or silence
// (dispatched from neither) — see
// docs/specs/office/system-design/step-entry-sequence-execution.md
// ("The two lists together are exhaustive").
func TestSessionShapedAndSessionIndependentKindsPartitionCompiledOnEnterKinds(t *testing.T) {
compiled, err := actionKindsAssignedInFunc("types.go", "compileOnEnter")
compiled, err := actionKindsAssignedInFunc("types.go", "CompileOnEnterAction")
require.NoError(t, err)
require.NotEmpty(t, compiled, "compileOnEnter must assign at least one ActionKind for this test to be meaningful")
require.NotEmpty(t, compiled, "CompileOnEnterAction must assign at least one ActionKind for this test to be meaningful")
for _, kind := range compiled {
shaped := isSessionShapedActionKind(kind)
@@ -38,11 +40,11 @@ func TestSessionShapedAndSessionIndependentKindsPartitionCompiledOnEnterKinds(t
for kind := range sessionShapedActionKinds {
assert.Contains(t, compiled, kind,
"sessionShapedActionKinds lists %q, which compileOnEnter no longer emits", kind)
"sessionShapedActionKinds lists %q, which CompileOnEnterAction no longer emits", kind)
}
for kind := range sessionIndependentActionKinds {
assert.Contains(t, compiled, kind,
"sessionIndependentActionKinds lists %q, which compileOnEnter no longer emits", kind)
"sessionIndependentActionKinds lists %q, which CompileOnEnterAction no longer emits", kind)
}
}
@@ -454,6 +454,18 @@ func (c QueueRunForEachParticipantCallback) Execute(ctx context.Context, in Acti
return ActionResult{}, fmt.Errorf("queue_run_for_each_participant list participants: %w", err)
}
reason := queueRunForEachParticipantReason(in)
// Collect-and-continue (AC-C1): one participant's QueueRun failure must
// not abort the fan-out to their siblings — a reviewer whose queue is
// briefly unavailable should not silently block every other reviewer's
// run from ever being queued. Every matching participant gets an attempt
// regardless of earlier failures; the errors are joined and returned
// together so the caller still sees (and can log/retry) every failure.
//
// Merge note (local/integration): #2969 (WO-30) replaced the raw
// participant list with roleSeatsForFanOut, which already filters by
// role and canonicalizes the slate, so #2907's inline `p.Role != cfg.Role`
// guard is redundant here and is dropped rather than duplicated.
var errs []error
for _, p := range seats {
req := QueueRunRequest{
AgentProfileID: p.AgentProfileID,
@@ -463,10 +475,19 @@ func (c QueueRunForEachParticipantCallback) Execute(ctx context.Context, in Acti
IdempotencyKey: idempotencyKey(in, p.AgentProfileID, taskID),
Payload: queueRunPayload(in, cfg.Payload, taskID),
}
// Merge note (local/integration): #3011 gave QueueRun a second return
// value; #2907's fan-out collects errors rather than aborting (AC-C1,
// see the comment above). Take #3011's signature and keep the
// collect-and-continue policy — returning early here would abort the
// fan-out on one participant's failure, which is the exact behaviour
// AC-C1 exists to prevent.
if _, err := c.Adapter.QueueRun(ctx, req); err != nil {
return ActionResult{}, fmt.Errorf("queue_run for participant %s: %w", p.ID, err)
errs = append(errs, fmt.Errorf("queue_run for participant %s: %w", p.ID, err))
}
}
if len(errs) > 0 {
return ActionResult{}, errors.Join(errs...)
}
return ActionResult{}, nil
}
@@ -799,3 +799,63 @@ func TestQueueRunForEachParticipantCallback_MissingDeps_Errors(t *testing.T) {
t.Fatalf("expected ErrActionNotYetWired, got %v", err)
}
}
// selectiveFailRunQueue fails QueueRun only for the agent profile ids listed
// in failFor, recording every call (including the ones it fails) so a test
// can assert the fan-out kept going past the failure.
type selectiveFailRunQueue struct {
calls []QueueRunRequest
failFor map[string]error
}
func (f *selectiveFailRunQueue) QueueRun(_ context.Context, req QueueRunRequest) (QueueOutcome, error) {
f.calls = append(f.calls, req)
if err, ok := f.failFor[req.AgentProfileID]; ok {
return "", err
}
return "", nil
}
// TestQueueRunForEachParticipantCallback_OneParticipantFailureDoesNotAbortFanOut
// pins AC-C1: a single participant's QueueRun error must not stop the
// remaining participants from being queued. Before this fix the loop
// returned on the first error, so rev-B and rev-C never got a call.
func TestQueueRunForEachParticipantCallback_OneParticipantFailureDoesNotAbortFanOut(t *testing.T) {
boom := errors.New("boom")
q := &selectiveFailRunQueue{failFor: map[string]error{"rev-A": boom}}
parts := fakeParticipants{list: []ParticipantInfo{
{ID: "p1", Role: "reviewer", AgentProfileID: "rev-A"},
{ID: "p2", Role: "reviewer", AgentProfileID: "rev-B"},
{ID: "p3", Role: "reviewer", AgentProfileID: "rev-C"},
}}
cb := QueueRunForEachParticipantCallback{Adapter: q, Participants: parts}
in := ActionInput{
Trigger: TriggerOnEnter,
State: MachineState{TaskID: "task-1"},
Step: StepSpec{ID: "step-1"},
Action: Action{
Kind: ActionQueueRunForEachParticipant,
QueueRunForEachParticipant: &QueueRunForEachParticipantAction{
Role: "reviewer",
},
},
}
_, err := cb.Execute(context.Background(), in)
if err == nil {
t.Fatalf("expected the joined error from rev-A's failure to be returned")
}
if !errors.Is(err, boom) {
t.Fatalf("expected the returned error to wrap the participant failure, got %v", err)
}
if len(q.calls) != 3 {
t.Fatalf("expected all 3 participants to receive a QueueRun attempt despite rev-A's failure, got %d calls: %#v", len(q.calls), q.calls)
}
got := []string{q.calls[0].AgentProfileID, q.calls[1].AgentProfileID, q.calls[2].AgentProfileID}
want := []string{"rev-A", "rev-B", "rev-C"}
for i, w := range want {
if got[i] != w {
t.Fatalf("call %d agent = %q, want %q (order: %#v)", i, got[i], w, got)
}
}
}
+63 -38
View File
@@ -311,49 +311,74 @@ func CompileStep(step *wfmodels.WorkflowStep) StepSpec {
func compileOnEnter(step *wfmodels.WorkflowStep) []Action {
actions := make([]Action, 0, len(step.Events.OnEnter))
for _, action := range step.Events.OnEnter {
switch action.Type {
case wfmodels.OnEnterEnablePlanMode:
actions = append(actions, Action{Kind: ActionEnablePlanMode})
case wfmodels.OnEnterAutoStartAgent:
actions = append(actions, Action{Kind: ActionAutoStartAgent, AutoStartAgent: &AutoStartAgentAction{QueueIfBusy: true}})
case wfmodels.OnEnterResetAgentContext:
actions = append(actions, Action{Kind: ActionResetAgentContext})
case wfmodels.OnEnterSetSessionMode:
mode := readSessionMode(action.Config)
if mode == "" {
continue // skip set_session_mode actions with no target mode
}
actions = append(actions, Action{Kind: ActionSetSessionMode, SetSessionMode: &SetSessionModeAction{Mode: mode}})
case wfmodels.OnEnterRunCodeReview:
actions = append(actions, Action{
Kind: ActionRunCodeReview,
RunCodeReview: &RunCodeReviewAction{AgentProfileID: readReviewAgentProfileID(action.Config)},
})
case wfmodels.OnEnterClearDecisions:
actions = append(actions, Action{
Kind: ActionClearDecisions,
ClearDecisions: &ClearDecisionsAction{},
})
case wfmodels.OnEnterQueueRunForEachParticipant:
actions = append(actions, Action{
Kind: ActionQueueRunForEachParticipant,
QueueRunForEachParticipant: readQueueRunForEachParticipantConfig(action.Config),
})
case wfmodels.OnEnterQueueRun:
actions = append(actions, Action{
Kind: ActionQueueRun,
QueueRun: readQueueRunConfig(action.Config),
})
case wfmodels.OnEnterEnsureParticipantSeat:
actions = append(actions, Action{
Kind: ActionEnsureParticipantSeat,
EnsureParticipantSeat: readEnsureParticipantSeatConfig(action.Config),
})
if compiled, ok := CompileOnEnterAction(action); ok {
actions = append(actions, compiled)
}
}
return actions
}
// CompileOnEnterAction compiles a single on_enter declaration into its
// engine Action, or (Action{}, false) when the declaration produces no
// action (an unrecognised type, or a recognised type with config that
// doesn't resolve to anything runnable — e.g. set_session_mode with no
// target mode).
//
// Exported so callers outside the engine's own on_enter evaluation loop —
// specifically internal/orchestrator's processOnEnter, which dispatches
// clear_decisions/queue_run_for_each_participant directly via a step-entry
// marker rather than through Engine.HandleTrigger — can reuse the exact
// same per-kind config parsing instead of duplicating it. See
// docs/specs/workflow-on-enter-action-dispatch/spec.md.
func CompileOnEnterAction(action wfmodels.OnEnterAction) (Action, bool) {
switch action.Type {
case wfmodels.OnEnterEnablePlanMode:
return Action{Kind: ActionEnablePlanMode}, true
case wfmodels.OnEnterAutoStartAgent:
return Action{Kind: ActionAutoStartAgent, AutoStartAgent: &AutoStartAgentAction{QueueIfBusy: true}}, true
case wfmodels.OnEnterResetAgentContext:
return Action{Kind: ActionResetAgentContext}, true
case wfmodels.OnEnterSetSessionMode:
mode := readSessionMode(action.Config)
if mode == "" {
return Action{}, false // no target mode: nothing to run
}
return Action{Kind: ActionSetSessionMode, SetSessionMode: &SetSessionModeAction{Mode: mode}}, true
case wfmodels.OnEnterRunCodeReview:
return Action{
Kind: ActionRunCodeReview,
RunCodeReview: &RunCodeReviewAction{AgentProfileID: readReviewAgentProfileID(action.Config)},
}, true
case wfmodels.OnEnterClearDecisions:
return Action{
Kind: ActionClearDecisions,
ClearDecisions: &ClearDecisionsAction{},
}, true
case wfmodels.OnEnterQueueRunForEachParticipant:
return Action{
Kind: ActionQueueRunForEachParticipant,
QueueRunForEachParticipant: readQueueRunForEachParticipantConfig(action.Config),
}, true
case wfmodels.OnEnterQueueRun:
return Action{
Kind: ActionQueueRun,
QueueRun: readQueueRunConfig(action.Config),
}, true
case wfmodels.OnEnterEnsureParticipantSeat:
// Carried across the CompileOnEnterAction refactor from #3043's
// open-coded switch. Without this case the compiler silently drops
// every ensure_participant_seat action, which is the whole point of
// that PR: nothing else creates reviewer/approver seats, so the slate
// stays empty and queue_run_for_each_participant has nobody to queue.
return Action{
Kind: ActionEnsureParticipantSeat,
EnsureParticipantSeat: readEnsureParticipantSeatConfig(action.Config),
}, true
default:
return Action{}, false
}
}
func compileOnTurnStart(step *wfmodels.WorkflowStep) []Action {
actions := make([]Action, 0, len(step.Events.OnTurnStart))
for _, action := range step.Events.OnTurnStart {
@@ -0,0 +1,153 @@
// Package stepentry carries the durable "step-entry" allocation contract from
// the caller that resolves a workflow step transition (and therefore knows
// the landing step's on_enter declaration) down to the task repository
// transaction that persists the step change. It intentionally has no
// dependency on the task repository package; the repository reads only the
// already-resolved PendingAllocation/AllocationResult shapes carried on
// context, mirroring how internal/steptelemetry threads attribution.
//
// Scope note: this package only recognises clear_decisions and
// queue_run_for_each_participant as engine-owned on_enter kinds for now.
// queue_run and run_code_review on_enter dispatch (AC-A7/AC-A8 in
// docs/specs/workflow-on-enter-action-dispatch/spec.md) are explicitly
// deferred to a later Build round — see that spec's Verification section and
// the task plan's scope note.
package stepentry
import (
"context"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
)
// EnginePosition is one engine-owned on_enter action's declared position
// (0-based, index into the step's on_enter list) and kind, as resolved by
// the caller that has the workflow step in hand.
type EnginePosition struct {
Position int
Kind string
}
// PendingAllocation is what the caller resolving a step transition passes
// down to the write-site transaction: which step is being entered, the
// digest of its full on_enter declaration, and the ordered list of
// engine-owned positions the write site should allocate markers for.
type PendingAllocation struct {
StepID string
Digest string
Positions []EnginePosition
}
// AllocationResult is a mutable out-parameter: the caller creates one, wraps
// it onto the context via WithResultHolder, and reads it back after the
// write-site transaction commits. It is populated by the repository layer.
type AllocationResult struct {
EntryID int64
EntrySeq int64
}
// MarkerState is a step-entry marker's terminal (or in-progress) state.
// Declared here rather than in the task repository package so that
// dispatch-side callers (internal/orchestrator) can reference it without
// importing the repository package's implementation types.
type MarkerState string
const (
// MarkerInProgress is set the moment a claim succeeds.
MarkerInProgress MarkerState = "in_progress"
// MarkerDone marks a successfully executed action.
MarkerDone MarkerState = "done"
// MarkerFailed marks an action whose callback returned an error; the
// error is recorded alongside the marker.
MarkerFailed MarkerState = "failed"
)
type pendingAllocationKey struct{}
type resultHolderKey struct{}
// WithPendingAllocation attaches the resolved allocation input to ctx.
func WithPendingAllocation(ctx context.Context, p PendingAllocation) context.Context {
return context.WithValue(ctx, pendingAllocationKey{}, p)
}
// FromContext reads back a PendingAllocation previously attached with
// WithPendingAllocation. ok is false when none was attached (the common
// case for callers that have not opted into step-entry allocation yet).
func FromContext(ctx context.Context) (PendingAllocation, bool) {
p, ok := ctx.Value(pendingAllocationKey{}).(PendingAllocation)
return p, ok
}
// WithResultHolder attaches a mutable AllocationResult pointer to ctx so the
// repository layer can report the allocated entry identity back to a caller
// several function calls away without changing every intermediate
// signature — the same shape steptelemetry uses for attribution, just with
// an output instead of an input.
func WithResultHolder(ctx context.Context, r *AllocationResult) context.Context {
return context.WithValue(ctx, resultHolderKey{}, r)
}
// ResultHolderFromContext reads back the AllocationResult pointer previously
// attached with WithResultHolder.
func ResultHolderFromContext(ctx context.Context) (*AllocationResult, bool) {
r, ok := ctx.Value(resultHolderKey{}).(*AllocationResult)
return r, ok
}
// IsEngineOwnedOnEnter reports whether an on_enter action kind is dispatched
// by the step-entry marker system as of this Build round. See the package
// doc's scope note for the kinds deliberately excluded for now.
func IsEngineOwnedOnEnter(t wfmodels.OnEnterActionType) bool {
switch t {
case wfmodels.OnEnterClearDecisions, wfmodels.OnEnterQueueRunForEachParticipant:
return true
default:
return false
}
}
// BuildPendingAllocation inspects a step's on_enter declaration and returns
// the PendingAllocation the write site should allocate, plus false when the
// step declares no engine-owned on_enter actions (nothing to allocate).
func BuildPendingAllocation(stepID string, actions []wfmodels.OnEnterAction) (PendingAllocation, bool) {
positions := make([]EnginePosition, 0, len(actions))
for i, action := range actions {
if IsEngineOwnedOnEnter(action.Type) {
positions = append(positions, EnginePosition{Position: i, Kind: string(action.Type)})
}
}
if len(positions) == 0 {
return PendingAllocation{}, false
}
return PendingAllocation{
StepID: stepID,
Digest: ComputeDigest(actions),
Positions: positions,
}, true
}
// ComputeDigest returns a stable digest of a step's full on_enter
// declaration (every action, not just the engine-owned ones), so a
// mid-flight template edit can eventually be detected by comparing an
// entry's stored digest against a freshly computed one. The algorithm and
// encoding are this package's choice (AC-H7): sha256 hex over the ordered
// "position:kind" pairs. Config payloads are deliberately excluded — this
// digest only needs to notice a shape change (actions added/removed/
// reordered/retyped), not a config tweak.
func ComputeDigest(actions []wfmodels.OnEnterAction) string {
var b strings.Builder
for i, action := range actions {
if i > 0 {
b.WriteByte('\n')
}
b.WriteString(strconv.Itoa(i))
b.WriteByte(':')
b.WriteString(string(action.Type))
}
sum := sha256.Sum256([]byte(b.String()))
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,129 @@
package stepentry
import (
"context"
"reflect"
"testing"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
)
func TestComputeDigestDeterministicAndPositionSensitive(t *testing.T) {
a := []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterClearDecisions},
{Type: wfmodels.OnEnterQueueRunForEachParticipant},
}
b := []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterClearDecisions},
{Type: wfmodels.OnEnterQueueRunForEachParticipant},
}
if ComputeDigest(a) != ComputeDigest(b) {
t.Fatalf("expected identical declarations to produce identical digests")
}
reordered := []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterQueueRunForEachParticipant},
{Type: wfmodels.OnEnterClearDecisions},
}
if ComputeDigest(a) == ComputeDigest(reordered) {
t.Fatalf("expected reordered declaration to change the digest")
}
withConfigOnly := []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterClearDecisions, Config: map[string]interface{}{"unused": "value"}},
{Type: wfmodels.OnEnterQueueRunForEachParticipant},
}
if ComputeDigest(a) != ComputeDigest(withConfigOnly) {
t.Fatalf("expected a config-only change to leave the digest unchanged (shape digest, not config digest)")
}
}
func TestIsEngineOwnedOnEnter(t *testing.T) {
cases := map[wfmodels.OnEnterActionType]bool{
wfmodels.OnEnterClearDecisions: true,
wfmodels.OnEnterQueueRunForEachParticipant: true,
wfmodels.OnEnterQueueRun: false,
wfmodels.OnEnterRunCodeReview: false,
wfmodels.OnEnterEnablePlanMode: false,
wfmodels.OnEnterAutoStartAgent: false,
wfmodels.OnEnterResetAgentContext: false,
wfmodels.OnEnterSetSessionMode: false,
}
for kind, want := range cases {
if got := IsEngineOwnedOnEnter(kind); got != want {
t.Errorf("IsEngineOwnedOnEnter(%s) = %v, want %v", kind, got, want)
}
}
}
func TestBuildPendingAllocationNoEngineOwnedActions(t *testing.T) {
actions := []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterEnablePlanMode},
{Type: wfmodels.OnEnterAutoStartAgent},
}
_, ok := BuildPendingAllocation("step-1", actions)
if ok {
t.Fatalf("expected no allocation for a step with no engine-owned on_enter actions")
}
}
func TestBuildPendingAllocationTracksDeclaredPositions(t *testing.T) {
actions := []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterEnablePlanMode},
{Type: wfmodels.OnEnterClearDecisions},
{Type: wfmodels.OnEnterQueueRunForEachParticipant},
}
pending, ok := BuildPendingAllocation("step-1", actions)
if !ok {
t.Fatalf("expected an allocation for a step declaring engine-owned actions")
}
if pending.StepID != "step-1" {
t.Fatalf("expected StepID to be carried through, got %q", pending.StepID)
}
if len(pending.Positions) != 2 {
t.Fatalf("expected 2 engine-owned positions, got %d: %+v", len(pending.Positions), pending.Positions)
}
if pending.Positions[0].Position != 1 || pending.Positions[0].Kind != string(wfmodels.OnEnterClearDecisions) {
t.Errorf("unexpected first position: %+v", pending.Positions[0])
}
if pending.Positions[1].Position != 2 || pending.Positions[1].Kind != string(wfmodels.OnEnterQueueRunForEachParticipant) {
t.Errorf("unexpected second position: %+v", pending.Positions[1])
}
if pending.Digest != ComputeDigest(actions) {
t.Errorf("expected pending.Digest to match ComputeDigest(actions)")
}
}
func TestPendingAllocationContextRoundTrip(t *testing.T) {
ctx := context.Background()
if _, ok := FromContext(ctx); ok {
t.Fatalf("expected no pending allocation on a bare context")
}
pending := PendingAllocation{StepID: "step-1", Digest: "abc"}
ctx = WithPendingAllocation(ctx, pending)
got, ok := FromContext(ctx)
if !ok {
t.Fatalf("expected pending allocation to round-trip through context")
}
if !reflect.DeepEqual(got, pending) {
t.Errorf("got %+v, want %+v", got, pending)
}
}
func TestResultHolderContextRoundTrip(t *testing.T) {
ctx := context.Background()
if _, ok := ResultHolderFromContext(ctx); ok {
t.Fatalf("expected no result holder on a bare context")
}
holder := &AllocationResult{}
ctx = WithResultHolder(ctx, holder)
got, ok := ResultHolderFromContext(ctx)
if !ok {
t.Fatalf("expected result holder to round-trip through context")
}
got.EntryID = 42
got.EntrySeq = 3
if holder.EntryID != 42 || holder.EntrySeq != 3 {
t.Fatalf("expected mutations through the round-tripped pointer to reach the original holder")
}
}
+1
View File
@@ -36,5 +36,6 @@ The following specification was added after this migration and remains in the le
- [Task Cost & Token Ledger](task-cost-ledger/spec.md) (draft)
- [Startup listener before recovery](startup-listener-before-recovery/spec.md) (draft)
- [Workflow on_enter action dispatch](workflow-on-enter-action-dispatch/spec.md) (draft)
## Authoring rule
+2 -1
View File
@@ -11,6 +11,7 @@
},
"legacy_size_exceptions": {
"docs/specs/office-task-content-editing/spec.md": 84103,
"docs/specs/task-cost-ledger/spec.md": 91931
"docs/specs/task-cost-ledger/spec.md": 91931,
"docs/specs/workflow-on-enter-action-dispatch/spec.md": 116869
}
}
@@ -43,4 +43,3 @@ The UI system owns this presentation contract. The task system supplies the orde
- Changes to repository attachment order.
- Changes to saved-view persistence or group-collapse persistence.
- Changes to group-header layout, truncation, or touch behavior.
File diff suppressed because it is too large Load Diff