PR #2967
Sections
Review

fix(orchestrator): auto-start tasks created directly on a start step

main ← feature/wo-36-heavy-routine-hsz 9 files +412 −18 PR #2967 ↗

Heavy routine tasks now auto-start when created directly on the Routine workflow start step, by stamping a create-time opt-in and handling task.created in the orchestrator.

Why this change

A task created directly on a step with on_enter auto_start_agent never started. All auto-start paths assumed a transition into the step, so a fresh task that lands on its start step had no on_enter evaluation.

What it does

Architecture, end to end

The routine dispatcher creates a task directly on the Routine start step. The new task.created path evaluates on_enter and starts the agent.

flowchart LR
  Dispatcher[Routines dispatcher] --> Creator[CreateOfficeTaskInWorkflow]
  Creator --> Task[(Task row)]
  Task --> Event[task.created event]
  Event --> Handler[handleTaskCreated]
  Handler --> Gate{Has opt-in? IsFromOffice? Queued?}
  Gate -- yes --> AutoStart[autoStartTaskForStep]
  AutoStart --> StepCheck{Step has auto_start_agent?}
  StepCheck -- yes --> Launch[StartTask / queue run]
  Gate -- no --> Skip[No launch]
  StepCheck -- no --> Skip

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 MetaKeyAutoStartOnCreate = "auto_start_on_create"
Click for details →

The key marks a task that wants create-time on_enter evaluation. Absence means no opinion, so existing producers keep their behavior.

Metadata key
// MetaKeyAutoStartOnCreate is a positive opt-in a task creator stamps when
// it wants task.created to evaluate the destination step's on_enter
// actions immediately, as if creation were itself a transition into that
// step. Absence is the default and preserves existing behavior for every
// other producer (REST/MCP/WS create with or without start_agent /
// prepare_session, CreateChildTask, etc.) — those already have their own
// launch decision, and task.created must not second-guess it. Set today
// only by CreateOfficeTaskInWorkflow for materialized heavy-routine runs,
// whose Routine workflow start step has no other transition to carry it
// into an auto_start_agent evaluation.
MetaKeyAutoStartOnCreate = "auto_start_on_create"
Helper
// HasAutoStartOnCreateIntent reports whether task metadata carries the
// positive MetaKeyAutoStartOnCreate opt-in. Only an explicit true value
// counts — absence (the default for nearly every task producer) must never
// be read as "please auto-start me".
func HasAutoStartOnCreateIntent(metadata map[string]interface{}) bool {
  intent, ok := metadata[MetaKeyAutoStartOnCreate].(bool)
  return ok && intent
}
func (a *taskCreatorAdapter) CreateOfficeTaskInWorkflow(...)
Click for details →

The Routine workflow start step pins no agent, so the task must carry both the opt-in and the assignee fallback for the kanban auto-start path.

CreateOfficeTaskInWorkflow
func (a *taskCreatorAdapter) CreateOfficeTaskInWorkflow(
  ctx context.Context, workspaceID, projectID, assigneeAgentID, workflowID, title, description string,
) (string, error) {
  metadata := map[string]interface{}{
    // The Routine workflow's start step carries on_enter:
    // auto_start_agent, but a materialized run lands directly on that
    // step rather than transitioning into it, so nothing would
    // otherwise evaluate on_enter for it. This opts the task into
    // handleTaskCreated's create-time on_enter evaluation.
    models.MetaKeyAutoStartOnCreate: true,
  }
  if assigneeAgentID != "" {
    // The Routine workflow's start step pins no agent (routine.yml), so
    // the kanban auto-start path's fallback read of
    // task.Metadata[MetaKeyAgentProfileID] is what lets a materialized
    // heavy-routine task actually launch with the routine's assignee.
    metadata[models.MetaKeyAgentProfileID] = assigneeAgentID
  }
  result, err := a.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{
    WorkspaceID:            workspaceID,
    WorkflowID:             workflowID,
    Title:                  title,
    Description:            description,
    ProjectID:              projectID,
    AssigneeAgentProfileID: assigneeAgentID,
    Metadata:               metadata,
    Origin:                 models.TaskOriginOnboarding,
  })
  if err != nil {
    return "", err
  }
  return result.Task.ID, nil
}
func (s *Service) handleTaskCreated(ctx context.Context, data watcher.TaskEventData)
Click for details →

