fix(workflow): route agent starts to the first auto-start step (#2983)
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,18 @@ func (r *adapterStartStepResolver) ResolveStartStep(ctx context.Context, workflo
|
||||
return stepID, err
|
||||
}
|
||||
|
||||
func (r *adapterStartStepResolver) ResolveAutoStartStep(ctx context.Context, workflowID string) (string, error) {
|
||||
var stepID string
|
||||
err := r.repo.DB().QueryRowContext(ctx,
|
||||
`SELECT id FROM workflow_steps WHERE workflow_id = ? AND events LIKE '%auto_start_agent%' ORDER BY position LIMIT 1`,
|
||||
workflowID,
|
||||
).Scan(&stepID)
|
||||
if err == sql.ErrNoRows {
|
||||
return r.ResolveStartStep(ctx, workflowID)
|
||||
}
|
||||
return stepID, err
|
||||
}
|
||||
|
||||
func (r *adapterStartStepResolver) ResolveFirstStep(ctx context.Context, workflowID string) (string, error) {
|
||||
var stepID string
|
||||
err := r.repo.DB().QueryRowContext(ctx,
|
||||
|
||||
@@ -140,3 +140,25 @@ func TestPluginsTaskWriter_UpdateRejectsSchedulingState(t *testing.T) {
|
||||
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||
require.Nil(t, svc.lastUpdate)
|
||||
}
|
||||
|
||||
// A plugin that creates a task with start_agent launches it right after
|
||||
// CreateTask returns, so its create carries the same start intent the REST, WS
|
||||
// and MCP surfaces carry — otherwise step resolution parks the task on the
|
||||
// start step and the plugin's agent runs in a column configured to run nothing.
|
||||
func TestPluginsTaskWriter_CreateCarriesStartAgentIntent(t *testing.T) {
|
||||
for name, startAgent := range map[string]bool{
|
||||
"starting an agent": true,
|
||||
"create only": false,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
svc := &fakePluginTaskWriteService{}
|
||||
a := pluginsTaskWriterAdapter{svc: svc}
|
||||
|
||||
_, err := a.CreateTask(context.Background(), plugins.TaskCreateInput{
|
||||
WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "x", StartAgent: startAgent,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, startAgent, svc.lastCreate.StartAgent)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,6 +768,15 @@ func (a *startStepResolverAdapter) ResolveFirstStep(ctx context.Context, workflo
|
||||
return step.ID, nil
|
||||
}
|
||||
|
||||
// ResolveAutoStartStep implements taskservice.StartStepResolver.
|
||||
func (a *startStepResolverAdapter) ResolveAutoStartStep(ctx context.Context, workflowID string) (string, error) {
|
||||
step, err := a.svc.ResolveAutoStartStep(ctx, workflowID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return step.ID, nil
|
||||
}
|
||||
|
||||
// githubSecretAdapter adapts secrets.SecretStore to github.SecretProvider and github.SecretManager.
|
||||
type githubSecretAdapter struct {
|
||||
store secrets.SecretStore
|
||||
@@ -1153,6 +1162,7 @@ func (a pluginsTaskWriterAdapter) CreateTask(ctx context.Context, in plugins.Tas
|
||||
Metadata: metadata,
|
||||
Repositories: repositories,
|
||||
PlanMode: in.PlanMode,
|
||||
StartAgent: in.StartAgent,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
workflowctrl "github.com/kandev/kandev/internal/workflow/controller"
|
||||
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
)
|
||||
|
||||
// The MCP create resolves launch metadata from a pendingTask that does not exist
|
||||
// yet, so the destination step has to be decided before that lookup. Leaving it
|
||||
// empty pinned the agent profile, and the deferred-launch record built from it,
|
||||
// to the start step's profile while CreateTask sent an agent start to the
|
||||
// auto-start step.
|
||||
func TestResolveMCPDestinationStep(t *testing.T) {
|
||||
h, _, repo := setupImportHandlers(t)
|
||||
ctx := context.Background()
|
||||
h.workflowCtrl = workflowctrl.NewController(h.workflowSvc)
|
||||
|
||||
require.NoError(t, repo.CreateStep(ctx, &wfmodels.WorkflowStep{
|
||||
ID: "backlog", WorkflowID: "wf-test", Name: "Backlog", Position: 0, IsStartStep: true,
|
||||
}))
|
||||
require.NoError(t, repo.CreateStep(ctx, &wfmodels.WorkflowStep{
|
||||
ID: "in-progress", WorkflowID: "wf-test", Name: "In Progress", Position: 1,
|
||||
Events: wfmodels.StepEvents{
|
||||
OnEnter: []wfmodels.OnEnterAction{{Type: wfmodels.OnEnterAutoStartAgent}},
|
||||
},
|
||||
}))
|
||||
require.NoError(t, repo.CreateStep(ctx, &wfmodels.WorkflowStep{
|
||||
ID: "deploy", WorkflowID: "wf-test", Name: "Deploy", Position: 2,
|
||||
Events: wfmodels.StepEvents{
|
||||
OnEnter: []wfmodels.OnEnterAction{{Type: wfmodels.OnEnterAutoStartAgent}},
|
||||
},
|
||||
}))
|
||||
|
||||
assert.Equal(t, "in-progress", h.resolveMCPDestinationStep(ctx, "wf-test", true),
|
||||
"an agent start belongs on the first step that runs agents")
|
||||
assert.Equal(t, "backlog", h.resolveMCPDestinationStep(ctx, "wf-test", false),
|
||||
"a create with no agent belongs on the start step")
|
||||
assert.Empty(t, h.resolveMCPDestinationStep(ctx, "wf-missing", true),
|
||||
"an unreadable workflow must leave resolution to CreateTask")
|
||||
assert.Empty(t, h.resolveMCPDestinationStep(ctx, "", true),
|
||||
"no workflow means no destination to pre-resolve")
|
||||
|
||||
h.workflowCtrl = nil
|
||||
assert.Empty(t, h.resolveMCPDestinationStep(ctx, "wf-test", true),
|
||||
"without a workflow controller the handler must not guess")
|
||||
}
|
||||
@@ -743,11 +743,20 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
|
||||
}
|
||||
|
||||
// Resolve the destination step before launch metadata. The profile lookup
|
||||
// below reads it off pendingTask, and CreateTask sends an agent start to
|
||||
// the first auto_start_agent step rather than to the start step — leaving
|
||||
// this empty pinned the metadata and the deferred-launch record to the
|
||||
// start step's profile while the task landed somewhere else.
|
||||
resolvedStepID := req.WorkflowStepID
|
||||
if resolvedStepID == "" {
|
||||
resolvedStepID = h.resolveMCPDestinationStep(ctx, req.WorkflowID, startAgent)
|
||||
}
|
||||
pendingTask := &models.Task{
|
||||
ParentID: req.ParentID,
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
WorkflowID: req.WorkflowID,
|
||||
WorkflowStepID: req.WorkflowStepID,
|
||||
WorkflowStepID: resolvedStepID,
|
||||
}
|
||||
launchConfig, metadata, err := h.resolveMCPLaunchMetadataWithSource(
|
||||
ctx, pendingTask, req.AgentProfileID, req.ExecutorProfileID, req.SourceTaskID, req.SourceSessionID,
|
||||
@@ -787,7 +796,7 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
ParentID: req.ParentID,
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
WorkflowID: req.WorkflowID,
|
||||
WorkflowStepID: req.WorkflowStepID,
|
||||
WorkflowStepID: resolvedStepID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Autopilot: req.Autopilot,
|
||||
@@ -797,6 +806,7 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
AssigneeAgentProfileID: req.AssigneeAgentProfileID,
|
||||
Metadata: metadata,
|
||||
DeferredLaunch: deferredLaunch,
|
||||
StartAgent: startAgent,
|
||||
ExternalID: req.ExternalID,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1497,6 +1507,31 @@ func (h *Handlers) resolveWorkflowStepAgentProfile(ctx context.Context, workflow
|
||||
return resp.Step.AgentProfileID, workflowID
|
||||
}
|
||||
|
||||
// resolveMCPDestinationStep pre-resolves the step a new task will be created
|
||||
// on, mirroring the task service's rules: an agent start goes to the first
|
||||
// auto_start_agent step, anything else to the start step. Both are computed by
|
||||
// the shared selectors, so the two implementations cannot drift. Returns "" when
|
||||
// the workflow has no steps or cannot be read, which leaves resolution to
|
||||
// CreateTask exactly as before.
|
||||
func (h *Handlers) resolveMCPDestinationStep(ctx context.Context, workflowID string, startAgent bool) string {
|
||||
if h.workflowCtrl == nil || workflowID == "" {
|
||||
return ""
|
||||
}
|
||||
resp, err := h.workflowCtrl.ListStepsByWorkflow(ctx, workflowctrl.ListStepsRequest{WorkflowID: workflowID})
|
||||
if err != nil || resp == nil {
|
||||
return ""
|
||||
}
|
||||
if startAgent {
|
||||
if step := workflowmodels.SelectAutoStartStep(resp.Steps); step != nil {
|
||||
return step.ID
|
||||
}
|
||||
}
|
||||
if step := workflowmodels.SelectStartStep(resp.Steps); step != nil {
|
||||
return step.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *Handlers) resolveWorkflowStartStepAgentProfile(ctx context.Context, workflowID string) string {
|
||||
resp, err := h.workflowCtrl.ListStepsByWorkflow(ctx, workflowctrl.ListStepsRequest{WorkflowID: workflowID})
|
||||
if err != nil || resp == nil {
|
||||
|
||||
@@ -49,6 +49,11 @@ type TaskCreateInput struct {
|
||||
Launch TaskLaunchInput
|
||||
Metadata map[string]any
|
||||
PlanMode bool
|
||||
// StartAgent reports that the caller will launch an agent for this task
|
||||
// right after creation. The adapter maps it onto CreateTaskRequest so step
|
||||
// resolution sends the task to the first step that runs agents rather than
|
||||
// to the workflow's parking start step.
|
||||
StartAgent bool
|
||||
}
|
||||
|
||||
// TaskLaunchInput contains validated launch fields for a freshly-created
|
||||
@@ -173,6 +178,7 @@ func (r taskReader) Create(ctx context.Context, in pluginsdk.CreateTaskInput) (*
|
||||
Launch: launch,
|
||||
Metadata: metadata,
|
||||
PlanMode: launch.PlanMode,
|
||||
StartAgent: in.StartAgent,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -497,3 +497,38 @@ func TestPluginHost_Messages_SendRequiresTaskAndText(t *testing.T) {
|
||||
t.Fatalf("messenger called %d times despite invalid input", d.messenger.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// The host launches a plugin task right after CreateTask returns, so the start
|
||||
// intent has to reach the create itself. Without it the task service resolves
|
||||
// the parking start step and the plugin's agent runs in a column configured to
|
||||
// run nothing — the bug this PR fixes for REST, WS and MCP, reached by a fourth
|
||||
// route.
|
||||
func TestPluginHost_Tasks_CreatePropagatesStartAgentToTheWriter(t *testing.T) {
|
||||
for name, startAgent := range map[string]bool{
|
||||
"starting an agent": true,
|
||||
"create only": false,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}})
|
||||
d.taskWriter.created = &taskmodels.Task{ID: "task-9", WorkspaceID: "ws-1", WorkflowID: "wf-1"}
|
||||
d.profiles.resp = &agentsettingsdto.ListAgentsResponse{Agents: []agentsettingsdto.AgentDTO{{
|
||||
ID: "agent-1", Profiles: []agentsettingsdto.AgentProfileDTO{{ID: "agent-1"}},
|
||||
}}}
|
||||
d.tasks.executorProfiles = []*taskmodels.ExecutorProfile{{ID: "executor-profile-1"}}
|
||||
agentProfileID, executorProfileID := "agent-1", "executor-profile-1"
|
||||
|
||||
_, err := d.host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{
|
||||
WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Investigate", StartAgent: startAgent,
|
||||
Launch: &pluginsdk.PluginTaskLaunchOptions{
|
||||
AgentProfileID: &agentProfileID, ExecutorProfileID: &executorProfileID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() unexpected error: %v", err)
|
||||
}
|
||||
if d.taskWriter.lastCreate.StartAgent != startAgent {
|
||||
t.Fatalf("create StartAgent = %v, want %v", d.taskWriter.lastCreate.StartAgent, startAgent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
// routingStepResolver reports which destination rule the create request asked
|
||||
// for, so the transport mapping can be asserted without a workflow database.
|
||||
type routingStepResolver struct{}
|
||||
|
||||
func (routingStepResolver) ResolveStartStep(context.Context, string) (string, error) {
|
||||
return "start-step", nil
|
||||
}
|
||||
|
||||
func (routingStepResolver) ResolveFirstStep(context.Context, string) (string, error) {
|
||||
return "first-step", nil
|
||||
}
|
||||
|
||||
func (routingStepResolver) ResolveAutoStartStep(context.Context, string) (string, error) {
|
||||
return "auto-start-step", nil
|
||||
}
|
||||
|
||||
// The service-level routing tests exercise resolveWorkflowStep directly, which
|
||||
// says nothing about whether a transport actually carries `start_agent` into
|
||||
// CreateTaskRequest. Each transport maps the field by hand, so each one can drop
|
||||
// it independently — and a dropped flag is invisible on the built-in templates,
|
||||
// where the start step and the auto-start step are the same step. The resolved
|
||||
// step ID on the persisted task is the observable proof the flag arrived.
|
||||
|
||||
func TestHTTPCreateTask_StartAgentSelectsDestinationStep(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
startAgent bool
|
||||
wantStep string
|
||||
}{
|
||||
"start_agent=true routes to the auto-start step": {startAgent: true, wantStep: "auto-start-step"},
|
||||
"start_agent=false routes to the start step": {startAgent: false, wantStep: "start-step"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
repo := &captureCreateTaskRepo{}
|
||||
svc := service.NewService(service.Repos{
|
||||
Workspaces: repo, Tasks: repo, TaskRepos: repo,
|
||||
Workflows: repo, Messages: repo, Turns: repo,
|
||||
Sessions: repo, GitSnapshots: repo, RepoEntities: repo,
|
||||
Executors: repo, Environments: repo, TaskEnvironments: repo,
|
||||
Reviews: repo,
|
||||
}, nil, log, service.RepositoryDiscoveryConfig{})
|
||||
svc.SetWorkflowStepGetter(repo)
|
||||
svc.SetStartStepResolver(routingStepResolver{})
|
||||
h := &TaskHandlers{service: svc, orchestrator: &captureOrchestrator{}, logger: log}
|
||||
|
||||
body := `{
|
||||
"workspace_id": "ws-1",
|
||||
"workflow_id": "wf-1",
|
||||
"title": "Routed by intent",
|
||||
"description": "do the thing",
|
||||
"agent_profile_id": "profile-1",
|
||||
"start_agent": ` + boolLiteral(tc.startAgent) + `
|
||||
}`
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.httpCreateTask(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
require.NotNil(t, repo.captured, "CreateTask must persist the task")
|
||||
assert.Equal(t, tc.wantStep, repo.captured.WorkflowStepID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSCreateTask_StartAgentSelectsDestinationStep(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
payload map[string]any
|
||||
wantStep string
|
||||
}{
|
||||
"start_agent=true routes to the auto-start step": {
|
||||
payload: map[string]any{
|
||||
"workspace_id": "ws-b", "workflow_id": "wf-b", "title": "Routed by intent",
|
||||
"agent_profile_id": "profile-1", "start_agent": true,
|
||||
},
|
||||
wantStep: "auto-start-step",
|
||||
},
|
||||
"start_agent=false routes to the start step": {
|
||||
payload: map[string]any{
|
||||
"workspace_id": "ws-b", "workflow_id": "wf-b", "title": "Routed by intent",
|
||||
"agent_profile_id": "profile-1", "start_agent": false,
|
||||
},
|
||||
wantStep: "start-step",
|
||||
},
|
||||
"omitted start_agent routes to the start step": {
|
||||
payload: map[string]any{
|
||||
"workspace_id": "ws-b", "workflow_id": "wf-b", "title": "Routed by intent",
|
||||
},
|
||||
wantStep: "start-step",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
repo := &wsTaskRepo{}
|
||||
h := newWSTaskHandlers(t, repo)
|
||||
h.service.SetStartStepResolver(routingStepResolver{})
|
||||
|
||||
resp, err := h.wsCreateTask(context.Background(),
|
||||
wsWorkflowRequest(t, ws.ActionTaskCreate, tc.payload))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ws.MessageTypeResponse, resp.Type, "body: %s", resp.Payload)
|
||||
require.Len(t, repo.created, 1, "task.create must persist the task")
|
||||
assert.Equal(t, tc.wantStep, repo.created[0].WorkflowStepID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func boolLiteral(v bool) string {
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
@@ -941,6 +941,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
|
||||
Metadata: metadata,
|
||||
DeferredLaunch: deferredLaunch,
|
||||
PlanMode: body.PlanMode,
|
||||
StartAgent: body.StartAgent,
|
||||
ParentID: body.ParentID,
|
||||
WorkspacePath: body.WorkspacePath,
|
||||
BlockedBy: body.BlockedBy,
|
||||
|
||||
@@ -186,6 +186,7 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
Metadata: req.Metadata,
|
||||
DeferredLaunch: deferredLaunch,
|
||||
PlanMode: req.PlanMode,
|
||||
StartAgent: req.StartAgent,
|
||||
ParentID: req.ParentID,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -207,6 +207,7 @@ type PRTaskResolver interface {
|
||||
type StartStepResolver interface {
|
||||
ResolveStartStep(ctx context.Context, workflowID string) (string, error)
|
||||
ResolveFirstStep(ctx context.Context, workflowID string) (string, error)
|
||||
ResolveAutoStartStep(ctx context.Context, workflowID string) (string, error)
|
||||
}
|
||||
|
||||
// StepHistoryRecorder persists an ADR 0015 session-step transition audit
|
||||
|
||||
@@ -30,6 +30,17 @@ func (r *dbStepResolver) ResolveStartStep(ctx context.Context, workflowID string
|
||||
return stepID, err
|
||||
}
|
||||
|
||||
func (r *dbStepResolver) ResolveAutoStartStep(ctx context.Context, workflowID string) (string, error) {
|
||||
var stepID string
|
||||
err := r.repo.DB().QueryRowContext(ctx,
|
||||
`SELECT id FROM workflow_steps WHERE workflow_id = ? AND events LIKE '%auto_start_agent%' ORDER BY position LIMIT 1`,
|
||||
workflowID).Scan(&stepID)
|
||||
if err == sql.ErrNoRows {
|
||||
return r.ResolveStartStep(ctx, workflowID)
|
||||
}
|
||||
return stepID, err
|
||||
}
|
||||
|
||||
func (r *dbStepResolver) ResolveFirstStep(ctx context.Context, workflowID string) (string, error) {
|
||||
var stepID string
|
||||
err := r.repo.DB().QueryRowContext(ctx,
|
||||
|
||||
@@ -67,10 +67,15 @@ type CreateTaskRequest struct {
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
DeferredLaunch map[string]interface{} `json:"deferred_launch,omitempty"`
|
||||
PlanMode bool `json:"plan_mode,omitempty"`
|
||||
IsEphemeral bool `json:"is_ephemeral,omitempty"` // Ephemeral tasks are hidden from kanban, used for quick chat
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Autopilot bool `json:"autopilot,omitempty"`
|
||||
WorkspacePath string `json:"workspace_path,omitempty"` // Optional host folder for repo-less tasks
|
||||
|
||||
// StartAgent reports that the caller intends to launch an agent for this
|
||||
// task right away. It only steers step resolution (see resolveWorkflowStep)
|
||||
// — the launch itself is the caller's job, after CreateTask returns.
|
||||
StartAgent bool `json:"start_agent,omitempty"`
|
||||
IsEphemeral bool `json:"is_ephemeral,omitempty"` // Ephemeral tasks are hidden from kanban, used for quick chat
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Autopilot bool `json:"autopilot,omitempty"`
|
||||
WorkspacePath string `json:"workspace_path,omitempty"` // Optional host folder for repo-less tasks
|
||||
|
||||
// ExternalID is a caller-supplied identity used for create-idempotency
|
||||
// (docs/specs/tasks/requirements/external-id-idempotency.md). Accepted on REST
|
||||
|
||||
@@ -662,14 +662,32 @@ func (s *Service) resolveOfficeWorkflow(ctx context.Context, req *CreateTaskRequ
|
||||
}
|
||||
|
||||
// resolveWorkflowStep resolves the starting workflow step for a new task.
|
||||
//
|
||||
// Three destinations, picked by what the caller is asking for:
|
||||
//
|
||||
// - plan mode → the first step by position. Planning happens before the work,
|
||||
// so the task belongs at the head of the board even when a later step is
|
||||
// marked as the start step.
|
||||
// - starting an agent now → the first step that runs agents
|
||||
// (on_enter: auto_start_agent). A task that is about to run does not belong
|
||||
// in a parking column that was never configured to run anything.
|
||||
// - everything else → the workflow's start step (is_start_step).
|
||||
//
|
||||
// The middle case is the one that is easy to get wrong: `is_start_step` and
|
||||
// `auto_start_agent` are separate settings, and routing an agent start through
|
||||
// the start step silently made the two synonymous. It went unnoticed because
|
||||
// every built-in template puts both on the same step.
|
||||
func (s *Service) resolveWorkflowStep(ctx context.Context, req *CreateTaskRequest) string {
|
||||
workflowStepID := req.WorkflowStepID
|
||||
if workflowStepID == "" && req.WorkflowID != "" && s.startStepResolver != nil {
|
||||
var resolvedID string
|
||||
var err error
|
||||
if req.PlanMode {
|
||||
switch {
|
||||
case req.PlanMode:
|
||||
resolvedID, err = s.startStepResolver.ResolveFirstStep(ctx, req.WorkflowID)
|
||||
} else {
|
||||
case req.StartAgent:
|
||||
resolvedID, err = s.startStepResolver.ResolveAutoStartStep(ctx, req.WorkflowID)
|
||||
default:
|
||||
resolvedID, err = s.startStepResolver.ResolveStartStep(ctx, req.WorkflowID)
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// recordingStepResolver reports which resolver CreateTask consulted, so the
|
||||
// routing rule can be asserted without a workflow database.
|
||||
type recordingStepResolver struct {
|
||||
called string
|
||||
}
|
||||
|
||||
func (r *recordingStepResolver) ResolveStartStep(context.Context, string) (string, error) {
|
||||
r.called = "start"
|
||||
return "start-step", nil
|
||||
}
|
||||
|
||||
func (r *recordingStepResolver) ResolveFirstStep(context.Context, string) (string, error) {
|
||||
r.called = "first"
|
||||
return "first-step", nil
|
||||
}
|
||||
|
||||
func (r *recordingStepResolver) ResolveAutoStartStep(context.Context, string) (string, error) {
|
||||
r.called = "auto-start"
|
||||
return "auto-start-step", nil
|
||||
}
|
||||
|
||||
// A create that is about to launch an agent belongs in the first step that runs
|
||||
// agents, not in the workflow's start step. Routing it through ResolveStartStep
|
||||
// made `is_start_step` and `auto_start_agent` synonymous: marking Backlog as the
|
||||
// start step parked the task there and started an agent in it anyway.
|
||||
func TestResolveWorkflowStep_RoutesByCreateIntent(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
req *CreateTaskRequest
|
||||
wantResolver string
|
||||
wantStepID string
|
||||
}{
|
||||
{
|
||||
name: "starting an agent uses the first auto-start step",
|
||||
req: &CreateTaskRequest{WorkflowID: "wf-1", StartAgent: true},
|
||||
wantResolver: "auto-start",
|
||||
wantStepID: "auto-start-step",
|
||||
},
|
||||
{
|
||||
name: "creating without an agent uses the start step",
|
||||
req: &CreateTaskRequest{WorkflowID: "wf-1"},
|
||||
wantResolver: "start",
|
||||
wantStepID: "start-step",
|
||||
},
|
||||
{
|
||||
name: "plan mode still uses the first step by position",
|
||||
req: &CreateTaskRequest{WorkflowID: "wf-1", PlanMode: true},
|
||||
wantResolver: "first",
|
||||
wantStepID: "first-step",
|
||||
},
|
||||
{
|
||||
name: "plan mode wins over an agent start",
|
||||
req: &CreateTaskRequest{WorkflowID: "wf-1", PlanMode: true, StartAgent: true},
|
||||
wantResolver: "first",
|
||||
wantStepID: "first-step",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc, _, _ := createTestService(t)
|
||||
resolver := &recordingStepResolver{}
|
||||
svc.SetStartStepResolver(resolver)
|
||||
|
||||
got := svc.resolveWorkflowStep(context.Background(), tc.req)
|
||||
|
||||
if resolver.called != tc.wantResolver {
|
||||
t.Errorf("consulted %q resolver; want %q", resolver.called, tc.wantResolver)
|
||||
}
|
||||
if got != tc.wantStepID {
|
||||
t.Errorf("resolveWorkflowStep() = %q; want %q", got, tc.wantStepID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit workflow_step_id is the caller's decision and outranks every rule
|
||||
// above — dragging a card into a column, or a watcher targeting a named step,
|
||||
// must not be re-routed by the create intent.
|
||||
func TestResolveWorkflowStep_ExplicitStepWins(t *testing.T) {
|
||||
svc, _, _ := createTestService(t)
|
||||
resolver := &recordingStepResolver{}
|
||||
svc.SetStartStepResolver(resolver)
|
||||
|
||||
got := svc.resolveWorkflowStep(context.Background(), &CreateTaskRequest{
|
||||
WorkflowID: "wf-1",
|
||||
WorkflowStepID: "chosen-step",
|
||||
StartAgent: true,
|
||||
})
|
||||
|
||||
if got != "chosen-step" {
|
||||
t.Errorf("resolveWorkflowStep() = %q; want %q", got, "chosen-step")
|
||||
}
|
||||
if resolver.called != "" {
|
||||
t.Errorf("consulted the %q resolver; an explicit step must short-circuit", resolver.called)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@ func (r fixedStartStepResolver) ResolveFirstStep(context.Context, string) (strin
|
||||
return r.stepID, nil
|
||||
}
|
||||
|
||||
func (r fixedStartStepResolver) ResolveAutoStartStep(context.Context, string) (string, error) {
|
||||
return r.stepID, nil
|
||||
}
|
||||
|
||||
func TestCreateTask_QueuesFullWIPStepWithoutFeeder(t *testing.T) {
|
||||
svc, events, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -243,6 +243,41 @@ func (s *WorkflowStep) HasOnEnterAction(actionType OnEnterActionType) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SelectAutoStartStep returns the first step by position whose on_enter carries
|
||||
// auto_start_agent, or nil when no step automates. It is the destination for a
|
||||
// task that is starting an agent immediately: `is_start_step` says where tasks
|
||||
// are parked, which is not necessarily a step configured to run anything.
|
||||
func SelectAutoStartStep(steps []*WorkflowStep) *WorkflowStep {
|
||||
var best *WorkflowStep
|
||||
for _, step := range steps {
|
||||
if step == nil || !step.HasOnEnterAction(OnEnterAutoStartAgent) {
|
||||
continue
|
||||
}
|
||||
if best == nil || step.Position < best.Position {
|
||||
best = step
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// SelectStartStep returns the step marked is_start_step, falling back to the
|
||||
// first step by position. Nil only when there are no steps.
|
||||
func SelectStartStep(steps []*WorkflowStep) *WorkflowStep {
|
||||
var firstByPosition *WorkflowStep
|
||||
for _, step := range steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
if step.IsStartStep {
|
||||
return step
|
||||
}
|
||||
if firstByPosition == nil || step.Position < firstByPosition.Position {
|
||||
firstByPosition = step
|
||||
}
|
||||
}
|
||||
return firstByPosition
|
||||
}
|
||||
|
||||
// HasOnTurnStartAction checks if the step has any on_turn_start actions.
|
||||
func (s *WorkflowStep) HasOnTurnStartAction() bool {
|
||||
return len(s.Events.OnTurnStart) > 0
|
||||
|
||||
@@ -424,6 +424,33 @@ func (s *Service) ResolveStartStep(ctx context.Context, workflowID string) (*mod
|
||||
return s.ResolveFirstStep(ctx, workflowID)
|
||||
}
|
||||
|
||||
// ResolveAutoStartStep resolves which step a task should be created in when the
|
||||
// creator asked to start an agent immediately: the first step by position whose
|
||||
// on_enter carries auto_start_agent.
|
||||
//
|
||||
// `is_start_step` and `auto_start_agent` are independent settings — the first
|
||||
// says where new tasks are parked, the second says which steps run agents — so
|
||||
// a task that is starting now belongs in the first step that actually runs
|
||||
// agents, not in the parking column. They coincide in every built-in template
|
||||
// (In Progress is both), which is why routing an agent start through
|
||||
// ResolveStartStep looked correct until someone moved the start step onto their
|
||||
// backlog and watched the agent start there anyway.
|
||||
//
|
||||
// Fallback chain: first auto_start_agent step by position → ResolveStartStep
|
||||
// (is_start_step → first step by position), so a workflow with no automated
|
||||
// step behaves exactly as it did before.
|
||||
func (s *Service) ResolveAutoStartStep(ctx context.Context, workflowID string) (*models.WorkflowStep, error) {
|
||||
steps, err := s.repo.ListStepsByWorkflow(ctx, workflowID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to list steps for auto-start step resolution", zap.String("workflow_id", workflowID), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if step := models.SelectAutoStartStep(steps); step != nil {
|
||||
return step, nil
|
||||
}
|
||||
return s.ResolveStartStep(ctx, workflowID)
|
||||
}
|
||||
|
||||
// ResolveFirstStep always returns the first step by position, ignoring is_start_step.
|
||||
func (s *Service) ResolveFirstStep(ctx context.Context, workflowID string) (*models.WorkflowStep, error) {
|
||||
steps, err := s.repo.ListStepsByWorkflow(ctx, workflowID)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/kandev/kandev/internal/workflow/models"
|
||||
)
|
||||
|
||||
func TestResolveAutoStartStep(t *testing.T) {
|
||||
t.Run("returns first step by position carrying auto_start_agent", func(t *testing.T) {
|
||||
svc, db := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
insertWorkflow(t, db, "wf-1", "Test Workflow")
|
||||
|
||||
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Backlog", Position: 0, IsStartStep: true})
|
||||
createStep(t, svc, &models.WorkflowStep{
|
||||
WorkflowID: "wf-1",
|
||||
Name: "In Progress",
|
||||
Position: 1,
|
||||
Events: models.StepEvents{
|
||||
OnEnter: []models.OnEnterAction{{Type: models.OnEnterAutoStartAgent}},
|
||||
},
|
||||
})
|
||||
createStep(t, svc, &models.WorkflowStep{
|
||||
WorkflowID: "wf-1",
|
||||
Name: "Deploy",
|
||||
Position: 2,
|
||||
Events: models.StepEvents{
|
||||
OnEnter: []models.OnEnterAction{{Type: models.OnEnterAutoStartAgent}},
|
||||
},
|
||||
})
|
||||
|
||||
step, err := svc.ResolveAutoStartStep(ctx, "wf-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, step)
|
||||
assert.Equal(t, "In Progress", step.Name)
|
||||
})
|
||||
|
||||
t.Run("falls back to the start step when no step auto-starts", func(t *testing.T) {
|
||||
svc, db := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
insertWorkflow(t, db, "wf-1", "Test Workflow")
|
||||
|
||||
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0})
|
||||
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Start Here", Position: 1, IsStartStep: true})
|
||||
|
||||
step, err := svc.ResolveAutoStartStep(ctx, "wf-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, step)
|
||||
assert.Equal(t, "Start Here", step.Name)
|
||||
})
|
||||
|
||||
t.Run("empty workflow returns error", func(t *testing.T) {
|
||||
svc, db := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
insertWorkflow(t, db, "wf-empty", "Empty Workflow")
|
||||
|
||||
step, err := svc.ResolveAutoStartStep(ctx, "wf-empty")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, step)
|
||||
assert.Contains(t, err.Error(), "has no steps")
|
||||
})
|
||||
}
|
||||
@@ -155,7 +155,6 @@ function useSubmitHandlersWiring({
|
||||
workspaceId,
|
||||
workflowId,
|
||||
effectiveWorkflowId: computed.effectiveWorkflowId,
|
||||
effectiveDefaultStepId: computed.effectiveDefaultStepId,
|
||||
repositories: fs.repositories,
|
||||
discoveredRepositories: fs.discoveredRepositories,
|
||||
workspaceRepositories,
|
||||
|
||||
@@ -55,7 +55,6 @@ type BuildCreateTaskPayloadCall = {
|
||||
};
|
||||
|
||||
const buildCreateTaskPayloadMock = vi.fn((args: BuildCreateTaskPayloadCall) => ({
|
||||
workflow_step_id: "step-1",
|
||||
repositories: args.repositoriesPayload,
|
||||
agent_profile_id: args.agentProfileId || undefined,
|
||||
executor_id: args.executorId || undefined,
|
||||
@@ -131,7 +130,6 @@ function makeDeps(overrides: Partial<SubmitHandlersDeps>): SubmitHandlersDeps {
|
||||
workspaceId: "ws-1",
|
||||
workflowId: "wf-1",
|
||||
effectiveWorkflowId: "wf-1",
|
||||
effectiveDefaultStepId: "step-1",
|
||||
repositories: [],
|
||||
discoveredRepositories: [],
|
||||
workspaceRepositories: [],
|
||||
@@ -310,7 +308,6 @@ describe("useTaskSubmitHandlers — handleCreateSubmit (CLI-mode parity)", () =>
|
||||
|
||||
expect(createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflow_step_id: "step-1",
|
||||
agent_profile_id: "agent-1",
|
||||
executor_id: "exec-1",
|
||||
}),
|
||||
@@ -411,3 +408,29 @@ describe("useTaskSubmitHandlers — handleCreateSubmit (CLI-mode parity)", () =>
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTaskSubmitHandlers — handleCreateWithoutAgent", () => {
|
||||
// "Create without starting agent" must leave the destination to the backend,
|
||||
// which parks the task in the workflow's start step. Sending a step from the
|
||||
// dialog pinned it to whatever the caller happened to pass as defaultStepId
|
||||
// (uniformly "first step by position"), so a workflow whose start step was
|
||||
// moved elsewhere was ignored.
|
||||
it("sends no workflow_step_id, leaving the start step to the backend", async () => {
|
||||
const createTask = vi.fn().mockResolvedValue({ id: TASK_ID });
|
||||
const deps = makeDeps({
|
||||
createTask,
|
||||
descriptionInputRef: makeRef("park this for later"),
|
||||
});
|
||||
const { result } = renderHook(() => useTaskSubmitHandlers(deps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateWithoutAgent();
|
||||
});
|
||||
|
||||
expect(createTask).toHaveBeenCalledTimes(1);
|
||||
expect(createTask.mock.calls[0][0]).not.toHaveProperty("workflow_step_id");
|
||||
expect(buildCreateTaskPayloadMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ withAgent: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,20 +43,16 @@ function notifyQueuedTask(
|
||||
|
||||
type NoAgentTaskRequirements = {
|
||||
description: string;
|
||||
workflowStepId: string;
|
||||
workspaceId: string;
|
||||
workflowId: string;
|
||||
};
|
||||
|
||||
function hasNoAgentTaskRequirements(input: {
|
||||
description: string;
|
||||
workflowStepId: string | null;
|
||||
workspaceId: string | null;
|
||||
workflowId: string | null;
|
||||
}): input is NoAgentTaskRequirements {
|
||||
return Boolean(
|
||||
input.description && input.workflowStepId && input.workspaceId && input.workflowId,
|
||||
);
|
||||
return Boolean(input.description && input.workspaceId && input.workflowId);
|
||||
}
|
||||
|
||||
function resolveWorkspacePath(noRepository: boolean, workspacePath: string): string | undefined {
|
||||
@@ -75,7 +71,6 @@ export function useTaskSubmitHandlers({
|
||||
workspaceId,
|
||||
workflowId,
|
||||
effectiveWorkflowId,
|
||||
effectiveDefaultStepId,
|
||||
repositories,
|
||||
discoveredRepositories,
|
||||
workspaceRepositories,
|
||||
@@ -665,7 +660,6 @@ export function useTaskSubmitHandlers({
|
||||
if (!validateForCreate(trimmedTitle, trimmedDescription)) return;
|
||||
const requirements = {
|
||||
description: trimmedDescription,
|
||||
workflowStepId: effectiveDefaultStepId,
|
||||
workspaceId,
|
||||
workflowId: effectiveWorkflowId,
|
||||
};
|
||||
@@ -694,7 +688,6 @@ export function useTaskSubmitHandlers({
|
||||
autopilot,
|
||||
blockedBy,
|
||||
});
|
||||
p.workflow_step_id = requirements.workflowStepId;
|
||||
submittedPayload = p;
|
||||
return p;
|
||||
};
|
||||
@@ -721,7 +714,6 @@ export function useTaskSubmitHandlers({
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
agentProfileId,
|
||||
effectiveDefaultStepId,
|
||||
executorId,
|
||||
executorProfileId,
|
||||
noRepository,
|
||||
|
||||
@@ -417,7 +417,6 @@ export type SubmitHandlersDeps = {
|
||||
workspaceId: string | null;
|
||||
workflowId: string | null;
|
||||
effectiveWorkflowId: string | null;
|
||||
effectiveDefaultStepId: string | null;
|
||||
/** Unified repo list from the form. Empty when in GitHub URL mode. */
|
||||
repositories: TaskRepoRow[];
|
||||
/** All on-machine discovered repos — used to look up `default_branch` for `localPath` rows. */
|
||||
|
||||
@@ -1127,6 +1127,8 @@ export class ApiClient {
|
||||
updates: {
|
||||
prompt?: string;
|
||||
agent_profile_id?: string;
|
||||
/** Promotes this step to the workflow's start step, demoting the previous one. */
|
||||
is_start_step?: boolean;
|
||||
events?: {
|
||||
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
on_turn_start?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
/**
|
||||
* `is_start_step` and `auto_start_agent` are independent settings: the first
|
||||
* says where new tasks are created, the second says which steps run agents.
|
||||
* Task creation used to resolve both create actions through the start step,
|
||||
* which silently made them synonymous — marking Backlog as the start step
|
||||
* parked the task there and started an agent in it anyway.
|
||||
*
|
||||
* The seeded "simple" workflow ships with In Progress as both, so the two
|
||||
* rules agree and nothing can be observed. Moving the start step onto Backlog
|
||||
* (which automates nothing) separates them, which is the only configuration
|
||||
* where the routing is visible at all.
|
||||
*/
|
||||
test.describe("start step vs auto-start step", () => {
|
||||
test("parks a no-agent create and starts an agent create on the automated step", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const backlog = seedData.steps[0];
|
||||
const inProgress = seedData.steps[1];
|
||||
expect(backlog.name).toBe("Backlog");
|
||||
expect(inProgress.name).toBe("In Progress");
|
||||
|
||||
// Backlog becomes the start step, a parking column that automates nothing;
|
||||
// In Progress is the first step that runs agents. seedData is worker-scoped,
|
||||
// so both settings are stated explicitly instead of inherited.
|
||||
await apiClient.updateWorkflowStep(inProgress.id, {
|
||||
events: { on_enter: [{ type: "auto_start_agent" }] },
|
||||
});
|
||||
await apiClient.updateWorkflowStep(backlog.id, { is_start_step: true });
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
|
||||
// --- Create without starting agent -> the start step ---
|
||||
await kanban.createTaskButton.click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await testPage.getByTestId("task-title-input").fill("Parked for later");
|
||||
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
|
||||
const chevron = testPage.getByTestId("submit-start-agent-chevron");
|
||||
await expect(chevron).toBeEnabled({ timeout: 30_000 });
|
||||
await chevron.click();
|
||||
await testPage.getByTestId("submit-create-without-agent").click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The no-agent create opens the prepared task; come back to the board.
|
||||
await kanban.goto();
|
||||
await expect(kanban.taskCardInColumn("Parked for later", backlog.id)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// --- Start task -> the first step carrying auto_start_agent ---
|
||||
await kanban.createTaskButton.click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await testPage.getByTestId("task-title-input").fill("Runs right now");
|
||||
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
|
||||
const start = testPage.getByTestId("submit-start-agent");
|
||||
await expect(start).toBeEnabled({ timeout: 30_000 });
|
||||
await start.click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await kanban.goto();
|
||||
await expect(kanban.taskCardInColumn("Runs right now", inProgress.id)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
// The parked card is untouched by the second create.
|
||||
await expect(kanban.taskCardInColumn("Parked for later", backlog.id)).toBeVisible();
|
||||
});
|
||||
|
||||
// The dialog used to send its own `workflow_step_id`, taken from whatever the
|
||||
// caller passed as `defaultStepId` — uniformly "first step by position", a
|
||||
// leftover from before `is_start_step` existed. On the stock template that
|
||||
// pinned a no-agent create to Backlog while the workflow's start step was
|
||||
// In Progress. The destination belongs to the backend.
|
||||
test("a no-agent create honors a start step that is not the first column", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const backlog = seedData.steps[0];
|
||||
const inProgress = seedData.steps[1];
|
||||
// Stock "simple" shape: In Progress is the start step, Backlog is column 0.
|
||||
// Drop the automation so this case isolates placement from launching.
|
||||
await apiClient.updateWorkflowStep(backlog.id, { is_start_step: false });
|
||||
await apiClient.updateWorkflowStep(inProgress.id, { is_start_step: true, events: {} });
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await kanban.createTaskButton.click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await testPage.getByTestId("task-title-input").fill("Lands on the start step");
|
||||
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
|
||||
const chevron = testPage.getByTestId("submit-start-agent-chevron");
|
||||
await expect(chevron).toBeEnabled({ timeout: 30_000 });
|
||||
await chevron.click();
|
||||
await testPage.getByTestId("submit-create-without-agent").click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await kanban.goto();
|
||||
await expect(kanban.taskCardInColumn("Lands on the start step", inProgress.id)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(kanban.taskCardInColumn("Lands on the start step", backlog.id)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -115,7 +115,7 @@ Call `create_task_kandev` with `parent_id: "self"`. `workspace_mode` defaults to
|
||||
- Inherited repository attachments deliberately do not copy an explicit checkout branch.
|
||||
- An explicit same-repository child uses the inherited base branch. An explicit cross-repository child defaults to that repository's default branch unless `base_branch` is supplied.
|
||||
- Every created task must resolve an agent profile, even with `start_agent: false`.
|
||||
- Profile precedence when the task lands on a workflow step is the destination step's launch profile first (the step's pinned profile, or the workflow default when the step is unpinned), because that is what the orchestrator launches; it overrides an explicit `agent_profile_id`, and the created task records it. With no explicit `workflow_step_id`, the destination is the workflow's start step, so a workflow with steps still applies its start-step launch profile. Off a workflow step (no workflow, or a workflow with no steps), the order is explicit profile, then parent/current/source task metadata or primary-session profile, then the workspace default.
|
||||
- Profile precedence when the task lands on a workflow step is the destination step's launch profile first (the step's pinned profile, or the workflow default when the step is unpinned), because that is what the orchestrator launches; it overrides an explicit `agent_profile_id`, and the created task records it. With no explicit `workflow_step_id`, the destination is the first **Auto-start agent** step for `start_agent: true` and the workflow's start step otherwise, so a workflow with steps still applies that step's launch profile. Off a workflow step (no workflow, or a workflow with no steps), the order is explicit profile, then parent/current/source task metadata or primary-session profile, then the workspace default.
|
||||
- If no executor or executor profile is explicit or inherited, task MCP uses the built-in **git-worktree** executor. It does not consult the workspace's **Default Executor** for this fallback.
|
||||
- The one-level Kanban depth rule still applies.
|
||||
- An ephemeral Quick Chat task cannot be a parent; omit `parent_id` and create a top-level task instead.
|
||||
|
||||
@@ -73,9 +73,9 @@ Use **New Task** in the sidebar. In an open task, the **Task** split button also
|
||||
5. Enter the initial description. In the **New Task** dialog, an empty description changes the primary action to **Start Plan Mode**; the other dialog actions require a description. Agent-facing task MCP has different empty-description rules. When agent-generated task titles are enabled, every task and subtask action requires a nonempty prompt; the empty-description Plan Mode exception is disabled. A nonempty description exposes the standard split actions.
|
||||
6. Choose the applicable action:
|
||||
- **Start Plan Mode** is the primary empty-description action and creates the task through the plan-mode path.
|
||||
- **Start task** requires a nonempty description, creates the task, and starts its agent.
|
||||
- **Start task** requires a nonempty description, creates the task, and starts its agent. This path starts in the first positional step whose entry actions include **Auto-start agent**, falling back to **Start step** when the workflow automates no step.
|
||||
- **Start task in plan mode** requires a nonempty description and starts the agent with plan mode enabled. This path starts in the first positional workflow step, even if another step is marked **Start step**.
|
||||
- **Create without starting agent** requires a nonempty description. A structured ACP profile prepares the session/workspace without starting an agent turn. Passthrough/TUI is an exception: the backend launches it immediately so its native PTY exists.
|
||||
- **Create without starting agent** requires a nonempty description and starts in **Start step**. A structured ACP profile prepares the session/workspace without starting an agent turn. Passthrough/TUI is an exception: the backend launches it immediately so its native PTY exists.
|
||||
|
||||
On mobile, the two non-primary actions are separate buttons labeled **Plan mode** and **Create only**; they have the same plan-mode and create-without-agent behavior.
|
||||
|
||||
@@ -405,7 +405,7 @@ New steps allow manual moves by default. **Show in command panel** also defaults
|
||||
|
||||
| Setting | Effect |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Start step** | Makes this the normal starting step. Only one step per workflow should be selected. If none is selected, Kandev falls back to the first positional step. |
|
||||
| **Start step** | Where a task is created when no agent starts with it. Only one step per workflow should be selected. If none is selected, Kandev falls back to the first positional step. This setting places tasks; it never starts agents, which is **Auto-start agent** below. |
|
||||
| Agent profile | Overrides the workflow/task profile when entering this step. A different profile creates a new session with fresh conversation context. The fixed profile override and original-session options are mutually exclusive. |
|
||||
| **Override original session options** | Keeps the original conversation tab while applying model and ACP configuration rules for the task's starting agent family. The options editor appears below WIP settings only when this is checked. |
|
||||
| **Auto-start agent** | Starts an agent whenever a task enters the step. |
|
||||
@@ -595,7 +595,7 @@ settled task in the still-working state.
|
||||
|
||||
- **No workflow is available:** open the workspace's **Workflows** page. Newly added workspaces have none by default.
|
||||
- **No agent starts:** the empty-description **Start Plan Mode** path does not use the normal start-agent submission. To begin an agent immediately, enter a description and use **Start task** or **Start task in plan mode**; also confirm the selected profiles are healthy and compatible.
|
||||
- **Task starts in the wrong step:** normal creation uses **Start step** with first-step fallback; **Start task in plan mode** deliberately uses the first positional step.
|
||||
- **Task starts in the wrong step:** the destination depends on the action. **Create without starting agent** uses **Start step** with first-step fallback; **Start task** uses the first **Auto-start agent** step and falls back to **Start step**; **Start task in plan mode** deliberately uses the first positional step. An explicit `workflow_step_id` from the creator outranks all three.
|
||||
- **A task moves unexpectedly:** inspect **On Turn Start**, **On Turn Complete**, child completion, entry actions, and the destination step's entry actions.
|
||||
- **A task stays after a cancel:** check for a pending clarification, the cancelled-turn completion policy, an absent or blocked transition, a queued WIP card, or an invalid target left by an older definition.
|
||||
- **Move rejected:** check the target WIP limit and whether the task is already counted there.
|
||||
|
||||
@@ -126,7 +126,7 @@ Workflow-level settings include the name and default agent profile. A step can o
|
||||
|---------|----------|
|
||||
| Name and color | Board label and presentation. Color is stored as a CSS utility class. |
|
||||
| Prompt | Step-specific agent prompt. `{{task_prompt}}` inserts the task description. Type `@` to reference a saved prompt by name. |
|
||||
| Start step | Preferred initial step. The editor keeps at most one. If none is set, task creation falls back to the first step by position. |
|
||||
| Start step | Where a task is created when no agent starts with it. The editor keeps at most one. If none is set, task creation falls back to the first step by position. Creating a task that starts an agent immediately uses the first Auto-start agent step instead, so a Start step with no entry actions is a genuine parking column. |
|
||||
| Auto-start agent | Adds `auto_start_agent` to `on_enter`. It still needs a valid agent and executor configuration. |
|
||||
| Plan mode | Adds `enable_plan_mode` on entry. Add the matching disable behavior on completion or exit when later steps should edit files. |
|
||||
| Reset agent context | Starts the step with fresh conversation context. It is redundant when the step changes agent profile. |
|
||||
@@ -219,7 +219,7 @@ A task may contain several repositories, but a workflow step is not bound to one
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Task starts in the wrong column:** confirm exactly one Start step, save the workflow, and check whether the creator supplied an explicit `workflow_step_id`.
|
||||
- **Task starts in the wrong column:** confirm exactly one Start step, save the workflow, and check whether the creator supplied an explicit `workflow_step_id`. Remember that a create which starts an agent targets the first Auto-start agent step, not the Start step.
|
||||
- **Agent does not start:** verify the effective workflow/step agent profile, its health, executor profile, repository access, and the `auto_start_agent` entry action.
|
||||
- **Task stays after a turn:** check for an absent transition, a pending clarification, the explicit-completion toggle, a queued WIP card waiting for capacity, or an invalid target left by an older definition.
|
||||
- **Task stays after a cancel:** check for a pending clarification, the cancelled-turn completion policy, an absent or blocked transition, a queued WIP card, or an invalid target left by an older definition.
|
||||
|
||||
Reference in New Issue
Block a user