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
}