The handler claims the one-shot opt-in token and forwards to the existing auto-start pipeline, with guards for office tasks, queued tasks, and duplicate deliveries.

handleTaskCreated
func (s *Service) handleTaskCreated(ctx context.Context, data watcher.TaskEventData) {
  task, err := s.repo.GetTask(ctx, data.TaskID)
  if err != nil || task == nil {
    if err != nil {
      s.logger.Warn("task.created: failed to load task", zap.String("task_id", data.TaskID), zap.Error(err))
    }
    return
  }
  if task.IsFromOffice || !models.HasAutoStartOnCreateIntent(task.Metadata) {
    return
  }
  if !s.claimTaskEventMetadata(ctx, task, models.MetaKeyAutoStartOnCreate) {
    return
  }
  s.autoStartTaskForStep(ctx, task.ID, task.WorkflowStepID, events.TaskCreated, data.StepTransitionID)
}
handlers := watcher.EventHandlers{ OnTaskCreated: s.handleTaskCreated }
Click for details →

The watcher now delivers task.created events to the new handler, alongside the existing task.moved and task.queue_promoted handlers.

EventHandlers wiring
handlers := watcher.EventHandlers{
  OnTaskDeleted:          s.handleTaskDeleted,
  OnTaskStateChanged:     s.handleTaskStateChanged,
  OnAgentRunning:         s.handleAgentRunning,
  OnAgentBootReady:       s.handleAgentBootReady,
  OnAgentReady:           s.handleAgentReady,
  OnAgentCompleted:       s.handleAgentCompleted,
  OnAgentFailed:          s.handleAgentFailed,
  OnAgentStalled:         s.handleAgentStalled,
  OnAgentStopped:         s.handleAgentStopped,
  OnAgentStreamEvent:     s.handleAgentStreamEvent,
  OnACPSessionCreated:    s.handleACPSessionCreated,
  OnPermissionRequest:    s.handlePermissionRequest,
  OnGitEvent:             s.handleGitEvent,
  OnContextWindowUpdated: s.handleContextWindowUpdated,
  OnTaskMoved:            s.handleTaskMoved,
  OnTaskQueuePromoted:    s.handleTaskQueuePromoted,
  OnTaskCreated:          s.handleTaskCreated,
}
s.watcher = watcher.NewWatcher(eventBus, handlers, cfg.QueueGroup, log)
func TestHandleTaskCreated(t *testing.T)
Click for details →

The tests prove the happy path launches, and that queued tasks, missing opt-in, office tasks, and duplicate deliveries do not launch.

Happy path and guards
func TestHandleTaskCreated(t *testing.T) {
  ctx := context.Background()
  t.Run("launches a session for a task created directly on an auto-start step", func(t *testing.T) {
    // metadata carries MetaKeyAutoStartOnCreate + MetaKeyAgentProfileID
    // step has OnEnterAutoStartAgent, mock agent captures launch
    svc.handleTaskCreated(ctx, watcher.TaskEventData{TaskID: "t1"})
    // asserts launched agentProfileID == "routine-assignee"
  })
  t.Run("skips a queued task even when it carries the create-time opt-in", func(t *testing.T) {
    // QueuedForStepID set -> GetStepCalls() must stay 0
  })
  t.Run("skips a task created on an auto-start step without the create-time opt-in (R-1)", func(t *testing.T) {
    // no MetaKeyAutoStartOnCreate -> GetStepCalls() == 0
  })
  t.Run("does not launch an office task even when it carries the create-time opt-in (R-2)", func(t *testing.T) {
    // IsFromOffice true -> GetStepCalls() == 0
  })
  t.Run("claims the create-time opt-in so a duplicate delivery cannot double-launch", func(t *testing.T) {
    // first delivery removes key, second delivery sees no key
  })
}
func TestCreateOfficeTaskInWorkflowCarriesAssigneeIntoLaunchMetadata(t *testing.T)
Click for details →

