PR #3294
Sections
Review

fix(office): stop the reject leg from waking the reviewer, not the assignee

main ← feature/unit-7-reject-leg-wa-waj 13 files +312 −48 PR #3294 ↗

The reject verdict now wakes the task assignee with the reviewer comment and a decision-scoped idempotency key, and the orchestrator no longer prompts the reviewer session on the reject transition.

Why this change

An agent reviewer that rejects a task woke its own session into Work instead of the assignee, and a second review round was suppressed by a once-per-task idempotency key.

What it does

Architecture, end to end

Reviewer decision flows through dashboard reactivity to the scheduler, then to the agent prompt. The orchestrator moves the workflow step but no longer continues the reviewer session.

flowchart LR
  Reviewer[Reviewer agent\nrecord_step_decision rejected] --> Dashboard[DashboardService\nbuildDecisionRuns]
  Dashboard --> Scheduler[SchedulerService\nQueueRunCtx]
  Scheduler --> Run[(Run queue\nreason=task_changes_requested)]
  Run --> Prompt[Prompt builder\nbuildReworkPrompt]
  Prompt --> Assignee[Assignee agent]
  Dashboard -.-> Engine[Workflow engine\nquorum re-evaluation]
  Engine --> Orchestrator[Orchestrator\napplyGuardedTransitionLifecycle]
  Orchestrator -- step moves Review to Work --> Task[(Task)]
  Orchestrator -.->|no session continuation| Reviewer

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

const DecisionRejected = "rejected"
Click for details →

Adds the agent-path literal so dashboard code can treat rejected and changes_requested as the same rework signal.

Decision constants
const (
	DecisionApproved         = "approved"
	DecisionChangesRequested = "changes_requested"
	// DecisionRejected is the agent-path verdict literal
	// (engine.DecisionRejected) for the same semantic as
	// DecisionChangesRequested — the quorum engine already treats them as
	// synonyms (isRejectionVerdict in internal/workflow/engine/quorum.go).
	DecisionRejected = "rejected"

	DeciderTypeUser  = "user"
	DeciderTypeAgent = "agent"
)
Assignee wake for rejected + decision-scoped keyapps/backend/internal/office/dashboard/decisions.go ↗
func (s *DashboardService) buildDecisionRuns(ctx context.Context, d *DecisionRecord, exec *sqlite.TaskExecutionFields) []ApprovalRun
Click for details →

Queues the assignee for both rejected and changes_requested, carries the reviewer comment, and scopes the wake to the decision ID.

buildDecisionRuns
func (s *DashboardService) buildDecisionRuns(
	ctx context.Context, d *DecisionRecord, exec *sqlite.TaskExecutionFields,
) []ApprovalRun {
	switch d.Decision {
	case models.DecisionChangesRequested, models.DecisionRejected:
		return []ApprovalRun{{
			AgentID:         exec.AssigneeAgentProfileID,
			Reason:          runTaskChangesRequested,
			TaskID:          d.TaskID,
			WorkspaceID:     exec.WorkspaceID,
			ActorID:         d.DeciderID,
			ActorType:       d.DeciderType,
			DecisionComment: d.Comment,
			IdempotencyKey:  decisionRunIdempotencyKey(d),
		}}
	case models.DecisionApproved:
		if !s.allApproversApproved(ctx, d.TaskID) {
			return nil
		}
		if !isReviewState(exec.State) {
			return nil
		}
		return []ApprovalRun{{
			AgentID:        exec.AssigneeAgentProfileID,
			Reason:         runTaskReadyToClose,
			TaskID:         d.TaskID,
			WorkspaceID:    exec.WorkspaceID,
			ActorID:        d.DeciderID,
			ActorType:      d.DeciderType,
			IdempotencyKey: decisionRunIdempotencyKey(d),
		}}
	}
	return nil
}
decisionRunIdempotencyKey
// decisionRunIdempotencyKey scopes an approval-flow wake to the durable
// decision that produced it. A task may enter review more than once, so the
// scheduler's default (reason, task, agent) key would suppress later rounds.
func decisionRunIdempotencyKey(d *DecisionRecord) string {
	if d == nil || d.ID == "" {
		return ""
	}
	return "decision:" + d.ID
}
func (a *DashboardApprovalAdapter) QueueApprovalRuns(ctx context.Context, runs []dashboard.ApprovalRun) error
Click for details →

Translates the dashboard ApprovalRun into a scheduler RunContext without dropping the idempotency key or decision comment.