The adapter test checks that the assignee lands in metadata and the opt-in is always set, with and without an assignee.

Adapter test
func TestCreateOfficeTaskInWorkflowCarriesAssigneeIntoLaunchMetadata(t *testing.T) {
  adapter, taskSvc := newOfficeTaskAdapterHarness(t)
  workflowID := workflows[0].ID
  taskID, _ := adapter.CreateOfficeTaskInWorkflow(ctx, "ws-1", "", "routine-assignee", workflowID, "Routine run", "Materialized run")
  task, _ := taskSvc.GetTask(ctx, taskID)
  // task.Metadata[MetaKeyAgentProfileID] == "routine-assignee"
  // HasAutoStartOnCreateIntent(task.Metadata) == true
  noAssigneeTaskID, _ := adapter.CreateOfficeTaskInWorkflow(ctx, "ws-1", "", "", workflowID, "Unassigned run", "Materialized run")
  // Metadata[AgentProfileID] absent, but HasAutoStartOnCreateIntent == true
}
Mock helper
type mockStepGetter struct {
  steps        map[string]*wfmodels.WorkflowStep
  getStepCalls int
  getStepMu    sync.Mutex
}
func (m *mockStepGetter) GetStep(_ context.Context, stepID string) (*wfmodels.WorkflowStep, error) {
  m.getStepMu.Lock()
  m.getStepCalls++
  m.getStepMu.Unlock()
  if s, ok := m.steps[stepID]; ok {
    return s, nil
  }
  return nil, nil
}
func (m *mockStepGetter) GetStepCalls() int {
  m.getStepMu.Lock()
  defer m.getStepMu.Unlock()
  return m.getStepCalls
}
Read the changes as a list

New create-time opt-in key

apps/backend/internal/task/models/models.go

The key marks a task that wants create-time on_enter evaluation. Absence means no opinion, so existing producers keep their behavior.

Metadata key
// MetaKeyAutoStartOnCreate is a positive opt-in a task creator stamps when
// it wants task.created to evaluate the destination step's on_enter
// actions immediately, as if creation were itself a transition into that
// step. Absence is the default and preserves existing behavior for every
// other producer (REST/MCP/WS create with or without start_agent /
// prepare_session, CreateChildTask, etc.) — those already have their own
// launch decision, and task.created must not second-guess it. Set today
// only by CreateOfficeTaskInWorkflow for materialized heavy-routine runs,
// whose Routine workflow start step has no other transition to carry it
// into an auto_start_agent evaluation.
MetaKeyAutoStartOnCreate = "auto_start_on_create"
Helper
// HasAutoStartOnCreateIntent reports whether task metadata carries the
// positive MetaKeyAutoStartOnCreate opt-in. Only an explicit true value
// counts — absence (the default for nearly every task producer) must never
// be read as "please auto-start me".
func HasAutoStartOnCreateIntent(metadata map[string]interface{}) bool {
  intent, ok := metadata[MetaKeyAutoStartOnCreate].(bool)
  return ok && intent
}

Producer stamps opt-in and assignee

apps/backend/internal/backendapp/adapters_office.go

The Routine workflow start step pins no agent, so the task must carry both the opt-in and the assignee fallback for the kanban auto-start path.

CreateOfficeTaskInWorkflow
func (a *taskCreatorAdapter) CreateOfficeTaskInWorkflow(
  ctx context.Context, workspaceID, projectID, assigneeAgentID, workflowID, title, description string,
) (string, error) {
  metadata := map[string]interface{}{
    // The Routine workflow's start step carries on_enter:
    // auto_start_agent, but a materialized run lands directly on that
    // step rather than transitioning into it, so nothing would
    // otherwise evaluate on_enter for it. This opts the task into
    // handleTaskCreated's create-time on_enter evaluation.
    models.MetaKeyAutoStartOnCreate: true,
  }
  if assigneeAgentID != "" {
    // The Routine workflow's start step pins no agent (routine.yml), so
    // the kanban auto-start path's fallback read of
    // task.Metadata[MetaKeyAgentProfileID] is what lets a materialized
    // heavy-routine task actually launch with the routine's assignee.
    metadata[models.MetaKeyAgentProfileID] = assigneeAgentID
  }
  result, err := a.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{
    WorkspaceID:            workspaceID,
    WorkflowID:             workflowID,
    Title:                  title,
    Description:            description,
    ProjectID:              projectID,
    AssigneeAgentProfileID: assigneeAgentID,
    Metadata:               metadata,
    Origin:                 models.TaskOriginOnboarding,
  })
  if err != nil {
    return "", err
  }
  return result.Task.ID, nil
}

New task.created handler

apps/backend/internal/orchestrator/event_handlers_workflow.go

The handler claims the one-shot opt-in token and forwards to the existing auto-start pipeline, with guards for office tasks, queued tasks, and duplicate deliveries.

handleTaskCreated
func (s *Service) handleTaskCreated(ctx context.Context, data watcher.TaskEventData) {
  task, err := s.repo.GetTask(ctx, data.TaskID)
  if err != nil || task == nil {
    if err != nil {
      s.logger.Warn("task.created: failed to load task", zap.String("task_id", data.TaskID), zap.Error(err))
    }
    return
  }
  if task.IsFromOffice || !models.HasAutoStartOnCreateIntent(task.Metadata) {
    return
  }
  if !s.claimTaskEventMetadata(ctx, task, models.MetaKeyAutoStartOnCreate) {
    return
  }
  s.autoStartTaskForStep(ctx, task.ID, task.WorkflowStepID, events.TaskCreated, data.StepTransitionID)
}

Wire handler into watcher

apps/backend/internal/orchestrator/service.go

The watcher now delivers task.created events to the new handler, alongside the existing task.moved and task.queue_promoted handlers.

EventHandlers wiring
handlers := watcher.EventHandlers{
  OnTaskDeleted:          s.handleTaskDeleted,
  OnTaskStateChanged:     s.handleTaskStateChanged,
  OnAgentRunning:         s.handleAgentRunning,
  OnAgentBootReady:       s.handleAgentBootReady,
  OnAgentReady:           s.handleAgentReady,
  OnAgentCompleted:       s.handleAgentCompleted,
  OnAgentFailed:          s.handleAgentFailed,
  OnAgentStalled:         s.handleAgentStalled,
  OnAgentStopped:         s.handleAgentStopped,
  OnAgentStreamEvent:     s.handleAgentStreamEvent,
  OnACPSessionCreated:    s.handleACPSessionCreated,
  OnPermissionRequest:    s.handlePermissionRequest,
  OnGitEvent:             s.handleGitEvent,
  OnContextWindowUpdated: s.handleContextWindowUpdated,
  OnTaskMoved:            s.handleTaskMoved,
  OnTaskQueuePromoted:    s.handleTaskQueuePromoted,
  OnTaskCreated:          s.handleTaskCreated,
}
s.watcher = watcher.NewWatcher(eventBus, handlers, cfg.QueueGroup, log)

Tests for create-time auto-start

apps/backend/internal/orchestrator/event_handlers_workflow_created_test.go

The tests prove the happy path launches, and that queued tasks, missing opt-in, office tasks, and duplicate deliveries do not launch.