QueueApprovalRuns
func (a *DashboardApprovalAdapter) QueueApprovalRuns(
	ctx context.Context, runs []dashboard.ApprovalRun,
) error {
	if a.scheduler == nil {
		return nil
	}
	for _, w := range runs {
		if w.AgentID == "" || w.Reason == "" {
			continue
		}
		c := RunContext{
			Reason:          w.Reason,
			TaskID:          w.TaskID,
			WorkspaceID:     w.WorkspaceID,
			ActorID:         w.ActorID,
			ActorType:       w.ActorType,
			Role:            w.Role,
			DecisionComment: w.DecisionComment,
			IdempotencyKey:  w.IdempotencyKey,
		}
		if err := a.scheduler.QueueRunCtx(ctx, w.AgentID, c); err != nil {
			a.scheduler.logger.Warn("approval run failed: " + err.Error())
		}
	}
	return nil
}
Rework prompt for task_changes_requestedapps/backend/internal/office/service/prompt_builder.go ↗
func BuildPrompt(pc *PromptContext) string
Click for details →

Routes the task_changes_requested reason to the rework prompt so the assignee sees reviewer feedback instead of a generic wake message.

BuildPrompt switch
func BuildPrompt(pc *PromptContext) string {
	var prompt string
	switch pc.Reason {
	case RunReasonTaskAssigned:
		prompt = buildTaskAssignedPrompt(pc)
	case legacyRunReasonReviewStarted:
		prompt = buildLegacyStagePrompt(pc, stageTypeReview)
	case legacyRunReasonApprovalStarted:
		prompt = buildLegacyStagePrompt(pc, stageTypeApproval)
	case RunReasonTaskReviewRequested:
		prompt = buildTaskAssignedPrompt(pc)
	case RunReasonTaskChangesRequested:
		prompt = buildReworkPrompt(pc)
	case RunReasonTaskComment:
		prompt = buildTaskCommentPrompt(pc)
	case RunReasonTaskBlockersResolved:
		prompt = buildBlockersResolvedPrompt(pc)
	case legacyRunReasonBlockersResolved:
		prompt = buildBlockersResolvedPrompt(pc)
	case RunReasonTaskChildrenCompleted:
		prompt = buildChildrenCompletedPrompt(pc)
	case legacyRunReasonChildrenCompleted:
		prompt = buildChildrenCompletedPrompt(pc)
	case RunReasonApprovalResolved:
		prompt = buildApprovalResolvedPrompt(pc)
	case RunReasonHeartbeat:
		prompt = buildHeartbeatPrompt(pc)
	case RunReasonBudgetAlert:
		prompt = buildBudgetAlertPrompt(pc)
	case RunReasonAgentError:
		prompt = buildAgentErrorPrompt(pc)
	default:
		prompt = fmt.Sprintf("You have been woken for reason: %s.", pc.Reason)
	}
	prompt = appendHandoffSection(prompt, pc.HandoffContext)
	return appendRuntimeContext(prompt, pc)
}
buildReworkPrompt
func buildReworkPrompt(pc *PromptContext) string {
	var b strings.Builder
	fmt.Fprintf(&b, "Task %s: %s was returned by reviewers with feedback.\n", taskRef(pc), pc.TaskTitle)
	fmt.Fprintf(&b, "\nReviewer feedback:\n%s\n", pc.ReviewFeedback)
	b.WriteString("\nAddress the feedback and resubmit your changes.")
	return b.String()
}
func (s *Service) applyGuardedTransitionLifecycle(ctx context.Context, taskID, sessionID, fromStepID, toStepID string, trigger engine.Trigger) (bool, error)
Click for details →

Introduces a guarded-decision lifecycle mode that moves the task step but skips session on_exit and on_enter continuation, so the reviewer session is not prompted into Work.

Lifecycle modes
// transitionLifecycleMode identifies the caller-owned part of a transition.
// Guarded decisions use a session-independent mode because the session passed
// by the engine is the decider's session, not the assignee's destination
// session.
type transitionLifecycleMode uint8