Happy path and guards
func TestHandleTaskCreated(t *testing.T) {
  ctx := context.Background()
  t.Run("launches a session for a task created directly on an auto-start step", func(t *testing.T) {
    // metadata carries MetaKeyAutoStartOnCreate + MetaKeyAgentProfileID
    // step has OnEnterAutoStartAgent, mock agent captures launch
    svc.handleTaskCreated(ctx, watcher.TaskEventData{TaskID: "t1"})
    // asserts launched agentProfileID == "routine-assignee"
  })
  t.Run("skips a queued task even when it carries the create-time opt-in", func(t *testing.T) {
    // QueuedForStepID set -> GetStepCalls() must stay 0
  })
  t.Run("skips a task created on an auto-start step without the create-time opt-in (R-1)", func(t *testing.T) {
    // no MetaKeyAutoStartOnCreate -> GetStepCalls() == 0
  })
  t.Run("does not launch an office task even when it carries the create-time opt-in (R-2)", func(t *testing.T) {
    // IsFromOffice true -> GetStepCalls() == 0
  })
  t.Run("claims the create-time opt-in so a duplicate delivery cannot double-launch", func(t *testing.T) {
    // first delivery removes key, second delivery sees no key
  })
}

Adapter and mock test support

apps/backend/internal/backendapp/adapters_office_test.go

The adapter test checks that the assignee lands in metadata and the opt-in is always set, with and without an assignee.

Adapter test
func TestCreateOfficeTaskInWorkflowCarriesAssigneeIntoLaunchMetadata(t *testing.T) {
  adapter, taskSvc := newOfficeTaskAdapterHarness(t)
  workflowID := workflows[0].ID
  taskID, _ := adapter.CreateOfficeTaskInWorkflow(ctx, "ws-1", "", "routine-assignee", workflowID, "Routine run", "Materialized run")
  task, _ := taskSvc.GetTask(ctx, taskID)
  // task.Metadata[MetaKeyAgentProfileID] == "routine-assignee"
  // HasAutoStartOnCreateIntent(task.Metadata) == true
  noAssigneeTaskID, _ := adapter.CreateOfficeTaskInWorkflow(ctx, "ws-1", "", "", workflowID, "Unassigned run", "Materialized run")
  // Metadata[AgentProfileID] absent, but HasAutoStartOnCreateIntent == true
}
Mock helper
type mockStepGetter struct {
  steps        map[string]*wfmodels.WorkflowStep
  getStepCalls int
  getStepMu    sync.Mutex
}
func (m *mockStepGetter) GetStep(_ context.Context, stepID string) (*wfmodels.WorkflowStep, error) {
  m.getStepMu.Lock()
  m.getStepCalls++
  m.getStepMu.Unlock()
  if s, ok := m.steps[stepID]; ok {
    return s, nil
  }
  return nil, nil
}
func (m *mockStepGetter) GetStepCalls() int {
  m.getStepMu.Lock()
  defer m.getStepMu.Unlock()
  return m.getStepCalls
}

Data and storage

One new metadata key drives the fix. No schema migration is needed.

FieldTypeNotes
metadata.auto_start_on_createboolPositive opt-in. Only true counts. Absence means no auto-start on create.
metadata.agent_profile_idstringFallback agent for Routine start step which pins no agent. Copied from assignee when present.
task.IsFromOfficebool (projection)Read-time flag. Office tasks are excluded even with opt-in to avoid double queue.
task.QueuedForStepIDstringQueued tasks are WIP-blocked. Handler bails before loading the step.

Risk

3 / 10 Low
1 low5 medium10 high

Why this score

  • Blast radius is narrow: only tasks with the new opt-in key trigger the path, and only one producer sets it.
  • Duplicate deliveries are safe: the opt-in is claimed atomically via RemoveTaskMetadataKey before launch.
  • Coverage is strong: adapter and handler tests cover happy path, queued, missing opt-in, office, and duplicate cases.

Trade-offs and review notes

Where to look first

  1. Read event_handlers_workflow.go handleTaskCreated first: the four guards and the claim before autoStartTaskForStep.
  2. Check adapters_office.go CreateOfficeTaskInWorkflow: the metadata map and the assignee fallback.
  3. Verify task/models/models.go: the key definition and HasAutoStartOnCreateIntent helper.
  4. Confirm service.go wiring: OnTaskCreated is added to the watcher handlers.
  5. Scan the new tests: event_handlers_workflow_created_test.go and adapters_office_test.go for the five guard cases.