const (
	transitionLifecycleWithOnEnter transitionLifecycleMode = iota
	transitionLifecycleOnTurnStart
	transitionLifecycleGuardedDecision
)
applyGuardedTransitionLifecycle
func (s *Service) applyGuardedTransitionLifecycle(
	ctx context.Context, taskID, sessionID, fromStepID, toStepID string, trigger engine.Trigger,
) (bool, error) {
	if s.workflowStore == nil {
		return false, errors.New("workflow store is not initialized")
	}
	task, err := s.repo.GetTask(ctx, taskID)
	if err != nil {
		return false, fmt.Errorf("load task for guarded transition lifecycle: %w", err)
	}
	session, err := s.repo.GetTaskSession(ctx, sessionID)
	if err != nil {
		return false, fmt.Errorf("load session for guarded transition lifecycle: %w", err)
	}
	casAttempted := false
	casApplied := false
	lifecycleApplied := s.applyEngineTransitionWithCommitMode(
		ctx,
		taskID,
		session,
		engine.HandleResult{Transitioned: true, FromStepID: fromStepID, ToStepID: toStepID},
		trigger,
		task.Description,
		transitionLifecycleGuardedDecision,
		func(commitCtx context.Context) (bool, error) {
			casAttempted = true
			transitionCtx := commitCtx
			if !steptelemetry.HasTrigger(transitionCtx) {
				transitionCtx = engineTransitionAttribution(transitionCtx, sessionID, trigger)
			}
			committedTask, oldWorkflowID, applied, commitErr := s.workflowStore.applyTransitionIfAtStepRaw(
				transitionCtx, taskID, fromStepID, toStepID,
			)
			if commitErr != nil {
				return false, commitErr
			}
			if !applied {
				return false, nil
			}
			casApplied = true
			s.publishTaskUpdated(ctx, committedTask, oldWorkflowID)
			s.workflowStore.pullNextTaskOnVacate(ctx, fromStepID, taskID)
			return true, nil
		},
	)
	if lifecycleApplied || casApplied {
		return true, nil
	}
	if casAttempted {
		return false, nil
	}
	return false, errors.New("guarded transition lifecycle did not apply")
}
Session lifecycle gate
func (s *Service) applyEngineTransitionWithCommitMode(
	ctx context.Context, taskID string, session *models.TaskSession,
	result engine.HandleResult, trigger engine.Trigger, taskDescription string,
	mode transitionLifecycleMode, commit func(context.Context) (bool, error),
) bool {
	ctx = withWorkflowMetaCache(ctx)
	sessionLifecycle := mode != transitionLifecycleGuardedDecision
	// ...
	if sessionLifecycle {
		s.processOnExit(ctx, taskID, session, fromStep)
	}
	// ...
	if mode == transitionLifecycleGuardedDecision {
		return true
	}
	if mode == transitionLifecycleOnTurnStart {
		effectiveSession, ok := s.maybySwitchSessionForProfile(ctx, taskID, session, targetStep, fromStep)
		if !ok {
			return false
		}
		s.setSessionWaitingForInput(ctx, taskID, effectiveSession.ID)
		return true
	}
}
Read the changes as a list

New rejected verdict constant

apps/backend/internal/office/models/models.go

Adds the agent-path literal so dashboard code can treat rejected and changes_requested as the same rework signal.

Decision constants
const (
	DecisionApproved         = "approved"
	DecisionChangesRequested = "changes_requested"
	// DecisionRejected is the agent-path verdict literal
	// (engine.DecisionRejected) for the same semantic as
	// DecisionChangesRequested — the quorum engine already treats them as
	// synonyms (isRejectionVerdict in internal/workflow/engine/quorum.go).
	DecisionRejected = "rejected"

	DeciderTypeUser  = "user"
	DeciderTypeAgent = "agent"
)

Assignee wake for rejected + decision-scoped key

apps/backend/internal/office/dashboard/decisions.go

Queues the assignee for both rejected and changes_requested, carries the reviewer comment, and scopes the wake to the decision ID.

buildDecisionRuns
func (s *DashboardService) buildDecisionRuns(
	ctx context.Context, d *DecisionRecord, exec *sqlite.TaskExecutionFields,
) []ApprovalRun {
	switch d.Decision {
	case models.DecisionChangesRequested, models.DecisionRejected:
		return []ApprovalRun{{
			AgentID:         exec.AssigneeAgentProfileID,
			Reason:          runTaskChangesRequested,
			TaskID:          d.TaskID,
			WorkspaceID:     exec.WorkspaceID,
			ActorID:         d.DeciderID,
			ActorType:       d.DeciderType,
			DecisionComment: d.Comment,
			IdempotencyKey:  decisionRunIdempotencyKey(d),
		}}
	case models.DecisionApproved:
		if !s.allApproversApproved(ctx, d.TaskID) {
			return nil
		}
		if !isReviewState(exec.State) {
			return nil
		}
		return []ApprovalRun{{
			AgentID:        exec.AssigneeAgentProfileID,
			Reason:         runTaskReadyToClose,
			TaskID:         d.TaskID,
			WorkspaceID:    exec.WorkspaceID,
			ActorID:        d.DeciderID,
			ActorType:      d.DeciderType,
			IdempotencyKey: decisionRunIdempotencyKey(d),
		}}
	}
	return nil
}
decisionRunIdempotencyKey
// decisionRunIdempotencyKey scopes an approval-flow wake to the durable
// decision that produced it. A task may enter review more than once, so the
// scheduler's default (reason, task, agent) key would suppress later rounds.
func decisionRunIdempotencyKey(d *DecisionRecord) string {
	if d == nil || d.ID == "" {
		return ""
	}
	return "decision:" + d.ID
}

Scheduler adapter propagates wake context

apps/backend/internal/office/scheduler/approval_adapter.go

Translates the dashboard ApprovalRun into a scheduler RunContext without dropping the idempotency key or decision comment.

QueueApprovalRuns
func (a *DashboardApprovalAdapter) QueueApprovalRuns(
	ctx context.Context, runs []dashboard.ApprovalRun,
) error {
	if a.scheduler == nil {
		return nil
	}
	for _, w := range runs {
		if w.AgentID == "" || w.Reason == "" {
			continue
		}
		c := RunContext{
			Reason:          w.Reason,
			TaskID:          w.TaskID,
			WorkspaceID:     w.WorkspaceID,
			ActorID:         w.ActorID,
			ActorType:       w.ActorType,
			Role:            w.Role,
			DecisionComment: w.DecisionComment,
			IdempotencyKey:  w.IdempotencyKey,
		}
		if err := a.scheduler.QueueRunCtx(ctx, w.AgentID, c); err != nil {
			a.scheduler.logger.Warn("approval run failed: " + err.Error())
		}
	}
	return nil
}

Rework prompt for task_changes_requested

apps/backend/internal/office/service/prompt_builder.go

Routes the task_changes_requested reason to the rework prompt so the assignee sees reviewer feedback instead of a generic wake message.

BuildPrompt switch
func BuildPrompt(pc *PromptContext) string {
	var prompt string
	switch pc.Reason {
	case RunReasonTaskAssigned:
		prompt = buildTaskAssignedPrompt(pc)
	case legacyRunReasonReviewStarted:
		prompt = buildLegacyStagePrompt(pc, stageTypeReview)
	case legacyRunReasonApprovalStarted:
		prompt = buildLegacyStagePrompt(pc, stageTypeApproval)
	case RunReasonTaskReviewRequested:
		prompt = buildTaskAssignedPrompt(pc)
	case RunReasonTaskChangesRequested:
		prompt = buildReworkPrompt(pc)
	case RunReasonTaskComment:
		prompt = buildTaskCommentPrompt(pc)
	case RunReasonTaskBlockersResolved:
		prompt = buildBlockersResolvedPrompt(pc)
	case legacyRunReasonBlockersResolved:
		prompt = buildBlockersResolvedPrompt(pc)
	case RunReasonTaskChildrenCompleted:
		prompt = buildChildrenCompletedPrompt(pc)
	case legacyRunReasonChildrenCompleted:
		prompt = buildChildrenCompletedPrompt(pc)
	case RunReasonApprovalResolved:
		prompt = buildApprovalResolvedPrompt(pc)
	case RunReasonHeartbeat:
		prompt = buildHeartbeatPrompt(pc)
	case RunReasonBudgetAlert:
		prompt = buildBudgetAlertPrompt(pc)
	case RunReasonAgentError:
		prompt = buildAgentErrorPrompt(pc)
	default:
		prompt = fmt.Sprintf("You have been woken for reason: %s.", pc.Reason)
	}
	prompt = appendHandoffSection(prompt, pc.HandoffContext)
	return appendRuntimeContext(prompt, pc)
}
buildReworkPrompt
func buildReworkPrompt(pc *PromptContext) string {
	var b strings.Builder
	fmt.Fprintf(&b, "Task %s: %s was returned by reviewers with feedback.\n", taskRef(pc), pc.TaskTitle)
	fmt.Fprintf(&b, "\nReviewer feedback:\n%s\n", pc.ReviewFeedback)
	b.WriteString("\nAddress the feedback and resubmit your changes.")
	return b.String()
}

Guarded transition no longer wakes reviewer

apps/backend/internal/orchestrator/event_handlers_workflow.go

Introduces a guarded-decision lifecycle mode that moves the task step but skips session on_exit and on_enter continuation, so the reviewer session is not prompted into Work.

Lifecycle modes
// transitionLifecycleMode identifies the caller-owned part of a transition.
// Guarded decisions use a session-independent mode because the session passed
// by the engine is the decider's session, not the assignee's destination
// session.
type transitionLifecycleMode uint8

const (
	transitionLifecycleWithOnEnter transitionLifecycleMode = iota
	transitionLifecycleOnTurnStart
	transitionLifecycleGuardedDecision
)
applyGuardedTransitionLifecycle
func (s *Service) applyGuardedTransitionLifecycle(
	ctx context.Context, taskID, sessionID, fromStepID, toStepID string, trigger engine.Trigger,
) (bool, error) {
	if s.workflowStore == nil {
		return false, errors.New("workflow store is not initialized")
	}
	task, err := s.repo.GetTask(ctx, taskID)
	if err != nil {
		return false, fmt.Errorf("load task for guarded transition lifecycle: %w", err)
	}
	session, err := s.repo.GetTaskSession(ctx, sessionID)
	if err != nil {
		return false, fmt.Errorf("load session for guarded transition lifecycle: %w", err)
	}
	casAttempted := false
	casApplied := false
	lifecycleApplied := s.applyEngineTransitionWithCommitMode(
		ctx,
		taskID,
		session,
		engine.HandleResult{Transitioned: true, FromStepID: fromStepID, ToStepID: toStepID},
		trigger,
		task.Description,
		transitionLifecycleGuardedDecision,
		func(commitCtx context.Context) (bool, error) {
			casAttempted = true
			transitionCtx := commitCtx
			if !steptelemetry.HasTrigger(transitionCtx) {
				transitionCtx = engineTransitionAttribution(transitionCtx, sessionID, trigger)
			}
			committedTask, oldWorkflowID, applied, commitErr := s.workflowStore.applyTransitionIfAtStepRaw(
				transitionCtx, taskID, fromStepID, toStepID,
			)
			if commitErr != nil {
				return false, commitErr
			}
			if !applied {
				return false, nil
			}
			casApplied = true
			s.publishTaskUpdated(ctx, committedTask, oldWorkflowID)
			s.workflowStore.pullNextTaskOnVacate(ctx, fromStepID, taskID)
			return true, nil
		},
	)
	if lifecycleApplied || casApplied {
		return true, nil
	}
	if casAttempted {
		return false, nil
	}
	return false, errors.New("guarded transition lifecycle did not apply")
}
Session lifecycle gate
func (s *Service) applyEngineTransitionWithCommitMode(
	ctx context.Context, taskID string, session *models.TaskSession,
	result engine.HandleResult, trigger engine.Trigger, taskDescription string,
	mode transitionLifecycleMode, commit func(context.Context) (bool, error),
) bool {
	ctx = withWorkflowMetaCache(ctx)
	sessionLifecycle := mode != transitionLifecycleGuardedDecision
	// ...
	if sessionLifecycle {
		s.processOnExit(ctx, taskID, session, fromStep)
	}
	// ...
	if mode == transitionLifecycleGuardedDecision {
		return true
	}
	if mode == transitionLifecycleOnTurnStart {
		effectiveSession, ok := s.maybySwitchSessionForProfile(ctx, taskID, session, targetStep, fromStep)
		if !ok {
			return false
		}
		s.setSessionWaitingForInput(ctx, taskID, effectiveSession.ID)
		return true
	}
}

Data and storage

Decision and run rows touched by the fix. The idempotency key is the only new persisted field on the run path.

FieldTypeNotes
workflow_step_decisions.decisiontextnow stores rejected as synonym of changes_requested
ApprovalRun.IdempotencyKeystringdecision:ID, passed to RunContext
RunContext.IdempotencyKeystringscheduler adapter field, persisted as runs.idempotency_key
runs.idempotency_keytext nullabledecision:decision-1, distinct per review round
runs.payload.decision_commentjson stringreviewer reason, rendered as ReviewFeedback in prompt

Risk

5 / 10 Medium
1 low5 medium10 high

Why this score

  • Touches the approval quorum path and the orchestrator transition lifecycle, both high-fanout areas.
  • Idempotency key change is additive and scoped to decision:ID, but a wrong key would suppress or duplicate wakes.
  • Covered by new unit tests for rejected assignee wake, distinct decision keys, and the guarded-transition no-prompt proof.

Trade-offs and review notes

Where to look first

  1. Check buildDecisionRuns handles both rejected and changes_requested and sets decision:ID for both approved and rework wakes.
  2. Confirm DashboardApprovalAdapter copies IdempotencyKey and DecisionComment into RunContext and that QueueRunCtx persists them.
  3. Verify applyGuardedTransitionLifecycle uses transitionLifecycleGuardedDecision and that sessionLifecycle gates on_exit, on_enter, signal clear, and data patch.
  4. Read the new regression test that proves the reviewer session is not prompted and the assignee is woken via Office reactivity.