feat(tasks): add task dependencies with auto-start chaining (#2589)
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -669,6 +669,9 @@ func (b bootStateBuilder) taskDTOsWithSessionInfo(ctx context.Context, tasks []*
|
||||
if queuedErr != nil {
|
||||
b.logBootError("queued prompt counts", queuedErr)
|
||||
}
|
||||
// Dependency state is derived per read (never stored, so the auto-start gate
|
||||
// can never read a stale value). One batched call for the whole boot payload.
|
||||
dependencyViews := b.p.taskSvc.BuildDependencyViews(ctx, tasks)
|
||||
result := make([]taskdto.TaskDTO, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if task == nil {
|
||||
@@ -710,6 +713,7 @@ func (b bootStateBuilder) taskDTOsWithSessionInfo(ctx context.Context, tasks []*
|
||||
if b.p.orchestratorSvc != nil {
|
||||
taskdto.EnrichTaskForegroundActivity(&dto, sessions, b.p.orchestratorSvc)
|
||||
}
|
||||
taskdto.EnrichTaskDependencies(&dto, bootDependencyProjection(dependencyViews[task.ID]), task)
|
||||
dto.StatusSummary = statusSummaries[task.ID]
|
||||
if dto.StatusSummary != nil {
|
||||
switch {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package backendapp
|
||||
|
||||
import (
|
||||
taskdto "github.com/kandev/kandev/internal/task/dto"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
)
|
||||
|
||||
// bootDependencyProjection adapts the task service's derived DependencyView to
|
||||
// the DTO projection type. Mirrors the same adapter in the task handlers; both
|
||||
// exist because dto must stay importable from service without a cycle.
|
||||
func bootDependencyProjection(view taskservice.DependencyView) taskdto.TaskDependencyProjection {
|
||||
return taskdto.TaskDependencyProjection{
|
||||
Blocked: view.Blocked,
|
||||
BlockedReason: view.BlockedReason,
|
||||
DependsOn: bootDependencyRefs(view.DependsOn),
|
||||
Blocks: bootDependencyRefs(view.Blocks),
|
||||
}
|
||||
}
|
||||
|
||||
func bootDependencyRefs(refs []taskservice.DependencyRef) []taskdto.TaskDependencyRefDTO {
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]taskdto.TaskDependencyRefDTO, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
out = append(out, taskdto.TaskDependencyRefDTO{
|
||||
ID: r.ID, Title: r.Title, State: r.State, Status: r.Status,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -613,9 +613,34 @@ func mapKanbanTaskState(task taskdto.TaskDTO) map[string]any {
|
||||
"parentTaskId": nullString(task.ParentID),
|
||||
"updatedAt": task.UpdatedAt,
|
||||
"createdAt": task.CreatedAt,
|
||||
// Dependency projection. This mapper is a camelCase whitelist writing
|
||||
// straight into the frontend store shape, so a new DTO field is invisible
|
||||
// to the boot payload until it is listed here — the board badge and the
|
||||
// dependency chip both read these on first paint.
|
||||
"blocked": task.Blocked,
|
||||
"blockedReason": nullString(task.BlockedReason),
|
||||
"dependsOn": dependencyRefStates(task.DependsOn),
|
||||
"blocks": dependencyRefStates(task.Blocks),
|
||||
"startWhenUnblocked": task.StartWhenUnblocked,
|
||||
}
|
||||
}
|
||||
|
||||
// dependencyRefStates maps dependency edge entries into the store shape. Returns
|
||||
// an empty slice rather than nil so the client reads "no edges" instead of
|
||||
// "unknown" and does not keep a stale badge.
|
||||
func dependencyRefStates(refs []taskdto.TaskDependencyRefDTO) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
out = append(out, map[string]any{
|
||||
"id": ref.ID,
|
||||
"title": ref.Title,
|
||||
"state": ref.State,
|
||||
statusKey: ref.Status,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mapSidebarViews maps sidebar views to the SPA boot shape.
|
||||
func mapSidebarViews(views []usermodels.SidebarView) []map[string]any {
|
||||
if len(views) == 0 {
|
||||
|
||||
@@ -1078,6 +1078,14 @@ func initOfficeServices(
|
||||
cfg, repos, services, orchestratorSvc, eventBus,
|
||||
agentctlBinaryPath, cfgLoader, cfgWriter, log,
|
||||
)
|
||||
|
||||
// Task dependencies are a core Kanban relationship, not an Office feature.
|
||||
// The task_blockers table physically lives in the Office repository's DDL but
|
||||
// sits in the same database, so wire the store BEFORE the Office early return
|
||||
// below. Wiring it after left a Kanban-only install with no dependency store
|
||||
// at all: blocked_by on create failed and list_related_tasks reported nothing.
|
||||
services.Task.SetBlockerRepository(repos.Office)
|
||||
|
||||
if !cfg.Features.Office {
|
||||
log.Info("Office feature disabled; Office services skipped while global run scheduling remains enabled")
|
||||
return runProcessorSvc, true
|
||||
@@ -1105,8 +1113,9 @@ func initOfficeServices(
|
||||
// its own delivery code.
|
||||
wireRuntimeSkillDeployer(lifecycleMgr, agentRegistry, repos.Office, services.Office, cfg.ResolvedHomeDir(), log)
|
||||
|
||||
// Wire office-owned repositories into the task service for cross-package operations.
|
||||
services.Task.SetBlockerRepository(repos.Office)
|
||||
// Wire office-owned repositories into the task service for cross-package
|
||||
// operations. The blocker repository is wired above, before the Office gate,
|
||||
// because task dependencies are not Office-only.
|
||||
services.Task.SetCommentRepository(repos.Office)
|
||||
|
||||
// Build feature-package services and wire all inter-service dependencies.
|
||||
|
||||
@@ -153,6 +153,11 @@ func provideOrchestrator(
|
||||
// boot payload and task.updated events.
|
||||
taskSvc.SetForegroundActivityProvider(orchestratorSvc)
|
||||
|
||||
// Task dependencies gate every automated launch and drive chain advancement.
|
||||
// Wired unconditionally: dependencies are a core Kanban relationship, not an
|
||||
// Office feature.
|
||||
orchestratorSvc.SetTaskDependencyReader(taskSvc)
|
||||
|
||||
// Let the task service stamp status_summary.queued_prompt_count on task
|
||||
// list/snapshot payloads (initial-load backstop for the sidebar badge; the
|
||||
// status-summary projector keeps the field live between loads).
|
||||
|
||||
@@ -10,6 +10,13 @@ const (
|
||||
TaskMoved = "task.moved" // Manual step change via MoveTask
|
||||
TaskQueuePromoted = "task.queue_promoted"
|
||||
SessionWorkspaceSourcesUpdated = "session.workspace_sources.updated"
|
||||
// TaskDependenciesResolved fires when a task's last unresolved dependency
|
||||
// completes successfully. Payload: {task_id, resolved_by_task_id}.
|
||||
TaskDependenciesResolved = "task.dependencies_resolved"
|
||||
// TaskDependencyFailed fires when a blocked task's chain halts because a
|
||||
// predecessor failed or was cancelled. Payload:
|
||||
// {task_id, failed_task_id, failed_state}.
|
||||
TaskDependencyFailed = "task.dependency_failed"
|
||||
)
|
||||
|
||||
// Event types for office task tree controls.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
)
|
||||
|
||||
// dependencyMutationPayload is the wire shape for both dependency tools.
|
||||
type dependencyMutationPayload struct {
|
||||
TaskID string `json:"task_id"`
|
||||
DependsOnTaskID string `json:"depends_on_task_id"`
|
||||
}
|
||||
|
||||
// handleAddTaskDependency backs add_task_dependency_kandev.
|
||||
func (h *Handlers) handleAddTaskDependency(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
req, errMsg := decodeDependencyMutation(msg)
|
||||
if errMsg != nil {
|
||||
return errMsg, nil
|
||||
}
|
||||
err := h.taskSvc.AddDependency(ctx, req.TaskID, req.DependsOnTaskID)
|
||||
if err != nil {
|
||||
var cycle *service.CycleError
|
||||
if errors.As(err, &cycle) {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, cycle.Error(),
|
||||
map[string]interface{}{"cycle": cycle.Path})
|
||||
}
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
|
||||
}
|
||||
return h.dependencyResult(ctx, msg, req.TaskID)
|
||||
}
|
||||
|
||||
// handleRemoveTaskDependency backs remove_task_dependency_kandev. Removing an
|
||||
// absent edge succeeds.
|
||||
func (h *Handlers) handleRemoveTaskDependency(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
req, errMsg := decodeDependencyMutation(msg)
|
||||
if errMsg != nil {
|
||||
return errMsg, nil
|
||||
}
|
||||
if err := h.taskSvc.RemoveDependency(ctx, req.TaskID, req.DependsOnTaskID); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
|
||||
}
|
||||
return h.dependencyResult(ctx, msg, req.TaskID)
|
||||
}
|
||||
|
||||
func decodeDependencyMutation(msg *ws.Message) (*dependencyMutationPayload, *ws.Message) {
|
||||
var req dependencyMutationPayload
|
||||
if err := json.Unmarshal(msg.Payload, &req); err != nil {
|
||||
errMsg, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
return nil, errMsg
|
||||
}
|
||||
if req.TaskID == "" || req.DependsOnTaskID == "" {
|
||||
errMsg, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation,
|
||||
"task_id and depends_on_task_id are required", nil)
|
||||
return nil, errMsg
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
// dependencyResult returns the task's resulting dependency projection so the
|
||||
// agent sees the effect of its own call without a follow-up read.
|
||||
func (h *Handlers) dependencyResult(ctx context.Context, msg *ws.Message, taskID string) (*ws.Message, error) {
|
||||
task, err := h.taskSvc.GetTask(ctx, taskID)
|
||||
if err != nil || task == nil {
|
||||
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{keyTaskID: taskID})
|
||||
}
|
||||
views := h.taskSvc.BuildDependencyViews(ctx, []*models.Task{task})
|
||||
view := views[taskID]
|
||||
dependsOn := make([]string, 0, len(view.DependsOn))
|
||||
for _, ref := range view.DependsOn {
|
||||
dependsOn = append(dependsOn, ref.ID)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
|
||||
keyTaskID: taskID,
|
||||
"blocked": view.Blocked,
|
||||
"blocked_reason": view.BlockedReason,
|
||||
"depends_on": dependsOn,
|
||||
})
|
||||
}
|
||||
@@ -357,6 +357,8 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) {
|
||||
d.RegisterFunc(ws.ActionMCPUpdateTaskPRAutomation, h.handleUpdateTaskPRAutomation)
|
||||
d.RegisterFunc(ws.ActionMCPGetTaskMRAutomation, h.handleGetTaskMRAutomation)
|
||||
d.RegisterFunc(ws.ActionMCPUpdateTaskMRAutomation, h.handleUpdateTaskMRAutomation)
|
||||
d.RegisterFunc(ws.ActionMCPAddTaskDependency, h.handleAddTaskDependency)
|
||||
d.RegisterFunc(ws.ActionMCPRemoveTaskDependency, h.handleRemoveTaskDependency)
|
||||
d.RegisterFunc(ws.ActionMCPAddBranchToTask, h.handleAddBranchToTask)
|
||||
d.RegisterFunc(ws.ActionMCPAddWorkspaceSources, h.handleAddWorkspaceSources)
|
||||
d.RegisterFunc(ws.ActionMCPUpdateRepositoryBaseBranch, h.handleUpdateRepositoryBaseBranch)
|
||||
@@ -590,6 +592,7 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
Repositories []mcpRepositoryInput `json:"repositories"` // explicit repositories for top-level tasks
|
||||
BaseBranch string `json:"base_branch"` // top-level fallback applied to every resolved repo only when no per-repo entries are supplied; explicit per-repo BaseBranch is authoritative when Repositories is set
|
||||
BlockedBy []string `json:"blocked_by"` // task IDs that must complete before this task
|
||||
StartWhenUnblocked *bool `json:"start_when_unblocked"` // nil = derive from start_agent when BlockedBy is set
|
||||
AssigneeAgentProfileID string `json:"assignee_agent_profile_id"` // agent instance to assign the task to
|
||||
ExternalID string `json:"external_id"` // caller-supplied create-idempotency key
|
||||
}
|
||||
@@ -719,6 +722,7 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
Autopilot: req.Autopilot,
|
||||
Repositories: repos,
|
||||
BlockedBy: req.BlockedBy,
|
||||
StartWhenUnblocked: req.StartWhenUnblocked,
|
||||
AssigneeAgentProfileID: req.AssigneeAgentProfileID,
|
||||
Metadata: metadata,
|
||||
DeferredLaunch: deferredLaunch,
|
||||
@@ -809,12 +813,28 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
}
|
||||
|
||||
// Auto-start agent session asynchronously only if requested and admitted.
|
||||
if startAgent && task.QueuedForStepID == "" && h.sessionLauncher != nil {
|
||||
//
|
||||
// A create that declared dependencies does NOT launch now: the start intent
|
||||
// was recorded as a start-when-unblocked deferred launch and dependency
|
||||
// resolution consumes it. Agents pass start_agent=true by habit, so without
|
||||
// this every step of an agent-built chain would launch at once.
|
||||
startWhenUnblocked := service.ResolveStartWhenUnblocked(&service.CreateTaskRequest{
|
||||
BlockedBy: req.BlockedBy,
|
||||
StartWhenUnblocked: req.StartWhenUnblocked,
|
||||
})
|
||||
// Blockers suppress the immediate launch on their own. start_when_unblocked
|
||||
// only decides whether a DEFERRED launch is recorded, so `false` means "no
|
||||
// automatic start at all" — launching now would start a task that is blocked.
|
||||
if startAgent && len(req.BlockedBy) == 0 && task.QueuedForStepID == "" && h.sessionLauncher != nil {
|
||||
h.launchAutoStartTask(ctx, task, launchConfig)
|
||||
}
|
||||
|
||||
response := dto.FromTask(task)
|
||||
// Report the deferred start so the caller does not have to infer whether the
|
||||
// task launched or is waiting on its dependencies.
|
||||
response.StartWhenUnblocked = startWhenUnblocked
|
||||
return ws.NewResponse(msg.ID, msg.Action, mcpCreateTaskResult{
|
||||
TaskDTO: dto.FromTask(task),
|
||||
TaskDTO: response,
|
||||
Deduplicated: false,
|
||||
CreationComplete: true,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
// selfTaskSentinel is the "my own task" shorthand create_task_kandev already
|
||||
// accepts for parent_id; the dependency tools take it for task_id too.
|
||||
const selfTaskSentinel = "self"
|
||||
|
||||
// stringArrayArg reads a string array argument, tolerating the []interface{}
|
||||
// shape JSON decoding produces and skipping non-string entries rather than
|
||||
// failing the whole call.
|
||||
func stringArrayArg(req mcp.CallToolRequest, key string) []string {
|
||||
raw, ok := req.GetArguments()[key]
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case []string:
|
||||
return v
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, entry := range v {
|
||||
if s, ok := entry.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// addTaskDependencyHandler backs add_task_dependency_kandev.
|
||||
func (s *Server) addTaskDependencyHandler() server.ToolHandlerFunc {
|
||||
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return s.dispatchDependencyMutation(ctx, req, ws.ActionMCPAddTaskDependency)
|
||||
}
|
||||
}
|
||||
|
||||
// removeTaskDependencyHandler backs remove_task_dependency_kandev.
|
||||
func (s *Server) removeTaskDependencyHandler() server.ToolHandlerFunc {
|
||||
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return s.dispatchDependencyMutation(ctx, req, ws.ActionMCPRemoveTaskDependency)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchDependencyMutation resolves the target task (defaulting to the
|
||||
// caller's own) and forwards the edge mutation. Both tools share this because
|
||||
// they differ only in the action they send.
|
||||
func (s *Server) dispatchDependencyMutation(
|
||||
ctx context.Context, req mcp.CallToolRequest, action string,
|
||||
) (*mcp.CallToolResult, error) {
|
||||
dependsOn, err := req.RequireString("depends_on_task_id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("depends_on_task_id is required"), nil
|
||||
}
|
||||
taskID := req.GetString(mcpKeyTaskID, "")
|
||||
if taskID == "" || taskID == selfTaskSentinel {
|
||||
if s.taskID == "" {
|
||||
return mcp.NewToolResultError("task_id is required: no current task context"), nil
|
||||
}
|
||||
taskID = s.taskID
|
||||
}
|
||||
// In a task-bound session the dependent end is the server's own task.
|
||||
// Ownership comes from the AgentExecution the session was bound to, never
|
||||
// from an agent-supplied id, so an explicit task_id may only confirm it —
|
||||
// the same rule add_branch_to_task_kandev applies. The predecessor
|
||||
// (depends_on_task_id) is necessarily another task and stays free; the
|
||||
// backend authorizes both ends against the session's identity.
|
||||
if s.taskID != "" && taskID != s.taskID {
|
||||
return mcp.NewToolResultError(
|
||||
"task_id must be your current task; use depends_on_task_id to name the other task",
|
||||
), nil
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
mcpKeyTaskID: taskID,
|
||||
"depends_on_task_id": dependsOn,
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := s.backend.RequestPayload(ctx, action, payload, &result); err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
data, _ := json.MarshalIndent(result, "", " ")
|
||||
return mcp.NewToolResultText(string(data)), nil
|
||||
}
|
||||
@@ -187,6 +187,22 @@ func (s *Server) createTaskHandler() server.ToolHandlerFunc {
|
||||
payload["external_id"] = externalID
|
||||
}
|
||||
|
||||
// Dependency edges declared at create time. The handler already read
|
||||
// blocked_by from its payload before this feature, but the tool schema
|
||||
// never declared the parameter, so no agent could reach it.
|
||||
if blockedBy := stringArrayArg(req, "blocked_by"); len(blockedBy) > 0 {
|
||||
payload["blocked_by"] = blockedBy
|
||||
}
|
||||
// Omitted means "derive": with blocked_by set, a start request becomes a
|
||||
// start-when-unblocked intent. start_agent defaults to true and agents
|
||||
// pass it by habit, so deriving is what makes an agent-built chain run in
|
||||
// order instead of launching every step at once.
|
||||
if args := req.GetArguments(); args["start_when_unblocked"] != nil {
|
||||
if v, ok := args["start_when_unblocked"].(bool); ok {
|
||||
payload["start_when_unblocked"] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Add repository info. For subtasks an explicit repo overrides the
|
||||
// parent's; if omitted the backend inherits from the parent.
|
||||
repositoryID := req.GetString("repository_id", "")
|
||||
|
||||
@@ -68,6 +68,7 @@ func TestCreateTask_ToolSchema_HasParentID(t *testing.T) {
|
||||
"parent_id", "workspace_id", "workflow_id", "workflow_step_id", "workspace_mode",
|
||||
"title", "prompt", "autopilot", "agent_profile_id", "executor_profile_id", "start_agent",
|
||||
"repository_id", "local_path", "repository_url", "base_branch", "external_id",
|
||||
"blocked_by", "start_when_unblocked",
|
||||
}, propertyNames(props), "unexpected change to the advertised create_task_kandev schema")
|
||||
assert.NotContains(t, props, "description", "legacy alias must not increase the advertised schema")
|
||||
assert.Contains(t, tool.Tool.Description, "'prompt' is the sub-agent's initial prompt")
|
||||
|
||||
@@ -825,6 +825,8 @@ func (s *Server) profileToolGroups() []profileToolGroup {
|
||||
{name: "configuration-executors", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigExecutorTools() }},
|
||||
{name: "configuration-tasks", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigTaskTools() }},
|
||||
{name: "external-create-task", enabled: external, register: func(s *Server) { s.registerCreateTaskTool() }},
|
||||
// Dependency edges are manageable wherever a task can be created.
|
||||
{name: "task-dependencies", enabled: func(ctx mcpprofile.Context) bool { return kanban(ctx) || external(ctx) }, register: func(s *Server) { s.registerTaskDependencyTools() }},
|
||||
{name: "kanban-task", enabled: kanban, register: func(s *Server) { s.registerKanbanTools() }},
|
||||
{name: "github-pr", enabled: andProfilePredicates(kanban, func(ctx mcpprofile.Context) bool { return mcpproviders.Contains(ctx.Providers, mcpproviders.GitHub) }), register: func(s *Server) { s.registerPRAutomationTools() }},
|
||||
{name: "gitlab-mr", enabled: andProfilePredicates(kanban, func(ctx mcpprofile.Context) bool { return mcpproviders.Contains(ctx.Providers, mcpproviders.GitLab) }), register: func(s *Server) { s.registerMRAutomationTools() }},
|
||||
@@ -1157,11 +1159,60 @@ IDEMPOTENCY (external_id):
|
||||
mcp.WithString("repository_url", mcp.Description("Repository URL, GitHub pull request URL, or GitLab merge request URL (for example 'https://github.com/owner/repo'). A contribution URL attaches the task to that existing contribution and prepares its source branch. For subtasks: supply only when the subtask should target a different repo than the parent.")),
|
||||
mcp.WithString("base_branch", mcp.Description("Base branch for the repository (e.g. 'main'). Optional. Defaults: same-repo subtasks inherit the parent's base_branch; cross-repo subtasks and top-level tasks fall back to the repository's default_branch (visible via list_repositories_kandev).")),
|
||||
mcp.WithString("external_id", mcp.Description("A stable identifier from your own system (issue key, webhook delivery ID, a UUID you generated). Creating a task twice with the same external_id in the same workspace returns the first task instead of making a duplicate — use it when a retry or restart could re-run this call. Replay the same arguments you sent the first time. This creates the task when nothing holds the identity yet — it is not a lookup.")),
|
||||
mcp.WithArray("blocked_by",
|
||||
mcp.Description(blockedByParamDesc),
|
||||
mcp.Items(map[string]any{"type": "string"}),
|
||||
),
|
||||
mcp.WithBoolean("start_when_unblocked", mcp.Description(startWhenUnblockedParamDesc)),
|
||||
),
|
||||
s.wrapHandler("create_task_kandev", s.createTaskHandler()),
|
||||
)
|
||||
}
|
||||
|
||||
// Dependency parameter descriptions for create_task_kandev. Extracted as
|
||||
// constants because the same guidance has to appear on the two dependency tools
|
||||
// below and must not drift between them.
|
||||
const (
|
||||
blockedByParamDesc = "Task IDs this task depends on: it will not start until every one of them completes SUCCESSFULLY. " +
|
||||
"Use this — not parent_id — to express ordering. A subtask means \"part of\"; a dependency means \"not until\". " +
|
||||
"Decomposing a plan into ordered phases is N sibling tasks chained with blocked_by, NOT N subtasks started at once. " +
|
||||
"A predecessor that ends FAILED or CANCELLED halts the chain and needs human action; it will not retry itself."
|
||||
|
||||
startWhenUnblockedParamDesc = "Whether to start this task automatically once every task in blocked_by completes successfully. " +
|
||||
"Defaults to true when blocked_by is non-empty, which is what chains ordered work: with blocked_by set, " +
|
||||
"start_agent=true records this intent instead of launching now, so the whole chain does not start at once. " +
|
||||
"Pass false to create the dependency edges with no automatic start at all."
|
||||
)
|
||||
|
||||
// registerTaskDependencyTools registers add/remove for task dependency edges.
|
||||
// Mirrors the two HTTP routes one-to-one and shares the single edge validator
|
||||
// (self-edge, cross-workspace, cycle-with-path) in the task service. The read
|
||||
// side already exists as list_related_tasks_kandev.
|
||||
func (s *Server) registerTaskDependencyTools() {
|
||||
s.mcpServer.AddTool(
|
||||
mcp.NewTool("add_task_dependency_kandev",
|
||||
mcp.WithDescription(`Declare that a task is blocked by another task: it will not start until that one completes successfully.
|
||||
|
||||
`+blockedByParamDesc+`
|
||||
|
||||
task_id defaults to your CURRENT task. Rejected when the edge would create a cycle (the error names the cycle path), when both IDs are the same, or when the two tasks are in different workspaces. Returns the task's resulting depends_on list.`),
|
||||
mcp.WithString(mcpKeyTaskID, mcp.Description("The blocked task. Defaults to your current task when omitted.")),
|
||||
mcp.WithString("depends_on_task_id", mcp.Required(), mcp.Description("The task that must complete first (the predecessor).")),
|
||||
),
|
||||
s.wrapHandler("add_task_dependency_kandev", s.addTaskDependencyHandler()),
|
||||
)
|
||||
s.mcpServer.AddTool(
|
||||
mcp.NewTool("remove_task_dependency_kandev",
|
||||
mcp.WithDescription(`Remove a task dependency edge. Removing an edge that does not exist succeeds.
|
||||
|
||||
Removing the last edge unblocks the task but does NOT start it: an automatic start is triggered by a dependency RESOLVING, not by the edge going away. Removing the edge means you are taking manual control.`),
|
||||
mcp.WithString(mcpKeyTaskID, mcp.Description("The blocked task. Defaults to your current task when omitted.")),
|
||||
mcp.WithString("depends_on_task_id", mcp.Required(), mcp.Description("The predecessor task to unlink.")),
|
||||
),
|
||||
s.wrapHandler("remove_task_dependency_kandev", s.removeTaskDependencyHandler()),
|
||||
)
|
||||
}
|
||||
|
||||
// registerSpawnSessionTool registers spawn_session_kandev. Spawns an ADDITIONAL
|
||||
// agent session on an existing task (usually the caller's own) — unlike
|
||||
// create_task_kandev, no new task is created: the spawned session shares the
|
||||
|
||||
@@ -644,7 +644,7 @@ drained:
|
||||
// as in TestServerModeTask_ToolCount and
|
||||
// TestRegisterTools_LoggedCountMatchesRegisteredTools (list_task_sessions_test.go),
|
||||
// which pin the per-mode registration rather than this SetProviders rebuild.
|
||||
require.Len(t, tools, 33, "final registry should contain the complete GitLab-only task tool set")
|
||||
require.Len(t, tools, 35, "final registry should contain the complete GitLab-only task tool set")
|
||||
assert.Contains(t, tools, "get_task_mr_automation_kandev")
|
||||
assert.NotContains(t, tools, "get_task_pr_automation_kandev")
|
||||
}
|
||||
@@ -812,7 +812,8 @@ func TestServerModeTask_ToolCount(t *testing.T) {
|
||||
// list_task_sessions + PR automation + MR automation) + 1 add_branch_to_task +
|
||||
// 1 add_workspace_sources + 1 update_repository_base_branch +
|
||||
// 1 step_complete (ADR 0015) + 1 interaction + 4 plan + 3 walkthrough +
|
||||
// 1 publish_review_findings + 1 related-tasks + 1 diagnostic bundle = 35.
|
||||
// 1 publish_review_findings + 1 related-tasks + 1 diagnostic bundle
|
||||
// + 2 task-dependency (add/remove) = 37.
|
||||
// Task-document tools (list/get/write) are office-only.
|
||||
assert.Contains(t, tools, "step_complete_kandev", "ADR 0015 explicit-completion signal must be registered in task mode")
|
||||
assert.Contains(t, tools, "show_walkthrough_kandev", "walkthrough tool must be registered in task mode")
|
||||
@@ -820,7 +821,9 @@ func TestServerModeTask_ToolCount(t *testing.T) {
|
||||
assert.Contains(t, tools, "spawn_session_kandev", "spawn_session must be registered in task mode")
|
||||
assert.Contains(t, tools, "add_workspace_sources_kandev")
|
||||
assert.Contains(t, tools, "list_task_sessions_kandev", "session discovery must be registered in task mode")
|
||||
assert.Equal(t, 35, len(tools))
|
||||
assert.Contains(t, tools, "add_task_dependency_kandev", "dependency edges must be manageable in task mode")
|
||||
assert.Contains(t, tools, "remove_task_dependency_kandev")
|
||||
assert.Equal(t, 37, len(tools))
|
||||
}
|
||||
|
||||
func TestServerStepCompleteTool_TaskOnlyAndDiscoverable(t *testing.T) {
|
||||
@@ -1009,9 +1012,9 @@ func TestServerModeExternal_ToolCount(t *testing.T) {
|
||||
|
||||
s := New(backend, "", "", 0, log, "", true, ModeExternal)
|
||||
tools := getRegisteredToolNames(s)
|
||||
// 12 workflow (incl. list_repositories + import_workflow) + 4 agent + 4 mcp + 5 executor + 7 task (incl. list_task_sessions) + 1 create_task = 33.
|
||||
// 12 workflow (incl. list_repositories + import_workflow) + 4 agent + 4 mcp + 5 executor + 7 task (incl. list_task_sessions) + 1 create_task + 2 task-dependency = 35.
|
||||
// add_branch_to_task_kandev is task-mode only — external coding agents have no live session to attach a worktree to.
|
||||
assert.Equal(t, 33, len(tools))
|
||||
assert.Equal(t, 35, len(tools))
|
||||
assert.NotContains(t, tools, "add_branch_to_task_kandev")
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,62 @@ func (r *Repository) ListBlockersForTasks(ctx context.Context, taskIDs []string)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListDependentsForTasks returns a map of blocker task ID → list of task IDs
|
||||
// blocked by it, for a set of blocker task IDs. This is the batched reverse of
|
||||
// ListBlockersForTasks and backs the "blocks" direction of the dependency
|
||||
// payload; a per-task query would add one round trip per card to a board read.
|
||||
func (r *Repository) ListDependentsForTasks(ctx context.Context, blockerTaskIDs []string) (map[string][]string, error) {
|
||||
if len(blockerTaskIDs) == 0 {
|
||||
return map[string][]string{}, nil
|
||||
}
|
||||
query, args, err := sqlx.In(
|
||||
`SELECT task_id, blocker_task_id FROM task_blockers WHERE blocker_task_id IN (?) ORDER BY created_at, task_id`,
|
||||
blockerTaskIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query = r.ro.Rebind(query)
|
||||
type row struct {
|
||||
TaskID string `db:"task_id"`
|
||||
BlockerTaskID string `db:"blocker_task_id"`
|
||||
}
|
||||
var rows []row
|
||||
if err := r.ro.SelectContext(ctx, &rows, query, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string][]string, len(blockerTaskIDs))
|
||||
for _, rw := range rows {
|
||||
result[rw.BlockerTaskID] = append(result[rw.BlockerTaskID], rw.TaskID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListTasksWithDependencies returns the distinct dependent task IDs that have at
|
||||
// least one dependency edge. Used by startup reconciliation to bound its sweep to
|
||||
// tasks that could be chain steps rather than scanning the whole board.
|
||||
func (r *Repository) ListTasksWithDependencies(ctx context.Context) ([]string, error) {
|
||||
var ids []string
|
||||
err := r.ro.SelectContext(ctx, &ids,
|
||||
`SELECT DISTINCT task_id FROM task_blockers ORDER BY task_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ids == nil {
|
||||
ids = []string{}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// DeleteTaskBlockersForTask removes every dependency edge touching taskID, in
|
||||
// both directions. Called when a task is deleted: task_blockers predates the
|
||||
// tasks foreign key, so there is no ON DELETE CASCADE to rely on and orphaned
|
||||
// edges would keep dependents blocked on a task that no longer exists.
|
||||
func (r *Repository) DeleteTaskBlockersForTask(ctx context.Context, taskID string) error {
|
||||
_, err := r.db.ExecContext(ctx, r.db.Rebind(
|
||||
`DELETE FROM task_blockers WHERE task_id = ? OR blocker_task_id = ?`), taskID, taskID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) IsTaskInTerminalStep(ctx context.Context, taskID string) (bool, error) {
|
||||
var state string
|
||||
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(
|
||||
|
||||
@@ -24,6 +24,10 @@ func (s *Service) handleTaskStateChanged(ctx context.Context, data watcher.TaskE
|
||||
}
|
||||
|
||||
s.processParentChildrenCompletedForTaskState(ctx, data.TaskID, *data.NewState)
|
||||
// Peer dependencies react to the same terminal transitions, but with a
|
||||
// stricter definition of "done": on_children_completed counts FAILED as
|
||||
// terminal, dependency resolution requires success. Keep the two separate.
|
||||
s.handleTaskDependenciesForTerminalState(ctx, data.TaskID, *data.NewState)
|
||||
}
|
||||
|
||||
func (s *Service) processParentChildrenCompletedForTaskState(ctx context.Context, taskID string, state v1.TaskState) {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
// TaskDependencyReader is the orchestrator's read seam onto task dependency
|
||||
// state. Implemented by the task service.
|
||||
type TaskDependencyReader interface {
|
||||
// DependencyGate reports whether an automated path may start taskID.
|
||||
// Returns blocked=true on any read error so the gate fails closed.
|
||||
DependencyGate(ctx context.Context, taskID string) (bool, string, error)
|
||||
// ListDependentTaskIDs returns the tasks directly blocked by taskID.
|
||||
ListDependentTaskIDs(ctx context.Context, taskID string) ([]string, error)
|
||||
// ListPendingDependencyLaunches returns tasks that will start on their own
|
||||
// once their dependencies resolve. Used by startup reconciliation.
|
||||
ListPendingDependencyLaunches(ctx context.Context) ([]taskservice.PendingDependencyLaunch, error)
|
||||
}
|
||||
|
||||
// SetTaskDependencyReader wires dependency reads for the auto-start gate and
|
||||
// the dependency-resolution reaction.
|
||||
func (s *Service) SetTaskDependencyReader(reader TaskDependencyReader) {
|
||||
s.dependencyReader = reader
|
||||
}
|
||||
|
||||
// dependencyBlocksAutoStart reports whether taskID has an unresolved dependency
|
||||
// and therefore must not be launched by an automated path.
|
||||
//
|
||||
// Fails CLOSED: a read error returns true. Failing open would launch work whose
|
||||
// predecessor may never have run, which is the single outcome task dependencies
|
||||
// exist to prevent.
|
||||
func (s *Service) dependencyBlocksAutoStart(ctx context.Context, taskID, eventName string) bool {
|
||||
if s.dependencyReader == nil {
|
||||
return false
|
||||
}
|
||||
blocked, reason, err := s.dependencyReader.DependencyGate(ctx, taskID)
|
||||
if err != nil {
|
||||
s.logger.Warn(eventName+": dependency lookup failed; skipping auto-start",
|
||||
zap.String("task_id", taskID), zap.Error(err))
|
||||
return true
|
||||
}
|
||||
if blocked {
|
||||
s.logger.Debug(eventName+": task has unresolved dependencies; skipping auto-start",
|
||||
zap.String("task_id", taskID), zap.String("blocked_reason", reason))
|
||||
}
|
||||
return blocked
|
||||
}
|
||||
|
||||
// handleTaskDependenciesForTerminalState reacts to a task reaching a terminal
|
||||
// state by re-evaluating every task that depends on it.
|
||||
//
|
||||
// Called from handleTaskStateChanged. Moves into a terminal workflow step
|
||||
// converge here too: markTaskCompletedForTerminalStep persists
|
||||
// state = COMPLETED and publishes task.state_changed, so this one hook covers
|
||||
// both "agent finished" and "card dragged to Done".
|
||||
func (s *Service) handleTaskDependenciesForTerminalState(ctx context.Context, taskID string, state v1.TaskState) {
|
||||
if s.dependencyReader == nil {
|
||||
return
|
||||
}
|
||||
dependents, err := s.dependencyReader.ListDependentTaskIDs(ctx, taskID)
|
||||
if err != nil {
|
||||
s.logger.Warn("dependency resolution: failed to list dependents",
|
||||
zap.String("task_id", taskID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, dependentID := range dependents {
|
||||
s.evaluateDependentAfterPredecessorChange(ctx, dependentID, taskID, state)
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateDependentAfterPredecessorChange decides what one dependent should do
|
||||
// now that a predecessor reached a terminal state.
|
||||
func (s *Service) evaluateDependentAfterPredecessorChange(
|
||||
ctx context.Context, dependentID, predecessorID string, predecessorState v1.TaskState,
|
||||
) {
|
||||
blocked, reason, err := s.dependencyReader.DependencyGate(ctx, dependentID)
|
||||
if err != nil {
|
||||
s.logger.Warn("dependency resolution: gate lookup failed",
|
||||
zap.String("task_id", dependentID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if blocked {
|
||||
// Still waiting on something. When nothing is pending and the only
|
||||
// obstacle is a failed predecessor, the chain has halted and needs a
|
||||
// human: surface it rather than retrying or dropping the edge.
|
||||
if reason == taskservice.BlockedReasonFailed {
|
||||
s.publishDependencyFailed(ctx, dependentID, predecessorID, predecessorState)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Every predecessor resolved successfully. Publish, then enter the SAME
|
||||
// auto-start chokepoint every other automated launch uses, so the existing
|
||||
// deferred-launch claim keeps this to one session even if WIP promotion
|
||||
// reaches the task at the same moment.
|
||||
s.publishDependenciesResolved(ctx, dependentID, predecessorID)
|
||||
task, err := s.repo.GetTask(ctx, dependentID)
|
||||
if err != nil || task == nil {
|
||||
s.logger.Warn("dependency resolution: failed to load unblocked task",
|
||||
zap.String("task_id", dependentID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
// Resolution starts a task only when it recorded a start-when-unblocked
|
||||
// intent. Without this, a blocked task parked in a step that has
|
||||
// on_enter:auto_start_agent would launch the moment its gate opened, even
|
||||
// though nobody asked for a start — the chokepoint's step-based auto-start
|
||||
// is meant for a task ENTERING that step, not for one already sitting in it.
|
||||
if !taskservice.HasStartWhenUnblockedIntent(task) {
|
||||
s.logger.Debug("dependency resolution: no start-when-unblocked intent; leaving task unstarted",
|
||||
zap.String("task_id", task.ID))
|
||||
return
|
||||
}
|
||||
s.autoStartTaskForStep(ctx, task.ID, task.WorkflowStepID, events.TaskDependenciesResolved)
|
||||
}
|
||||
|
||||
// publishDependenciesResolved announces that a task's last unresolved
|
||||
// dependency resolved successfully.
|
||||
func (s *Service) publishDependenciesResolved(ctx context.Context, taskID, resolvedByTaskID string) {
|
||||
if s.eventBus == nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]any{metaKeyTaskID: taskID, "resolved_by_task_id": resolvedByTaskID}
|
||||
if err := s.eventBus.Publish(ctx, events.TaskDependenciesResolved,
|
||||
bus.NewEvent(events.TaskDependenciesResolved, "orchestrator", payload)); err != nil {
|
||||
s.logger.Warn("failed to publish task.dependencies_resolved",
|
||||
zap.String("task_id", taskID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// publishDependencyFailed announces that a blocked task's chain has halted
|
||||
// because a predecessor failed or was cancelled.
|
||||
func (s *Service) publishDependencyFailed(
|
||||
ctx context.Context, taskID, failedTaskID string, failedState v1.TaskState,
|
||||
) {
|
||||
if s.eventBus == nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
metaKeyTaskID: taskID,
|
||||
"failed_task_id": failedTaskID,
|
||||
"failed_state": string(failedState),
|
||||
}
|
||||
if err := s.eventBus.Publish(ctx, events.TaskDependencyFailed,
|
||||
bus.NewEvent(events.TaskDependencyFailed, "orchestrator", payload)); err != nil {
|
||||
s.logger.Warn("failed to publish task.dependency_failed",
|
||||
zap.String("task_id", taskID), zap.Error(err))
|
||||
}
|
||||
// Refresh the dependent so its card and chip re-render with the failed
|
||||
// reason without waiting for the next board load.
|
||||
if task, err := s.repo.GetTask(ctx, taskID); err == nil && task != nil {
|
||||
s.publishTaskUpdated(ctx, task)
|
||||
}
|
||||
}
|
||||
|
||||
// reconcileDependencyLaunchesOnStartup launches tasks whose dependencies were
|
||||
// satisfied while the process was down.
|
||||
//
|
||||
// Recovery path for a restart between a predecessor's completion and the
|
||||
// dependent's launch: task.dependencies_resolved is an in-memory event and is
|
||||
// not replayed, so without this sweep a chain would stall silently across a
|
||||
// restart. Idempotent — the deferred launch claim admits one launcher however
|
||||
// many times this runs.
|
||||
//
|
||||
// Scoped to tasks that actually carry a start-when-unblocked intent, so the
|
||||
// sweep is bounded by the number of pending chain steps rather than the board.
|
||||
func (s *Service) reconcileDependencyLaunchesOnStartup(ctx context.Context) {
|
||||
if s.dependencyReader == nil {
|
||||
return
|
||||
}
|
||||
pending, err := s.dependencyReader.ListPendingDependencyLaunches(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("startup: failed to list pending dependency launches", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, candidate := range pending {
|
||||
blocked, _, err := s.dependencyReader.DependencyGate(ctx, candidate.TaskID)
|
||||
if err != nil || blocked {
|
||||
continue
|
||||
}
|
||||
s.logger.Info("startup: launching task whose dependencies resolved while down",
|
||||
zap.String("task_id", candidate.TaskID))
|
||||
s.autoStartTaskForStep(ctx, candidate.TaskID, candidate.WorkflowStepID, "startup.dependencies_resolved")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
)
|
||||
|
||||
// Dependency resolution must launch only tasks that recorded a
|
||||
// start-when-unblocked intent. Without this, a blocked task sitting in a step
|
||||
// with on_enter:auto_start_agent would launch the moment its gate opened, even
|
||||
// though nobody asked for a start: the chokepoint's step-based auto-start is
|
||||
// meant for a task ENTERING that step, not one already parked in it.
|
||||
func TestResolutionLaunchesOnlyTasksWithARecordedIntent(t *testing.T) {
|
||||
chainStep := &models.Task{Metadata: map[string]interface{}{
|
||||
models.MetaKeyDeferredLaunch: map[string]interface{}{
|
||||
"intent": "start", "agent_profile_id": "p",
|
||||
models.DeferredLaunchStartWhenUnblockedKey: true,
|
||||
},
|
||||
}}
|
||||
if !taskservice.HasStartWhenUnblockedIntent(chainStep) {
|
||||
t.Error("a chain step must be recognised as launchable on resolution")
|
||||
}
|
||||
|
||||
// A WIP-overflow intent is the same record without the flag; resolution must
|
||||
// not adopt it, or a queued task would launch for the wrong reason.
|
||||
wipOverflow := &models.Task{Metadata: map[string]interface{}{
|
||||
models.MetaKeyDeferredLaunch: map[string]interface{}{
|
||||
"intent": "start", "agent_profile_id": "p",
|
||||
},
|
||||
}}
|
||||
if taskservice.HasStartWhenUnblockedIntent(wipOverflow) {
|
||||
t.Error("a WIP-overflow intent must not be treated as a chain intent")
|
||||
}
|
||||
|
||||
for name, task := range map[string]*models.Task{
|
||||
"no metadata": {},
|
||||
"nil task": nil,
|
||||
} {
|
||||
if taskservice.HasStartWhenUnblockedIntent(task) {
|
||||
t.Errorf("%s: must not be launchable on resolution", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The failure reason compared in the resolution path must be the exported
|
||||
// constant, not a local copy that can drift out of sync silently.
|
||||
func TestFailedReasonUsesTheExportedConstant(t *testing.T) {
|
||||
if taskservice.BlockedReasonFailed != "failed" {
|
||||
t.Fatalf("BlockedReasonFailed = %q; the resolution path compares against it",
|
||||
taskservice.BlockedReasonFailed)
|
||||
}
|
||||
}
|
||||
@@ -486,6 +486,13 @@ func (s *Service) autoStartTaskForStep(ctx context.Context, taskID, stepID, even
|
||||
if task == nil || task.QueuedForStepID != "" {
|
||||
return
|
||||
}
|
||||
// Dependency gate. Sits here — the single automated-launch chokepoint — so
|
||||
// one check covers task.moved, task.queue_promoted, watcher auto-start and
|
||||
// dependency resolution itself. Placed BEFORE launchDeferredTask so a
|
||||
// blocked task neither starts a session nor consumes its launch intent.
|
||||
if s.dependencyBlocksAutoStart(ctx, taskID, eventName) {
|
||||
return
|
||||
}
|
||||
if s.launchDeferredTask(ctx, task, eventName) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -388,6 +388,11 @@ type Service struct {
|
||||
// Optional.
|
||||
stepHistoryRecorder StepHistoryRecorder
|
||||
|
||||
// Reads task dependency state for the auto-start gate and the
|
||||
// dependency-resolution reaction. Nil-safe: when unset, no task has
|
||||
// dependencies so nothing is gated.
|
||||
dependencyReader TaskDependencyReader
|
||||
|
||||
// Resolves the agent family names written in configure_session rules onto
|
||||
// canonical agent IDs. Nil-safe: when unset, rule matching falls back to an
|
||||
// exact string comparison.
|
||||
@@ -1653,6 +1658,11 @@ func (s *Service) Start(ctx context.Context) error {
|
||||
if s.workflowStore != nil {
|
||||
s.workflowStore.ReconcileQueuedTasks(ctx)
|
||||
}
|
||||
// Chains whose predecessor completed while the process was down: the
|
||||
// dependencies_resolved event is in-memory and is not replayed, so without
|
||||
// this sweep a chain stalls silently across a restart. Runs after the WIP
|
||||
// reconciler so an admitted-by-promotion task is already eligible.
|
||||
s.reconcileDependencyLaunchesOnStartup(ctx)
|
||||
|
||||
// Start the watcher first to begin receiving events
|
||||
if err := s.watcher.Start(ctx); err != nil {
|
||||
|
||||
@@ -189,6 +189,22 @@ type TaskDTO struct {
|
||||
// metadata key at DTO conversion time (see FromTaskWithSessionInfo).
|
||||
Interrupted bool `json:"interrupted,omitempty"`
|
||||
|
||||
// Dependency projection. Derived on every read from task_blockers plus each
|
||||
// related task's own state — never persisted, because a stale copy would be
|
||||
// read by the auto-start gate. Stamped by EnrichTaskDependencies.
|
||||
Blocked bool `json:"blocked,omitempty"`
|
||||
// BlockedReason is "pending", "failed", or "unknown"; omitted when the task
|
||||
// is not blocked.
|
||||
BlockedReason string `json:"blocked_reason,omitempty"`
|
||||
// DependsOn lists direct predecessors, DependsOn/Blocks are not transitive.
|
||||
DependsOn []TaskDependencyRefDTO `json:"depends_on,omitempty"`
|
||||
// Blocks lists direct dependents so the dependency chip can render both
|
||||
// directions without a second round trip.
|
||||
Blocks []TaskDependencyRefDTO `json:"blocks,omitempty"`
|
||||
// StartWhenUnblocked reports that a launch intent is waiting on dependency
|
||||
// resolution. Read-only here; set through the create request or the picker.
|
||||
StartWhenUnblocked bool `json:"start_when_unblocked,omitempty"`
|
||||
|
||||
// Office extensions
|
||||
AssigneeAgentProfileID string `json:"assignee_agent_profile_id,omitempty"`
|
||||
Origin string `json:"origin,omitempty"`
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
// TaskDependencyRefDTO is one end of a dependency edge, carrying enough detail
|
||||
// for the dependency chip and the blocked badge to render without fetching each
|
||||
// related task individually.
|
||||
type TaskDependencyRefDTO struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
State v1.TaskState `json:"state,omitempty"`
|
||||
// Status is the resolution verdict for this edge: "resolved", "failed", or
|
||||
// "pending". Only meaningful on depends_on entries.
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// TaskDependencyProjection is the shape a caller supplies to stamp dependency
|
||||
// state onto a DTO. It mirrors service.DependencyView without importing the
|
||||
// service package (dto must stay importable from it).
|
||||
type TaskDependencyProjection struct {
|
||||
Blocked bool
|
||||
BlockedReason string
|
||||
DependsOn []TaskDependencyRefDTO
|
||||
Blocks []TaskDependencyRefDTO
|
||||
}
|
||||
|
||||
// EnrichTaskDependencies stamps the derived dependency projection onto a task
|
||||
// DTO, following the same read-time-enrichment pattern as
|
||||
// EnrichTaskForegroundActivity.
|
||||
//
|
||||
// StartWhenUnblocked is read from the task's deferred-launch metadata rather
|
||||
// than passed in: the intent and the edges are stored separately, and only the
|
||||
// combination means "this will start on its own".
|
||||
func EnrichTaskDependencies(dto *TaskDTO, projection TaskDependencyProjection, task *models.Task) {
|
||||
if dto == nil {
|
||||
return
|
||||
}
|
||||
dto.Blocked = projection.Blocked
|
||||
dto.BlockedReason = projection.BlockedReason
|
||||
dto.DependsOn = projection.DependsOn
|
||||
dto.Blocks = projection.Blocks
|
||||
dto.StartWhenUnblocked = HasStartWhenUnblockedIntent(task)
|
||||
}
|
||||
|
||||
// HasStartWhenUnblockedIntent reports whether the task carries a deferred launch
|
||||
// intent that dependency resolution should consume. Delegates to the models
|
||||
// helper, which owns the metadata key.
|
||||
func HasStartWhenUnblockedIntent(task *models.Task) bool {
|
||||
return models.HasStartWhenUnblockedIntent(task)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kandev/kandev/internal/task/dto"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
)
|
||||
|
||||
// dependencyProjection adapts the service's derived DependencyView to the DTO
|
||||
// package's projection type. The two are kept separate so dto stays importable
|
||||
// from service without a cycle.
|
||||
func dependencyProjection(view service.DependencyView) dto.TaskDependencyProjection {
|
||||
return dto.TaskDependencyProjection{
|
||||
Blocked: view.Blocked,
|
||||
BlockedReason: view.BlockedReason,
|
||||
DependsOn: dependencyRefs(view.DependsOn),
|
||||
Blocks: dependencyRefs(view.Blocks),
|
||||
}
|
||||
}
|
||||
|
||||
func dependencyRefs(refs []service.DependencyRef) []dto.TaskDependencyRefDTO {
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]dto.TaskDependencyRefDTO, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
out = append(out, dto.TaskDependencyRefDTO{
|
||||
ID: r.ID, Title: r.Title, State: r.State, Status: r.Status,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Response keys repeated across the dependency handlers.
|
||||
const (
|
||||
dependencyKeyError = "error"
|
||||
dependencyKeyTaskID = "task_id"
|
||||
)
|
||||
|
||||
// addTaskDependencyBody is the POST /tasks/:id/dependencies payload.
|
||||
type addTaskDependencyBody struct {
|
||||
DependsOnTaskID string `json:"depends_on_task_id" binding:"required"`
|
||||
}
|
||||
|
||||
// httpAddTaskDependency handles POST /api/v1/tasks/:id/dependencies —
|
||||
// "this task is blocked by depends_on_task_id".
|
||||
//
|
||||
// A cycle returns 409 with a `cycle` array so the frontend can render
|
||||
// "A → B → C → A"; that body shape is what BlockersPicker already parses.
|
||||
func (h *TaskHandlers) httpAddTaskDependency(c *gin.Context) {
|
||||
taskID := c.Param("id")
|
||||
var body addTaskDependencyBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{dependencyKeyError: "depends_on_task_id is required"})
|
||||
return
|
||||
}
|
||||
err := h.service.AddDependency(c.Request.Context(), taskID, body.DependsOnTaskID)
|
||||
if err == nil {
|
||||
h.respondWithDependencies(c, taskID)
|
||||
return
|
||||
}
|
||||
var cycle *service.CycleError
|
||||
if errors.As(err, &cycle) {
|
||||
c.JSON(http.StatusConflict, gin.H{dependencyKeyError: cycle.Error(), "cycle": cycle.Path})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrDependencyRepositoryUnavailable) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{dependencyKeyError: err.Error()})
|
||||
return
|
||||
}
|
||||
handleNotFound(c, h.logger, err, "task not found")
|
||||
}
|
||||
|
||||
// httpRemoveTaskDependency handles DELETE /api/v1/tasks/:id/dependencies/:depId.
|
||||
// Removing an absent edge is a success no-op.
|
||||
func (h *TaskHandlers) httpRemoveTaskDependency(c *gin.Context) {
|
||||
taskID := c.Param("id")
|
||||
if err := h.service.RemoveDependency(c.Request.Context(), taskID, c.Param("depId")); err != nil {
|
||||
if errors.Is(err, service.ErrDependencyRepositoryUnavailable) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{dependencyKeyError: err.Error()})
|
||||
return
|
||||
}
|
||||
handleNotFound(c, h.logger, err, "task not found")
|
||||
return
|
||||
}
|
||||
h.respondWithDependencies(c, taskID)
|
||||
}
|
||||
|
||||
// respondWithDependencies returns the task's dependency projection after a
|
||||
// mutation so the caller does not have to re-fetch the task to learn the result.
|
||||
func (h *TaskHandlers) respondWithDependencies(c *gin.Context, taskID string) {
|
||||
task, err := h.service.GetTask(c.Request.Context(), taskID)
|
||||
if err != nil || task == nil {
|
||||
c.JSON(http.StatusOK, gin.H{dependencyKeyTaskID: taskID})
|
||||
return
|
||||
}
|
||||
views := h.service.BuildDependencyViews(c.Request.Context(), []*models.Task{task})
|
||||
// Serialize through the same DTO adapter the task-list path uses, so a
|
||||
// mutation response and a list response are one JSON contract.
|
||||
projection := dependencyProjection(views[taskID])
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
dependencyKeyTaskID: taskID,
|
||||
"blocked": projection.Blocked,
|
||||
"blocked_reason": projection.BlockedReason,
|
||||
"depends_on": projection.DependsOn,
|
||||
"blocks": projection.Blocks,
|
||||
})
|
||||
}
|
||||
@@ -166,6 +166,12 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) {
|
||||
api.POST("/tasks/:id/unarchive", h.httpUnarchiveTask)
|
||||
api.GET("/tasks/:id/subtask-count", h.httpTaskSubtaskCount)
|
||||
|
||||
// Task dependencies ("this task is blocked by that one"). Task-scoped
|
||||
// equivalents of the Office-only blocker routes; both go through the single
|
||||
// validator in the task service.
|
||||
api.POST("/tasks/:id/dependencies", h.httpAddTaskDependency)
|
||||
api.DELETE("/tasks/:id/dependencies/:depId", h.httpRemoveTaskDependency)
|
||||
|
||||
api.POST("/tasks/bulk-move", h.httpBulkMoveTasks)
|
||||
api.GET("/workflows/:id/task-count", h.httpGetWorkflowTaskCount)
|
||||
api.GET("/workflow/steps/:id/task-count", h.httpGetStepTaskCount)
|
||||
|
||||
@@ -250,6 +250,10 @@ func buildTaskDTOsWithSessionInfo(
|
||||
if queuedErr != nil {
|
||||
log.Warn("failed to load queued prompt counts for task list, omitting badges", zap.Error(queuedErr))
|
||||
}
|
||||
// Dependency state is derived, never stored, so it is computed per read. One
|
||||
// batched call for the whole list: a per-task query would add a round trip
|
||||
// per card to every board load.
|
||||
dependencyViews := svc.BuildDependencyViews(ctx, tasks)
|
||||
result := make([]dto.TaskDTO, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
sessions := sessionsByTask[task.ID]
|
||||
@@ -281,6 +285,7 @@ func buildTaskDTOsWithSessionInfo(
|
||||
)
|
||||
taskDTO.TaskPendingAction = taskPendingActionPtr(sessions, pendingActionsBySession)
|
||||
dto.EnrichTaskForegroundActivity(&taskDTO, sessions, activityProvider)
|
||||
dto.EnrichTaskDependencies(&taskDTO, dependencyProjection(dependencyViews[task.ID]), task)
|
||||
taskDTO.StatusSummary = statusSummaries[task.ID]
|
||||
if taskDTO.StatusSummary != nil {
|
||||
switch {
|
||||
@@ -752,7 +757,10 @@ type httpCreateTaskRequest struct {
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
WorkspacePath string `json:"workspace_path,omitempty"`
|
||||
BlockedBy []string `json:"blocked_by,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
// StartWhenUnblocked records the agent start as an intent consumed by
|
||||
// dependency resolution. nil derives it from StartAgent when BlockedBy is set.
|
||||
StartWhenUnblocked *bool `json:"start_when_unblocked,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
// ExternalID is a caller-supplied identity used for create-idempotency
|
||||
// (docs/specs/tasks/external-id-idempotency/spec.md).
|
||||
ExternalID string `json:"external_id,omitempty"`
|
||||
@@ -902,25 +910,26 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
|
||||
}
|
||||
|
||||
result, err := h.service.CreateTask(c.Request.Context(), &service.CreateTaskRequest{
|
||||
WorkspaceID: body.WorkspaceID,
|
||||
WorkflowID: body.WorkflowID,
|
||||
WorkflowStepID: body.WorkflowStepID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
AutoTitle: body.AutoTitle,
|
||||
Autopilot: body.Autopilot,
|
||||
Priority: body.Priority,
|
||||
State: body.State,
|
||||
Repositories: convertToServiceRepos(repos),
|
||||
Position: body.Position,
|
||||
Metadata: metadata,
|
||||
DeferredLaunch: deferredLaunch,
|
||||
PlanMode: body.PlanMode && !body.StartAgent,
|
||||
ParentID: body.ParentID,
|
||||
WorkspacePath: body.WorkspacePath,
|
||||
BlockedBy: body.BlockedBy,
|
||||
ProjectID: body.ProjectID,
|
||||
ExternalID: body.ExternalID,
|
||||
WorkspaceID: body.WorkspaceID,
|
||||
WorkflowID: body.WorkflowID,
|
||||
WorkflowStepID: body.WorkflowStepID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
AutoTitle: body.AutoTitle,
|
||||
Autopilot: body.Autopilot,
|
||||
Priority: body.Priority,
|
||||
State: body.State,
|
||||
Repositories: convertToServiceRepos(repos),
|
||||
Position: body.Position,
|
||||
Metadata: metadata,
|
||||
DeferredLaunch: deferredLaunch,
|
||||
PlanMode: body.PlanMode && !body.StartAgent,
|
||||
ParentID: body.ParentID,
|
||||
WorkspacePath: body.WorkspacePath,
|
||||
BlockedBy: body.BlockedBy,
|
||||
StartWhenUnblocked: body.StartWhenUnblocked,
|
||||
ProjectID: body.ProjectID,
|
||||
ExternalID: body.ExternalID,
|
||||
})
|
||||
if err != nil {
|
||||
handleNotFound(c, h.logger, err, "task not created")
|
||||
@@ -987,7 +996,19 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
|
||||
// prepareTaskSession's doc comment for why. dispatch is nil unless a
|
||||
// start_agent create genuinely prepared a session; it is only ever acted
|
||||
// on below once settlement has succeeded.
|
||||
dispatch := h.prepareTaskSession(c, &response, taskDTO.ID, body, resolvedStepID, task.QueuedForStepID == "")
|
||||
// A create that declared dependencies records its start as a
|
||||
// start-when-unblocked intent instead of launching now; dependency
|
||||
// resolution consumes it later.
|
||||
startWhenUnblocked := service.ResolveStartWhenUnblocked(&service.CreateTaskRequest{
|
||||
BlockedBy: body.BlockedBy, StartWhenUnblocked: body.StartWhenUnblocked,
|
||||
})
|
||||
response.StartWhenUnblocked = startWhenUnblocked
|
||||
|
||||
// Blockers suppress the immediate launch regardless of start_when_unblocked:
|
||||
// that flag governs the deferred launch only, so `false` must not mean
|
||||
// "start the blocked task now".
|
||||
dispatch := h.prepareTaskSession(c, &response, taskDTO.ID, body, resolvedStepID,
|
||||
task.QueuedForStepID == "" && len(body.BlockedBy) == 0)
|
||||
|
||||
// Settlement (create-sequence step 7): after all required synchronous
|
||||
// work above — including session prepare — and before any asynchronous
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func launchTask(record map[string]interface{}) *Task {
|
||||
return &Task{Metadata: map[string]interface{}{MetaKeyDeferredLaunch: record}}
|
||||
}
|
||||
|
||||
// A WIP-overflow record is stale the moment the task is admitted, so admission
|
||||
// drops it. That has been true since the WIP queue shipped.
|
||||
func TestDropWIPDeferredLaunchRemovesAnOverflowRecord(t *testing.T) {
|
||||
task := launchTask(map[string]interface{}{"intent": "start"})
|
||||
|
||||
DropWIPDeferredLaunch(task)
|
||||
|
||||
if _, ok := task.Metadata[MetaKeyDeferredLaunch]; ok {
|
||||
t.Fatal("an admitted task must not keep its WIP overflow launch record")
|
||||
}
|
||||
}
|
||||
|
||||
// The regression this helper exists for. A dependency chain step is admitted at
|
||||
// create time like any other task, so deleting the shared record on admission
|
||||
// erased the intent before a predecessor could ever resolve it, and the whole
|
||||
// chain silently never ran. The record is waiting on a predecessor, not on
|
||||
// capacity, so admission has nothing to say about it.
|
||||
func TestDropWIPDeferredLaunchKeepsAStartWhenUnblockedIntent(t *testing.T) {
|
||||
task := launchTask(map[string]interface{}{
|
||||
"intent": "start",
|
||||
DeferredLaunchStartWhenUnblockedKey: true,
|
||||
})
|
||||
|
||||
DropWIPDeferredLaunch(task)
|
||||
|
||||
if !HasStartWhenUnblockedIntent(task) {
|
||||
t.Fatal("WIP admission must not consume a dependency-chain launch intent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropWIPDeferredLaunchToleratesEmptyTasks(t *testing.T) {
|
||||
DropWIPDeferredLaunch(nil)
|
||||
DropWIPDeferredLaunch(&Task{})
|
||||
DropWIPDeferredLaunch(&Task{Metadata: map[string]interface{}{}})
|
||||
}
|
||||
@@ -95,6 +95,12 @@ const (
|
||||
MetaKeyExecutorID = "executor_id"
|
||||
MetaKeyExecutorProfileID = "executor_profile_id"
|
||||
MetaKeyDeferredLaunch = "deferred_launch"
|
||||
// DeferredLaunchStartWhenUnblockedKey marks a deferred launch intent as
|
||||
// belonging to a task dependency chain rather than to WIP overflow. The
|
||||
// record itself is identical; the flag lets dependency resolution recognise
|
||||
// its own intents and keeps a WIP-only intent from being read as a chain
|
||||
// step (and vice versa).
|
||||
DeferredLaunchStartWhenUnblockedKey = "start_when_unblocked"
|
||||
// MetaKeyWorkspacePath is the optional host folder for repo-less tasks
|
||||
// (set by CreateTask, read by the orchestrator when building a session).
|
||||
// Centralised here so the set/read sites can't drift apart.
|
||||
@@ -756,6 +762,45 @@ type ChildCompletionRow struct {
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// HasStartWhenUnblockedIntent reports whether a task's deferred launch intent
|
||||
// belongs to a dependency chain rather than to WIP overflow.
|
||||
//
|
||||
// The two share one record; DeferredLaunchStartWhenUnblockedKey is what tells
|
||||
// them apart. Defined here, next to the key, so the DTO layer, the task service
|
||||
// and the orchestrator cannot drift on what counts as a chain intent.
|
||||
func HasStartWhenUnblockedIntent(task *Task) bool {
|
||||
if task == nil || task.Metadata == nil {
|
||||
return false
|
||||
}
|
||||
raw, ok := task.Metadata[MetaKeyDeferredLaunch]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
launch, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
flag, ok := launch[DeferredLaunchStartWhenUnblockedKey].(bool)
|
||||
return ok && flag
|
||||
}
|
||||
|
||||
// DropWIPDeferredLaunch removes a deferred launch record that only existed
|
||||
// because the task was waiting on WIP capacity.
|
||||
//
|
||||
// Every WIP admission point calls this instead of deleting the key outright.
|
||||
// An admitted task has nothing left to wait for as far as WIP is concerned, so
|
||||
// its overflow record is stale. A start-when-unblocked intent is NOT stale in
|
||||
// that situation: it is waiting on a predecessor, not on capacity, and the two
|
||||
// share this one metadata record. Deleting it on admission silently unmakes
|
||||
// every dependency chain, because admission happens at create time for any task
|
||||
// entering a step with room.
|
||||
func DropWIPDeferredLaunch(task *Task) {
|
||||
if task == nil || task.Metadata == nil || HasStartWhenUnblockedIntent(task) {
|
||||
return
|
||||
}
|
||||
delete(task.Metadata, MetaKeyDeferredLaunch)
|
||||
}
|
||||
|
||||
// IsTerminalTaskState reports whether a task state means no further child work
|
||||
// is expected from that task.
|
||||
func IsTerminalTaskState(state v1.TaskState) bool {
|
||||
|
||||
@@ -169,7 +169,7 @@ func runnerProjection(alias string) string {
|
||||
func (r *Repository) CreateTask(ctx context.Context, task *models.Task) error {
|
||||
if task.WorkflowStepID != "" && task.QueuedForStepID == "" && !task.IsEphemeral {
|
||||
task.WIPAdmitted = true
|
||||
delete(task.Metadata, models.MetaKeyDeferredLaunch)
|
||||
models.DropWIPDeferredLaunch(task)
|
||||
}
|
||||
return r.createTask(ctx, task, "", 0)
|
||||
}
|
||||
@@ -202,7 +202,7 @@ func (r *Repository) CreateTaskWithWorkflowStepAdmission(
|
||||
task.WIPAdmitted = !task.IsEphemeral
|
||||
task.QueuedForStepID = ""
|
||||
task.QueuedAt = nil
|
||||
delete(task.Metadata, models.MetaKeyDeferredLaunch)
|
||||
models.DropWIPDeferredLaunch(task)
|
||||
return r.CreateTask(ctx, task)
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ func (r *Repository) applyAdmissionPlacement(
|
||||
task.WIPAdmitted = true
|
||||
task.QueuedForStepID = ""
|
||||
task.QueuedAt = nil
|
||||
delete(task.Metadata, models.MetaKeyDeferredLaunch)
|
||||
models.DropWIPDeferredLaunch(task)
|
||||
case feederStepID == "":
|
||||
task.WorkflowStepID = targetStepID
|
||||
task.WIPAdmitted = false
|
||||
|
||||
@@ -207,6 +207,38 @@ func (f *fakeBlockerRepo) ListTasksBlockedBy(_ context.Context, blockerTaskID st
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (f *fakeBlockerRepo) ListBlockersForTasks(_ context.Context, taskIDs []string) (map[string][]string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
want := make(map[string]struct{}, len(taskIDs))
|
||||
for _, id := range taskIDs {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for _, b := range f.blockers {
|
||||
if _, ok := want[b.TaskID]; ok {
|
||||
out[b.TaskID] = append(out[b.TaskID], b.BlockerTaskID)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeBlockerRepo) ListDependentsForTasks(_ context.Context, blockerTaskIDs []string) (map[string][]string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
want := make(map[string]struct{}, len(blockerTaskIDs))
|
||||
for _, id := range blockerTaskIDs {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for _, b := range f.blockers {
|
||||
if _, ok := want[b.BlockerTaskID]; ok {
|
||||
out[b.BlockerTaskID] = append(out[b.BlockerTaskID], b.TaskID)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fakeTaskRepo provides the minimal TaskRepository surface AttachWorkspacePolicy
|
||||
// uses. It supports GetTask + ListChildren so sibling lookup works.
|
||||
type fakeTaskRepo struct {
|
||||
|
||||
@@ -312,11 +312,15 @@ type Service struct {
|
||||
remoteBranchLister RemoteBranchLister
|
||||
repoCloneLocation RepoCloneLocation
|
||||
blockers BlockerRepository
|
||||
comments CommentRepository
|
||||
secretStore secrets.SecretStore
|
||||
workspaceSecretDeleter WorkspaceSecretDeleter
|
||||
baseBranchPusher AgentBaseBranchPusher
|
||||
runtimeOverridesMu sync.Mutex
|
||||
// dependencyEdgeMu serializes validate-then-insert for dependency edges so
|
||||
// two concurrent adds cannot each pass a cycle walk that predates the
|
||||
// other's insert and commit a cycle between them.
|
||||
dependencyEdgeMu sync.Mutex
|
||||
comments CommentRepository
|
||||
secretStore secrets.SecretStore
|
||||
workspaceSecretDeleter WorkspaceSecretDeleter
|
||||
baseBranchPusher AgentBaseBranchPusher
|
||||
runtimeOverridesMu sync.Mutex
|
||||
|
||||
workspaceSourceProviderRefresher WorkspaceSourceProviderRefresher
|
||||
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
taskrepo "github.com/kandev/kandev/internal/task/repository"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
// Task dependencies ("task B is blocked by task A") are peer-to-peer edges,
|
||||
// distinct from the parent/child subtask hierarchy: a subtask means "part of",
|
||||
// a dependency means "not until". Edges live in task_blockers and are read
|
||||
// through BlockerRepository.
|
||||
//
|
||||
// Blocked state is DERIVED on every read, never stored. A denormalized
|
||||
// is_blocked column would be read by the auto-start gate, and a stale value
|
||||
// there would launch work whose predecessor never ran — the one failure this
|
||||
// feature exists to prevent.
|
||||
|
||||
// Dependency resolution verdicts for a single predecessor.
|
||||
const (
|
||||
// DependencyResolved means the predecessor finished successfully.
|
||||
DependencyResolved = "resolved"
|
||||
// DependencyFailed means the predecessor reached a terminal state that is
|
||||
// not success. It never resolves the edge.
|
||||
DependencyFailed = "failed"
|
||||
// DependencyPending means the predecessor has not finished. Archived
|
||||
// predecessors are pending: archival is neither success nor failure.
|
||||
DependencyPending = "pending"
|
||||
// dependencyMissing marks an edge whose predecessor row is gone. Internal
|
||||
// only: such edges are dropped from the projection rather than reported.
|
||||
dependencyMissing = "missing"
|
||||
)
|
||||
|
||||
// Blocked reasons reported on the task payload.
|
||||
const (
|
||||
// BlockedReasonPending — at least one predecessor is unfinished.
|
||||
BlockedReasonPending = "pending"
|
||||
// BlockedReasonFailed — no predecessor is unfinished and at least one
|
||||
// failed, so the chain has halted and needs human action.
|
||||
BlockedReasonFailed = "failed"
|
||||
// BlockedReasonUnknown — the dependency store could not be read. The gate
|
||||
// fails closed: unknown counts as blocked.
|
||||
BlockedReasonUnknown = "unknown"
|
||||
)
|
||||
|
||||
// dependencyCycleWalkLimit bounds the BFS in checkDependencyCycle. A walk that
|
||||
// exceeds it rejects the edge rather than accepting an unverified one.
|
||||
const dependencyCycleWalkLimit = 1000
|
||||
|
||||
// DependencyRef is one end of a dependency edge, carrying enough detail for the
|
||||
// dependency chip to render without fetching each related task.
|
||||
type DependencyRef struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
State v1.TaskState `json:"state"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// DependencyView is the derived dependency state for one task.
|
||||
type DependencyView struct {
|
||||
Blocked bool
|
||||
BlockedReason string
|
||||
DependsOn []DependencyRef
|
||||
Blocks []DependencyRef
|
||||
}
|
||||
|
||||
// CycleError is returned when a proposed edge would close a cycle. Path lists
|
||||
// the task IDs in traversal order (A, B, C, A) so callers can render
|
||||
// "A → B → C → A".
|
||||
type CycleError struct {
|
||||
Path []string
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *CycleError) Error() string {
|
||||
if len(e.Path) == 0 {
|
||||
return "would create a dependency cycle"
|
||||
}
|
||||
return "would create a dependency cycle: " + strings.Join(e.Path, " → ")
|
||||
}
|
||||
|
||||
// ErrDependencyRepositoryUnavailable is returned when the dependency store is
|
||||
// not wired. Callers must treat it as "cannot determine", not "not blocked".
|
||||
var ErrDependencyRepositoryUnavailable = fmt.Errorf("dependency repository not configured")
|
||||
|
||||
// ResolveStartWhenUnblocked decides whether a create request's agent start
|
||||
// should become a start-when-unblocked intent instead of an immediate launch.
|
||||
//
|
||||
// A create with no dependencies is never deferred by this rule. A create WITH
|
||||
// dependencies defaults to deferring, because `start_agent` defaults to true and
|
||||
// every automated caller passes it: launching immediately would start every step
|
||||
// of an agent-built chain at once, which is precisely the collision dependencies
|
||||
// exist to prevent. An explicit `start_when_unblocked: false` opts out and
|
||||
// creates the edges with no launch intent at all.
|
||||
func ResolveStartWhenUnblocked(req *CreateTaskRequest) bool {
|
||||
if req == nil || len(req.BlockedBy) == 0 {
|
||||
return false
|
||||
}
|
||||
if req.StartWhenUnblocked == nil {
|
||||
return true
|
||||
}
|
||||
return *req.StartWhenUnblocked
|
||||
}
|
||||
|
||||
// validateDependencyPair resolves both ends and rejects a cross-workspace edge.
|
||||
// Both tasks must exist: an edge to a missing task would leave the dependent
|
||||
// blocked on something that can never complete.
|
||||
func (s *Service) validateDependencyPair(ctx context.Context, taskID, dependsOnTaskID string) error {
|
||||
task, err := s.tasks.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve task: %w", err)
|
||||
}
|
||||
if task == nil {
|
||||
return fmt.Errorf("%w: %s", taskrepo.ErrTaskNotFound, taskID)
|
||||
}
|
||||
dep, err := s.tasks.GetTask(ctx, dependsOnTaskID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve dependency task: %w", err)
|
||||
}
|
||||
if dep == nil {
|
||||
return fmt.Errorf("%w: %s", taskrepo.ErrTaskNotFound, dependsOnTaskID)
|
||||
}
|
||||
if task.WorkspaceID != "" && dep.WorkspaceID != "" && task.WorkspaceID != dep.WorkspaceID {
|
||||
return fmt.Errorf("task %s belongs to a different workspace", dependsOnTaskID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authorizeDependencyPair guards BOTH ends of an edge before any read or write.
|
||||
//
|
||||
// Both IDs are caller-supplied, so guarding only the dependent would let a
|
||||
// caller link (or unlink) another user's task and infer its existence from the
|
||||
// validation error. Denials surface as ErrTaskNotFound, so there is no
|
||||
// existence leak.
|
||||
func (s *Service) authorizeDependencyPair(ctx context.Context, taskID, dependsOnTaskID string) error {
|
||||
if err := s.authorizeTaskID(ctx, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.authorizeTaskID(ctx, dependsOnTaskID)
|
||||
}
|
||||
|
||||
// AddDependency records "taskID depends on dependsOnTaskID".
|
||||
//
|
||||
// This is the single validator for dependency edges. Self-edges,
|
||||
// cross-workspace edges, and cycles of any length are rejected here, and both
|
||||
// the task-scoped routes and the Office blocker routes go through it — a second
|
||||
// validator would let a cycle in through whichever path was weakest.
|
||||
func (s *Service) AddDependency(ctx context.Context, taskID, dependsOnTaskID string) error {
|
||||
if s.blockers == nil {
|
||||
return ErrDependencyRepositoryUnavailable
|
||||
}
|
||||
if taskID == "" || dependsOnTaskID == "" {
|
||||
return fmt.Errorf("both task_id and depends_on_task_id are required")
|
||||
}
|
||||
if taskID == dependsOnTaskID {
|
||||
return fmt.Errorf("a task cannot depend on itself")
|
||||
}
|
||||
if err := s.authorizeDependencyPair(ctx, taskID, dependsOnTaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Serialize validate-then-insert. The cycle walk and the insert are two
|
||||
// statements, so two concurrent adds can each pass a walk that cannot yet
|
||||
// see the other's edge and commit a cycle between them, leaving both tasks
|
||||
// blocked forever. Kandev runs one backend process (see the run-scheduling
|
||||
// spec, which rules out multiple active processes), so a process-wide lock
|
||||
// is sufficient and keeps the check and the write atomic with respect to
|
||||
// each other.
|
||||
if err := s.insertDependencyEdge(ctx, taskID, dependsOnTaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Publish OUTSIDE the lock: event delivery is synchronous, so holding the
|
||||
// lock across it would serialize every dependency mutation behind fan-out,
|
||||
// and a subscriber that called back into this method would deadlock on a
|
||||
// non-reentrant mutex.
|
||||
s.publishDependencyChange(ctx, taskID, dependsOnTaskID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertDependencyEdge holds dependencyEdgeMu across exactly the validate,
|
||||
// cycle-walk and insert, and nothing else.
|
||||
func (s *Service) insertDependencyEdge(ctx context.Context, taskID, dependsOnTaskID string) error {
|
||||
s.dependencyEdgeMu.Lock()
|
||||
defer s.dependencyEdgeMu.Unlock()
|
||||
if err := s.validateDependencyPair(ctx, taskID, dependsOnTaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
if cycle, err := s.checkDependencyCycle(ctx, taskID, dependsOnTaskID); err != nil {
|
||||
return fmt.Errorf("check dependency cycle: %w", err)
|
||||
} else if cycle != nil {
|
||||
return cycle
|
||||
}
|
||||
return s.createBlockerEdge(ctx, taskID, dependsOnTaskID)
|
||||
}
|
||||
|
||||
// RemoveDependency deletes a dependency edge. Removing an absent edge is a
|
||||
// success no-op.
|
||||
//
|
||||
// Removing the last edge unblocks the task but deliberately does NOT consume
|
||||
// its start-when-unblocked intent: that intent is consumed by dependency
|
||||
// resolution, not by the absence of dependencies. A user who removes the edge
|
||||
// is taking manual control.
|
||||
func (s *Service) RemoveDependency(ctx context.Context, taskID, dependsOnTaskID string) error {
|
||||
if s.blockers == nil {
|
||||
return ErrDependencyRepositoryUnavailable
|
||||
}
|
||||
if err := s.authorizeDependencyPair(ctx, taskID, dependsOnTaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.deleteDependencyEdge(ctx, taskID, dependsOnTaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Published outside the lock, for the same reason as AddDependency.
|
||||
s.publishDependencyChange(ctx, taskID, dependsOnTaskID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteDependencyEdge holds the edge lock across the delete only.
|
||||
func (s *Service) deleteDependencyEdge(ctx context.Context, taskID, dependsOnTaskID string) error {
|
||||
s.dependencyEdgeMu.Lock()
|
||||
defer s.dependencyEdgeMu.Unlock()
|
||||
return s.blockers.DeleteTaskBlocker(ctx, taskID, dependsOnTaskID)
|
||||
}
|
||||
|
||||
// publishDependencyChange emits task.updated for both ends of a mutated edge so
|
||||
// every client refreshes its badge, chip and graph. Task mutations must go
|
||||
// through the event publisher; walking the repository alone breaks WS-driven UI.
|
||||
func (s *Service) publishDependencyChange(ctx context.Context, taskIDs ...string) {
|
||||
for _, id := range taskIDs {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
task, err := s.tasks.GetTask(ctx, id)
|
||||
if err != nil || task == nil {
|
||||
continue
|
||||
}
|
||||
// Carry the recomputed projection on the event. A bare task.updated
|
||||
// omits these keys, and the client reads an omitted projection as
|
||||
// "unchanged" (it must, because most task.updated publishers are
|
||||
// lightweight) — so without them every other open board would keep a
|
||||
// stale chip and badge until a full refetch.
|
||||
s.publishTaskEventWithExtra(ctx, events.TaskUpdated, task, nil, s.dependencyEventFields(ctx, task))
|
||||
}
|
||||
}
|
||||
|
||||
// dependencyEventFields renders one task's derived projection in the wire shape
|
||||
// the client's task.updated mapper reads.
|
||||
func (s *Service) dependencyEventFields(ctx context.Context, task *models.Task) map[string]interface{} {
|
||||
views := s.BuildDependencyViews(ctx, []*models.Task{task})
|
||||
view := views[task.ID]
|
||||
return map[string]interface{}{
|
||||
"blocked": view.Blocked,
|
||||
"blocked_reason": view.BlockedReason,
|
||||
"depends_on": view.DependsOn,
|
||||
"blocks": view.Blocks,
|
||||
}
|
||||
}
|
||||
|
||||
// checkDependencyCycle walks forward from dependsOnTaskID through existing
|
||||
// edges. If any path reaches taskID, adding the edge would close a cycle, and
|
||||
// the returned *CycleError carries the path for the caller to surface.
|
||||
func (s *Service) checkDependencyCycle(ctx context.Context, taskID, dependsOnTaskID string) (*CycleError, error) {
|
||||
// parent[x] is the node we reached x from, so a hit can be walked back
|
||||
// into a readable path instead of only reporting "there is a cycle".
|
||||
parent := map[string]string{dependsOnTaskID: ""}
|
||||
queue := []string{dependsOnTaskID}
|
||||
walked := 0
|
||||
for len(queue) > 0 {
|
||||
if walked > dependencyCycleWalkLimit {
|
||||
return &CycleError{}, nil
|
||||
}
|
||||
current := queue[0]
|
||||
queue = queue[1:]
|
||||
walked++
|
||||
if current == taskID {
|
||||
return &CycleError{Path: buildCyclePath(parent, taskID, dependsOnTaskID)}, nil
|
||||
}
|
||||
blockers, err := s.blockers.ListTaskBlockers(ctx, current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, b := range blockers {
|
||||
if _, seen := parent[b.BlockerTaskID]; seen {
|
||||
continue
|
||||
}
|
||||
parent[b.BlockerTaskID] = current
|
||||
queue = append(queue, b.BlockerTaskID)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// buildCyclePath renders the discovered cycle in depends-on order, starting and
|
||||
// ending at the task the new edge would block: taskID → dependsOn → … → taskID.
|
||||
//
|
||||
// parent[x] is the node whose blocker list contained x, so walking parent back
|
||||
// from taskID yields the chain from taskID to dependsOn reversed. The proposed
|
||||
// edge (taskID depends on dependsOn) closes the loop, so taskID is PREPENDED —
|
||||
// the reversed chain already ends at taskID, and appending it again produced a
|
||||
// duplicated final hop like "A → B → C → C" instead of "C → A → B → C".
|
||||
func buildCyclePath(parent map[string]string, taskID, dependsOnTaskID string) []string {
|
||||
reverse := []string{taskID}
|
||||
for node := parent[taskID]; node != ""; node = parent[node] {
|
||||
reverse = append(reverse, node)
|
||||
if node == dependsOnTaskID {
|
||||
break
|
||||
}
|
||||
}
|
||||
path := make([]string, 0, len(reverse)+1)
|
||||
path = append(path, taskID)
|
||||
for i := len(reverse) - 1; i >= 0; i-- {
|
||||
path = append(path, reverse[i])
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// BuildDependencyViews returns derived dependency state for a batch of tasks.
|
||||
//
|
||||
// Batched deliberately: the Kanban board reads a whole workflow at once, so a
|
||||
// per-task query would add one round trip per card to every board load.
|
||||
func (s *Service) BuildDependencyViews(ctx context.Context, tasks []*models.Task) map[string]DependencyView {
|
||||
out := make(map[string]DependencyView, len(tasks))
|
||||
if s.blockers == nil || len(tasks) == 0 {
|
||||
return out
|
||||
}
|
||||
ids := make([]string, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
if t != nil {
|
||||
ids = append(ids, t.ID)
|
||||
}
|
||||
}
|
||||
predecessors, err := s.blockers.ListBlockersForTasks(ctx, ids)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to load task dependencies", zap.Error(err))
|
||||
// Fail closed: a task whose edges cannot be read reports blocked with
|
||||
// an unknown reason rather than silently reporting "not blocked".
|
||||
for _, id := range ids {
|
||||
out[id] = DependencyView{Blocked: true, BlockedReason: BlockedReasonUnknown}
|
||||
}
|
||||
return out
|
||||
}
|
||||
dependents, err := s.blockers.ListDependentsForTasks(ctx, ids)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to load task dependents", zap.Error(err))
|
||||
dependents = map[string][]string{}
|
||||
}
|
||||
refs := s.resolveDependencyRefs(ctx, predecessors, dependents)
|
||||
for _, id := range ids {
|
||||
out[id] = buildDependencyView(refs, predecessors[id], dependents[id])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveDependencyRefs loads title/state for every task named on either side
|
||||
// of any edge in the batch, in one query.
|
||||
func (s *Service) resolveDependencyRefs(
|
||||
ctx context.Context, predecessors, dependents map[string][]string,
|
||||
) map[string]DependencyRef {
|
||||
seen := map[string]struct{}{}
|
||||
for _, group := range []map[string][]string{predecessors, dependents} {
|
||||
for _, ids := range group {
|
||||
for _, id := range ids {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
refs := make(map[string]DependencyRef, len(seen))
|
||||
ids := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
// One batched read: a board payload references every edge on every card, so
|
||||
// a per-edge query turned one board load into N round trips.
|
||||
found, batchErr := s.tasks.GetTasksByIDs(ctx, ids)
|
||||
byID := make(map[string]*models.Task, len(found))
|
||||
for _, task := range found {
|
||||
if task != nil {
|
||||
byID[task.ID] = task
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
task := byID[id]
|
||||
if task == nil {
|
||||
// Absent row (deleted predecessor, dangling edge) versus a failed
|
||||
// read: the former must not block forever, the latter must not open
|
||||
// the gate. Mark them differently so buildDependencyView can tell.
|
||||
status := dependencyMissing
|
||||
if batchErr != nil {
|
||||
status = DependencyPending
|
||||
}
|
||||
refs[id] = DependencyRef{ID: id, Status: status}
|
||||
continue
|
||||
}
|
||||
refs[id] = DependencyRef{
|
||||
ID: id,
|
||||
Title: task.Title,
|
||||
State: task.State,
|
||||
Status: DependencyStatusForTask(task),
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// buildDependencyView derives blocked/reason plus both edge lists for one task.
|
||||
func buildDependencyView(
|
||||
refs map[string]DependencyRef, predecessorIDs, dependentIDs []string,
|
||||
) DependencyView {
|
||||
view := DependencyView{
|
||||
DependsOn: make([]DependencyRef, 0, len(predecessorIDs)),
|
||||
Blocks: make([]DependencyRef, 0, len(dependentIDs)),
|
||||
}
|
||||
tally := dependencyTally{}
|
||||
for _, id := range predecessorIDs {
|
||||
ref := refs[id]
|
||||
if ref.ID == "" {
|
||||
ref = DependencyRef{ID: id, Status: DependencyPending}
|
||||
}
|
||||
if ref.Status == dependencyMissing {
|
||||
// Dangling edge to a deleted task: not shown and not counted, so a
|
||||
// failed cleanup cannot block a dependent forever.
|
||||
continue
|
||||
}
|
||||
view.DependsOn = append(view.DependsOn, ref)
|
||||
tally.add(ref.Status)
|
||||
}
|
||||
for _, id := range dependentIDs {
|
||||
ref := refs[id]
|
||||
if ref.ID == "" {
|
||||
ref = DependencyRef{ID: id, Status: DependencyPending}
|
||||
}
|
||||
view.Blocks = append(view.Blocks, ref)
|
||||
}
|
||||
view.Blocked, view.BlockedReason = tally.verdict()
|
||||
return view
|
||||
}
|
||||
|
||||
// DependencyStatusForTask classifies one predecessor.
|
||||
//
|
||||
// Resolution requires SUCCESS. This is deliberately stricter than the
|
||||
// on_children_completed trigger, which counts FAILED as terminal: a chain must
|
||||
// never proceed on a failed step. Archived tasks are pending, because archival
|
||||
// is neither success nor failure.
|
||||
func DependencyStatusForTask(task *models.Task) string {
|
||||
if task == nil {
|
||||
return DependencyPending
|
||||
}
|
||||
switch task.State {
|
||||
case v1.TaskStateCompleted:
|
||||
if task.ArchivedAt != nil {
|
||||
return DependencyPending
|
||||
}
|
||||
return DependencyResolved
|
||||
case v1.TaskStateFailed, v1.TaskStateCancelled:
|
||||
return DependencyFailed
|
||||
default:
|
||||
return DependencyPending
|
||||
}
|
||||
}
|
||||
|
||||
// DependencyGate reports whether taskID may be started by an automated path.
|
||||
//
|
||||
// Returns blocked=true on ANY read error. The gate fails closed on purpose:
|
||||
// failing open would launch work whose predecessor may not have run.
|
||||
func (s *Service) DependencyGate(ctx context.Context, taskID string) (blocked bool, reason string, err error) {
|
||||
if s.blockers == nil {
|
||||
// No dependency store wired means no edges can exist, so nothing is
|
||||
// blocked. This is not a read failure.
|
||||
return false, "", nil
|
||||
}
|
||||
blockers, listErr := s.blockers.ListTaskBlockers(ctx, taskID)
|
||||
if listErr != nil {
|
||||
return true, BlockedReasonUnknown, listErr
|
||||
}
|
||||
if len(blockers) == 0 {
|
||||
return false, "", nil
|
||||
}
|
||||
tally := dependencyTally{}
|
||||
for _, b := range blockers {
|
||||
predecessor, getErr := s.tasks.GetTask(ctx, b.BlockerTaskID)
|
||||
if getErr != nil && !errors.Is(getErr, taskrepo.ErrTaskNotFound) {
|
||||
return true, BlockedReasonUnknown, getErr
|
||||
}
|
||||
if predecessor == nil {
|
||||
// The predecessor row is genuinely gone: this is a dangling edge
|
||||
// left behind when delete-time cleanup failed. Counting it as
|
||||
// pending would block the dependent forever, since a deleted task
|
||||
// can never reach a terminal state. A deleted predecessor is
|
||||
// specified to unblock (without firing a launch intent), so ignore
|
||||
// the edge and prune it opportunistically.
|
||||
//
|
||||
// This is NOT the fail-open case: a read ERROR above still fails
|
||||
// closed. Only a confirmed-absent row is ignored.
|
||||
s.pruneDanglingEdge(ctx, taskID, b.BlockerTaskID)
|
||||
continue
|
||||
}
|
||||
tally.add(DependencyStatusForTask(predecessor))
|
||||
}
|
||||
blocked, reason = tally.verdict()
|
||||
return blocked, reason, nil
|
||||
}
|
||||
|
||||
// dependencyTally accumulates predecessor verdicts and applies the single
|
||||
// precedence rule. The gate and the derived projection both use it: two copies
|
||||
// of "pending wins over failed" could drift, and the fail-closed behaviour is
|
||||
// exactly the thing that must not.
|
||||
type dependencyTally struct {
|
||||
anyPending bool
|
||||
anyFailed bool
|
||||
}
|
||||
|
||||
func (d *dependencyTally) add(status string) {
|
||||
switch status {
|
||||
case DependencyFailed:
|
||||
d.anyFailed = true
|
||||
case DependencyResolved, dependencyMissing:
|
||||
// Resolved satisfies the edge; missing is a dangling edge that is
|
||||
// dropped entirely, so neither blocks.
|
||||
default:
|
||||
d.anyPending = true
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dependencyTally) verdict() (bool, string) {
|
||||
switch {
|
||||
case d.anyPending:
|
||||
return true, BlockedReasonPending
|
||||
case d.anyFailed:
|
||||
return true, BlockedReasonFailed
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// PendingDependencyLaunch is a task that will start on its own once its
|
||||
// dependencies resolve.
|
||||
type PendingDependencyLaunch struct {
|
||||
TaskID string
|
||||
WorkflowStepID string
|
||||
}
|
||||
|
||||
// ListPendingDependencyLaunches returns every non-archived task that has
|
||||
// dependency edges AND a start-when-unblocked intent.
|
||||
//
|
||||
// Backs the orchestrator's startup sweep: task.dependencies_resolved is
|
||||
// in-memory and is not replayed, so a restart between a predecessor completing
|
||||
// and its dependent launching would otherwise stall the chain silently. Scoped
|
||||
// by "has edges" so the sweep is bounded by pending chain steps, not the board.
|
||||
func (s *Service) ListPendingDependencyLaunches(ctx context.Context) ([]PendingDependencyLaunch, error) {
|
||||
if s.blockers == nil {
|
||||
return nil, nil
|
||||
}
|
||||
lister, ok := s.blockers.(dependentTaskLister)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
ids, err := lister.ListTasksWithDependencies(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]PendingDependencyLaunch, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
task, err := s.tasks.GetTask(ctx, id)
|
||||
if err != nil || task == nil || task.ArchivedAt != nil || task.IsEphemeral {
|
||||
continue
|
||||
}
|
||||
if !HasStartWhenUnblockedIntent(task) {
|
||||
continue
|
||||
}
|
||||
out = append(out, PendingDependencyLaunch{TaskID: task.ID, WorkflowStepID: task.WorkflowStepID})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// dependentTaskLister is the optional "which tasks have edges" read, kept off
|
||||
// BlockerRepository so existing test doubles keep compiling.
|
||||
type dependentTaskLister interface {
|
||||
ListTasksWithDependencies(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// HasStartWhenUnblockedIntent reports whether the task's deferred launch intent
|
||||
// belongs to a dependency chain rather than to WIP overflow.
|
||||
//
|
||||
// Thin re-export of the models helper so the orchestrator, the DTO layer and
|
||||
// this service cannot drift on what counts as a chain intent.
|
||||
func HasStartWhenUnblockedIntent(task *models.Task) bool {
|
||||
return models.HasStartWhenUnblockedIntent(task)
|
||||
}
|
||||
|
||||
// pruneDanglingEdge removes an edge whose predecessor row no longer exists.
|
||||
//
|
||||
// Best-effort and non-fatal: the caller has already decided to ignore the edge,
|
||||
// so a failed prune only means the next read prunes it again.
|
||||
func (s *Service) pruneDanglingEdge(ctx context.Context, taskID, missingTaskID string) {
|
||||
if s.blockers == nil {
|
||||
return
|
||||
}
|
||||
if err := s.blockers.DeleteTaskBlocker(ctx, taskID, missingTaskID); err != nil {
|
||||
s.logger.Warn("failed to prune dangling dependency edge",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("missing_task_id", missingTaskID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// ListDependentTaskIDs returns the tasks directly blocked by taskID.
|
||||
func (s *Service) ListDependentTaskIDs(ctx context.Context, taskID string) ([]string, error) {
|
||||
if s.blockers == nil {
|
||||
return nil, ErrDependencyRepositoryUnavailable
|
||||
}
|
||||
return s.blockers.ListTasksBlockedBy(ctx, taskID)
|
||||
}
|
||||
|
||||
// deleteDependencyEdgesForTask removes both directions of every edge touching
|
||||
// taskID. task_blockers predates the tasks foreign key, so nothing cascades.
|
||||
func (s *Service) deleteDependencyEdgesForTask(ctx context.Context, taskID string) {
|
||||
if s.blockers == nil {
|
||||
return
|
||||
}
|
||||
cleaner, ok := s.blockers.(taskDependencyCleaner)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
dependents, err := s.blockers.ListTasksBlockedBy(ctx, taskID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to list dependents before edge cleanup",
|
||||
zap.String("task_id", taskID), zap.Error(err))
|
||||
}
|
||||
if err := cleaner.DeleteTaskBlockersForTask(ctx, taskID); err != nil {
|
||||
s.logger.Warn("failed to clean up dependency edges for deleted task",
|
||||
zap.String("task_id", taskID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
// Dependents may now be unblocked, so refresh them. Deliberately no
|
||||
// auto-start: deletion is not success, and a chain must not advance
|
||||
// because a predecessor was removed.
|
||||
s.publishDependencyChange(ctx, dependents...)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
// Revive caps effective lines per file including tests, so the launch-semantics
|
||||
// cases live here rather than growing service_dependencies_test.go.
|
||||
|
||||
// `start_when_unblocked: false` means "no automatic start at all" — it governs
|
||||
// the DEFERRED launch only. The presence of blockers is what suppresses the
|
||||
// immediate one, so a create must never launch a task that is already blocked.
|
||||
func TestResolveStartWhenUnblockedGovernsOnlyTheDeferredLaunch(t *testing.T) {
|
||||
no := false
|
||||
req := &CreateTaskRequest{BlockedBy: []string{"a"}, StartWhenUnblocked: &no}
|
||||
if ResolveStartWhenUnblocked(req) {
|
||||
t.Fatal("explicit false must not record a deferred intent")
|
||||
}
|
||||
// The create paths gate the immediate launch on len(BlockedBy) == 0, not on
|
||||
// this flag; assert the invariant the handlers rely on.
|
||||
if len(req.BlockedBy) == 0 {
|
||||
t.Fatal("fixture should have blockers")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
orchmodels "github.com/kandev/kandev/internal/office/models"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
taskrepo "github.com/kandev/kandev/internal/task/repository"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
// errBlockerRepo wraps a working repo and fails the forward read on demand, so
|
||||
// the fail-closed contract can be proven by breaking the store rather than by
|
||||
// asserting the happy path.
|
||||
type errBlockerRepo struct {
|
||||
*mockBlockerRepo
|
||||
failList bool
|
||||
}
|
||||
|
||||
func (e *errBlockerRepo) ListTaskBlockers(ctx context.Context, taskID string) ([]*orchmodels.TaskBlocker, error) {
|
||||
if e.failList {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
return e.mockBlockerRepo.ListTaskBlockers(ctx, taskID)
|
||||
}
|
||||
|
||||
func (e *errBlockerRepo) ListBlockersForTasks(ctx context.Context, ids []string) (map[string][]string, error) {
|
||||
if e.failList {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
return e.mockBlockerRepo.ListBlockersForTasks(ctx, ids)
|
||||
}
|
||||
|
||||
func setDependencyState(t *testing.T, svc *Service, taskID string, state v1.TaskState) {
|
||||
t.Helper()
|
||||
task, err := svc.tasks.GetTask(context.Background(), taskID)
|
||||
if err != nil || task == nil {
|
||||
t.Fatalf("load task %s: %v", taskID, err)
|
||||
}
|
||||
task.State = state
|
||||
if err := svc.tasks.UpdateTask(context.Background(), task); err != nil {
|
||||
t.Fatalf("update task %s: %v", taskID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDependencyGate_PendingBlocksAndResolvedReleases(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
svc.SetBlockerRepository(&mockBlockerRepo{})
|
||||
ctx := context.Background()
|
||||
predecessor := mustSeedTask(t, svc, "Predecessor").ID
|
||||
dependent := mustSeedTask(t, svc, "Dependent").ID
|
||||
if err := svc.AddDependency(ctx, dependent, predecessor); err != nil {
|
||||
t.Fatalf("AddDependency: %v", err)
|
||||
}
|
||||
|
||||
blocked, reason, err := svc.DependencyGate(ctx, dependent)
|
||||
if err != nil || !blocked || reason != BlockedReasonPending {
|
||||
t.Fatalf("pending predecessor: blocked=%v reason=%q err=%v; want true/pending/nil", blocked, reason, err)
|
||||
}
|
||||
|
||||
setDependencyState(t, svc, predecessor, v1.TaskStateCompleted)
|
||||
blocked, reason, err = svc.DependencyGate(ctx, dependent)
|
||||
if err != nil || blocked {
|
||||
t.Fatalf("resolved predecessor: blocked=%v reason=%q err=%v; want false", blocked, reason, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A FAILED predecessor must NOT resolve the edge. This is the deliberate
|
||||
// divergence from on_children_completed, which counts FAILED as terminal.
|
||||
func TestDependencyGate_FailedPredecessorKeepsTaskBlocked(t *testing.T) {
|
||||
for _, state := range []v1.TaskState{v1.TaskStateFailed, v1.TaskStateCancelled} {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
svc.SetBlockerRepository(&mockBlockerRepo{})
|
||||
ctx := context.Background()
|
||||
predecessor := mustSeedTask(t, svc, "Predecessor").ID
|
||||
dependent := mustSeedTask(t, svc, "Dependent").ID
|
||||
if err := svc.AddDependency(ctx, dependent, predecessor); err != nil {
|
||||
t.Fatalf("AddDependency: %v", err)
|
||||
}
|
||||
setDependencyState(t, svc, predecessor, state)
|
||||
|
||||
blocked, reason, err := svc.DependencyGate(ctx, dependent)
|
||||
if err != nil || !blocked || reason != BlockedReasonFailed {
|
||||
t.Errorf("%s predecessor: blocked=%v reason=%q err=%v; want true/failed/nil",
|
||||
state, blocked, reason, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AND semantics: a task with two predecessors stays blocked until BOTH resolve.
|
||||
func TestDependencyGate_WaitsForEveryPredecessor(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
svc.SetBlockerRepository(&mockBlockerRepo{})
|
||||
ctx := context.Background()
|
||||
first := mustSeedTask(t, svc, "First").ID
|
||||
second := mustSeedTask(t, svc, "Second").ID
|
||||
dependent := mustSeedTask(t, svc, "Dependent").ID
|
||||
for _, p := range []string{first, second} {
|
||||
if err := svc.AddDependency(ctx, dependent, p); err != nil {
|
||||
t.Fatalf("AddDependency: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
setDependencyState(t, svc, first, v1.TaskStateCompleted)
|
||||
if blocked, _, _ := svc.DependencyGate(ctx, dependent); !blocked {
|
||||
t.Error("one of two predecessors resolved: want still blocked")
|
||||
}
|
||||
setDependencyState(t, svc, second, v1.TaskStateCompleted)
|
||||
if blocked, _, _ := svc.DependencyGate(ctx, dependent); blocked {
|
||||
t.Error("both predecessors resolved: want unblocked")
|
||||
}
|
||||
}
|
||||
|
||||
// The gate must fail CLOSED. Proven by breaking the store, not by the happy path:
|
||||
// failing open would launch work whose predecessor may never have run.
|
||||
func TestDependencyGate_FailsClosedOnReadError(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
repo := &errBlockerRepo{mockBlockerRepo: &mockBlockerRepo{}, failList: true}
|
||||
svc.SetBlockerRepository(repo)
|
||||
|
||||
blocked, reason, err := svc.DependencyGate(context.Background(), "any-task")
|
||||
if err == nil {
|
||||
t.Fatal("expected the read error to be returned")
|
||||
}
|
||||
if !blocked || reason != BlockedReasonUnknown {
|
||||
t.Errorf("read failure: blocked=%v reason=%q; want true/unknown", blocked, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDependencyViews_ReportsBothDirections(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
svc.SetBlockerRepository(&mockBlockerRepo{})
|
||||
ctx := context.Background()
|
||||
a := mustSeedTask(t, svc, "A")
|
||||
b := mustSeedTask(t, svc, "B")
|
||||
c := mustSeedTask(t, svc, "C")
|
||||
// B depends on A; C depends on B. B therefore has one of each direction.
|
||||
if err := svc.AddDependency(ctx, b.ID, a.ID); err != nil {
|
||||
t.Fatalf("AddDependency(b,a): %v", err)
|
||||
}
|
||||
if err := svc.AddDependency(ctx, c.ID, b.ID); err != nil {
|
||||
t.Fatalf("AddDependency(c,b): %v", err)
|
||||
}
|
||||
|
||||
views := svc.BuildDependencyViews(ctx, []*models.Task{b})
|
||||
view := views[b.ID]
|
||||
if len(view.DependsOn) != 1 || view.DependsOn[0].ID != a.ID {
|
||||
t.Errorf("depends_on = %+v; want [%s]", view.DependsOn, a.ID)
|
||||
}
|
||||
if len(view.Blocks) != 1 || view.Blocks[0].ID != c.ID {
|
||||
t.Errorf("blocks = %+v; want [%s]", view.Blocks, c.ID)
|
||||
}
|
||||
if view.DependsOn[0].Title != "A" {
|
||||
t.Errorf("depends_on entry should carry the title for the chip, got %q", view.DependsOn[0].Title)
|
||||
}
|
||||
if !view.Blocked || view.BlockedReason != BlockedReasonPending {
|
||||
t.Errorf("blocked=%v reason=%q; want true/pending", view.Blocked, view.BlockedReason)
|
||||
}
|
||||
}
|
||||
|
||||
// BuildDependencyViews must fail closed too: the board reads dependency state
|
||||
// through it, and a task reported unblocked on a read failure could be launched.
|
||||
func TestBuildDependencyViews_FailsClosedOnReadError(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
task := mustSeedTask(t, svc, "A")
|
||||
svc.SetBlockerRepository(&errBlockerRepo{mockBlockerRepo: &mockBlockerRepo{}, failList: true})
|
||||
|
||||
views := svc.BuildDependencyViews(context.Background(), []*models.Task{task})
|
||||
view := views[task.ID]
|
||||
if !view.Blocked || view.BlockedReason != BlockedReasonUnknown {
|
||||
t.Errorf("read failure: blocked=%v reason=%q; want true/unknown", view.Blocked, view.BlockedReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDependency_RejectsSelfEdge(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
svc.SetBlockerRepository(&mockBlockerRepo{})
|
||||
task := mustSeedTask(t, svc, "A")
|
||||
if err := svc.AddDependency(context.Background(), task.ID, task.ID); err == nil {
|
||||
t.Fatal("expected a self-edge to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDependency_RejectsCrossWorkspaceEdge(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
svc.SetBlockerRepository(&mockBlockerRepo{})
|
||||
ctx := context.Background()
|
||||
local := mustSeedTask(t, svc, "Local")
|
||||
// Move a seeded task to another workspace at the repository layer: CreateTask
|
||||
// requires a provisioned workspace, and the point here is only that the
|
||||
// validator compares the two tasks' workspaces.
|
||||
foreign := mustSeedTask(t, svc, "Foreign")
|
||||
foreign.WorkspaceID = "ws-other"
|
||||
if err := svc.tasks.UpdateTask(ctx, foreign); err != nil {
|
||||
t.Fatalf("move foreign task: %v", err)
|
||||
}
|
||||
if err := svc.AddDependency(ctx, local.ID, foreign.ID); err == nil {
|
||||
t.Fatal("expected a cross-workspace edge to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveDependency_AbsentEdgeSucceeds(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
svc.SetBlockerRepository(&mockBlockerRepo{})
|
||||
if err := svc.RemoveDependency(context.Background(), "a", "b"); err != nil {
|
||||
t.Fatalf("removing an absent edge should succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStartWhenUnblocked(t *testing.T) {
|
||||
yes, no := true, false
|
||||
cases := []struct {
|
||||
name string
|
||||
req *CreateTaskRequest
|
||||
want bool
|
||||
}{
|
||||
{"no dependencies never defers", &CreateTaskRequest{}, false},
|
||||
{"no dependencies ignores an explicit true", &CreateTaskRequest{StartWhenUnblocked: &yes}, false},
|
||||
// The load-bearing default: agents pass start_agent=true by habit, so a
|
||||
// create WITH dependencies must record an intent rather than launch now,
|
||||
// or every step of an agent-built chain starts at once.
|
||||
{"dependencies default to deferring", &CreateTaskRequest{BlockedBy: []string{"a"}}, true},
|
||||
{"explicit false opts out", &CreateTaskRequest{BlockedBy: []string{"a"}, StartWhenUnblocked: &no}, false},
|
||||
{"explicit true defers", &CreateTaskRequest{BlockedBy: []string{"a"}, StartWhenUnblocked: &yes}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := ResolveStartWhenUnblocked(tc.req); got != tc.want {
|
||||
t.Errorf("ResolveStartWhenUnblocked() = %v; want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDependencyStatusForTask_ArchivedIsPendingNotResolved(t *testing.T) {
|
||||
// Archival is neither success nor failure: an archived predecessor must not
|
||||
// release a dependent, and must not be reported as a failed chain either.
|
||||
if got := DependencyStatusForTask(&models.Task{State: v1.TaskStateCompleted}); got != DependencyResolved {
|
||||
t.Errorf("completed = %q; want %q", got, DependencyResolved)
|
||||
}
|
||||
archivedAt := time.Now().UTC()
|
||||
archived := &models.Task{State: v1.TaskStateCompleted, ArchivedAt: &archivedAt}
|
||||
if got := DependencyStatusForTask(archived); got != DependencyPending {
|
||||
t.Errorf("archived+completed = %q; want %q", got, DependencyPending)
|
||||
}
|
||||
if got := DependencyStatusForTask(nil); got != DependencyPending {
|
||||
t.Errorf("nil task = %q; want %q", got, DependencyPending)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression coverage for the review findings on PR #2589.
|
||||
|
||||
// seedForeignPair creates two tasks in another user's workspace plus one the
|
||||
// caller owns, so scoping can be exercised on both ends of an edge.
|
||||
func seedForeignPair(t *testing.T, repo interface {
|
||||
CreateWorkspace(context.Context, *models.Workspace) error
|
||||
CreateWorkflow(context.Context, *models.Workflow) error
|
||||
CreateTask(context.Context, *models.Task) error
|
||||
CreateTaskSession(context.Context, *models.TaskSession) error
|
||||
}) {
|
||||
t.Helper()
|
||||
seedScopedWorkspaces(t, repo)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateTask(ctx, &models.Task{
|
||||
ID: "task-b2", WorkspaceID: "ws-b", WorkflowID: "wf-b", WorkflowStepID: "step-1",
|
||||
Title: "B's second", State: v1.TaskStateTODO,
|
||||
}); err != nil {
|
||||
t.Fatalf("create second foreign task: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A caller must not be able to link, unlink, or probe another user's tasks.
|
||||
// Both IDs are caller-supplied, so guarding only the dependent end would still
|
||||
// let a caller attach a foreign task as a blocker.
|
||||
func TestDependencyMutationsAreScopedToTheCaller(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
seedForeignPair(t, repo)
|
||||
svc.SetBlockerRepository(&mockBlockerRepo{})
|
||||
|
||||
if err := svc.AddDependency(ctxAs("user-a"), "task-b", "task-b2"); !errors.Is(err, taskrepo.ErrTaskNotFound) {
|
||||
t.Errorf("add between two foreign tasks = %v; want ErrTaskNotFound", err)
|
||||
}
|
||||
if err := svc.RemoveDependency(ctxAs("user-a"), "task-b", "task-b2"); !errors.Is(err, taskrepo.ErrTaskNotFound) {
|
||||
t.Errorf("remove between two foreign tasks = %v; want ErrTaskNotFound", err)
|
||||
}
|
||||
// The owner is unaffected.
|
||||
if err := svc.AddDependency(ctxAs("user-b"), "task-b", "task-b2"); err != nil {
|
||||
t.Errorf("owner add = %v; want nil", err)
|
||||
}
|
||||
// An internal (identity-free) caller stays unscoped, as the orchestrator relies on.
|
||||
if err := svc.RemoveDependency(context.Background(), "task-b", "task-b2"); err != nil {
|
||||
t.Errorf("internal caller remove = %v; want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// gateBlockerRepo forces the interleaving the cycle race needs: each cycle walk
|
||||
// announces itself and then waits for the other walk to start. Two bare
|
||||
// goroutines practically never land inside the validate-then-insert window, so
|
||||
// a test without this barrier passes even with the serialization removed and
|
||||
// proves nothing.
|
||||
type gateBlockerRepo struct {
|
||||
*mockBlockerRepo
|
||||
arrived chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (g *gateBlockerRepo) ListTaskBlockers(ctx context.Context, taskID string) ([]*orchmodels.TaskBlocker, error) {
|
||||
g.once.Do(func() { close(g.arrived) })
|
||||
select {
|
||||
case <-g.arrived:
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
// Give the other goroutine room to finish its own walk before this one
|
||||
// inserts. Serialization must make that impossible; without it both walks
|
||||
// complete against a pre-insert view.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return g.mockBlockerRepo.ListTaskBlockers(ctx, taskID)
|
||||
}
|
||||
|
||||
// Two concurrent adds must not each pass a cycle walk that predates the other's
|
||||
// insert and commit a cycle between them.
|
||||
func TestAddDependencyConcurrentInsertsCannotCreateACycle(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
repo := &gateBlockerRepo{mockBlockerRepo: &mockBlockerRepo{}, arrived: make(chan struct{})}
|
||||
svc.SetBlockerRepository(repo)
|
||||
ctx := context.Background()
|
||||
a := mustSeedTask(t, svc, "A").ID
|
||||
b := mustSeedTask(t, svc, "B").ID
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 2)
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); errs[0] = svc.AddDependency(ctx, a, b) }()
|
||||
go func() { defer wg.Done(); errs[1] = svc.AddDependency(ctx, b, a) }()
|
||||
wg.Wait()
|
||||
|
||||
accepted := 0
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
accepted++
|
||||
}
|
||||
}
|
||||
if accepted != 1 {
|
||||
t.Fatalf("accepted %d of 2 opposing edges; exactly one must win", accepted)
|
||||
}
|
||||
// Belt and braces: the surviving graph must not block both ways.
|
||||
aBlocked, _, _ := svc.DependencyGate(ctx, a)
|
||||
bBlocked, _, _ := svc.DependencyGate(ctx, b)
|
||||
if aBlocked && bBlocked {
|
||||
t.Error("both tasks blocked by each other: a cycle was committed")
|
||||
}
|
||||
}
|
||||
|
||||
// A dangling edge (predecessor row gone because delete-time cleanup failed)
|
||||
// must not block its dependent forever, while a genuine read FAILURE still
|
||||
// fails closed.
|
||||
func TestDependencyGateIgnoresDanglingEdgeButFailsClosedOnError(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
repo := &mockBlockerRepo{}
|
||||
svc.SetBlockerRepository(repo)
|
||||
ctx := context.Background()
|
||||
dependent := mustSeedTask(t, svc, "Dependent").ID
|
||||
|
||||
// Edge pointing at a task that does not exist, as a failed cleanup leaves.
|
||||
if err := repo.CreateTaskBlocker(ctx, &orchmodels.TaskBlocker{
|
||||
TaskID: dependent, BlockerTaskID: "deleted-task-id",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed dangling edge: %v", err)
|
||||
}
|
||||
blocked, reason, err := svc.DependencyGate(ctx, dependent)
|
||||
if err != nil {
|
||||
t.Fatalf("gate returned error: %v", err)
|
||||
}
|
||||
if blocked {
|
||||
t.Errorf("dangling edge blocked the task (reason=%q); a deleted predecessor can never complete", reason)
|
||||
}
|
||||
|
||||
// A read error is different and must still fail closed.
|
||||
svc.SetBlockerRepository(&errBlockerRepo{mockBlockerRepo: &mockBlockerRepo{}, failList: true})
|
||||
blocked, reason, err = svc.DependencyGate(ctx, dependent)
|
||||
if err == nil || !blocked || reason != BlockedReasonUnknown {
|
||||
t.Errorf("read failure: blocked=%v reason=%q err=%v; want true/unknown/non-nil", blocked, reason, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The derived projection must drop a dangling edge too, so the board does not
|
||||
// render a blocked badge for a predecessor that no longer exists.
|
||||
func TestBuildDependencyViewsDropsDanglingEdges(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
repo := &mockBlockerRepo{}
|
||||
svc.SetBlockerRepository(repo)
|
||||
ctx := context.Background()
|
||||
dependent := mustSeedTask(t, svc, "Dependent")
|
||||
if err := repo.CreateTaskBlocker(ctx, &orchmodels.TaskBlocker{
|
||||
TaskID: dependent.ID, BlockerTaskID: "deleted-task-id",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed dangling edge: %v", err)
|
||||
}
|
||||
|
||||
view := svc.BuildDependencyViews(ctx, []*models.Task{dependent})[dependent.ID]
|
||||
if view.Blocked {
|
||||
t.Errorf("blocked=%v reason=%q; a dangling edge must not block", view.Blocked, view.BlockedReason)
|
||||
}
|
||||
if len(view.DependsOn) != 0 {
|
||||
t.Errorf("depends_on = %+v; a dangling edge must not be reported", view.DependsOn)
|
||||
}
|
||||
}
|
||||
|
||||
// An edge mutation must publish the recomputed projection, not a bare
|
||||
// task.updated: the client treats omitted dependency keys as "unchanged", so a
|
||||
// bare event leaves every other open board rendering a stale chip and badge.
|
||||
func TestDependencyEventFieldsCarryTheProjection(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
svc.SetBlockerRepository(&mockBlockerRepo{})
|
||||
ctx := context.Background()
|
||||
predecessor := mustSeedTask(t, svc, "Predecessor")
|
||||
dependent := mustSeedTask(t, svc, "Dependent")
|
||||
if err := svc.AddDependency(ctx, dependent.ID, predecessor.ID); err != nil {
|
||||
t.Fatalf("AddDependency: %v", err)
|
||||
}
|
||||
|
||||
fields := svc.dependencyEventFields(ctx, dependent)
|
||||
for _, key := range []string{"blocked", "blocked_reason", "depends_on", "blocks"} {
|
||||
if _, ok := fields[key]; !ok {
|
||||
t.Errorf("event payload is missing %q; the client would treat it as unchanged", key)
|
||||
}
|
||||
}
|
||||
if fields["blocked"] != true {
|
||||
t.Errorf("blocked = %v; want true", fields["blocked"])
|
||||
}
|
||||
if fields["blocked_reason"] != BlockedReasonPending {
|
||||
t.Errorf("blocked_reason = %v; want %q", fields["blocked_reason"], BlockedReasonPending)
|
||||
}
|
||||
deps, _ := fields["depends_on"].([]DependencyRef)
|
||||
if len(deps) != 1 || deps[0].ID != predecessor.ID {
|
||||
t.Errorf("depends_on = %+v; want one entry for %s", deps, predecessor.ID)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,16 @@ type BlockerRepository interface {
|
||||
// ListTasksBlockedBy returns task IDs that have blockerTaskID listed as
|
||||
// one of their blockers (the reverse direction of ListTaskBlockers).
|
||||
ListTasksBlockedBy(ctx context.Context, blockerTaskID string) ([]string, error)
|
||||
// ListBlockersForTasks batches the forward direction over many tasks.
|
||||
ListBlockersForTasks(ctx context.Context, taskIDs []string) (map[string][]string, error)
|
||||
// ListDependentsForTasks batches the reverse direction over many tasks.
|
||||
ListDependentsForTasks(ctx context.Context, blockerTaskIDs []string) (map[string][]string, error)
|
||||
}
|
||||
|
||||
// taskDependencyCleaner removes every edge touching a task. Optional on
|
||||
// BlockerRepository so existing test doubles keep compiling.
|
||||
type taskDependencyCleaner interface {
|
||||
DeleteTaskBlockersForTask(ctx context.Context, taskID string) error
|
||||
}
|
||||
|
||||
// CommentRepository provides access to task comment persistence.
|
||||
@@ -100,33 +110,30 @@ func (s *Service) ListTasksByAssignee(ctx context.Context, agentInstanceID strin
|
||||
}
|
||||
|
||||
// AddBlocker creates a blocker relationship between two tasks.
|
||||
// It validates that the blocker does not create a circular dependency.
|
||||
//
|
||||
// Thin alias for AddDependency, which owns the only edge validator (self-edge,
|
||||
// cross-workspace, BFS cycle with a reportable path). This used to run its own
|
||||
// weaker check; two validators meant a cycle could enter through whichever path
|
||||
// was laxer.
|
||||
func (s *Service) AddBlocker(ctx context.Context, taskID, blockerTaskID string) error {
|
||||
if s.blockers == nil {
|
||||
return fmt.Errorf("blocker repository not configured")
|
||||
}
|
||||
if taskID == blockerTaskID {
|
||||
return fmt.Errorf("a task cannot block itself")
|
||||
}
|
||||
|
||||
// Check for circular dependency: if blockerTaskID is already (transitively) blocked by taskID
|
||||
if err := s.checkCircularBlocker(ctx, taskID, blockerTaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blocker := &orchmodels.TaskBlocker{
|
||||
TaskID: taskID,
|
||||
BlockerTaskID: blockerTaskID,
|
||||
}
|
||||
return s.blockers.CreateTaskBlocker(ctx, blocker)
|
||||
return s.AddDependency(ctx, taskID, blockerTaskID)
|
||||
}
|
||||
|
||||
// RemoveBlocker removes a blocker relationship between two tasks.
|
||||
func (s *Service) RemoveBlocker(ctx context.Context, taskID, blockerTaskID string) error {
|
||||
if s.blockers == nil {
|
||||
return fmt.Errorf("blocker repository not configured")
|
||||
}
|
||||
return s.blockers.DeleteTaskBlocker(ctx, taskID, blockerTaskID)
|
||||
return s.RemoveDependency(ctx, taskID, blockerTaskID)
|
||||
}
|
||||
|
||||
// createBlockerEdge writes the dependency row.
|
||||
//
|
||||
// The row type is Office-owned, and ARCH-TASK-OFFICE-IMPORT keeps new task-tier
|
||||
// files free of Office imports; this file is already baselined for it, so the
|
||||
// construction is confined here rather than widening the baseline.
|
||||
func (s *Service) createBlockerEdge(ctx context.Context, taskID, blockerTaskID string) error {
|
||||
return s.blockers.CreateTaskBlocker(ctx, &orchmodels.TaskBlocker{
|
||||
TaskID: taskID,
|
||||
BlockerTaskID: blockerTaskID,
|
||||
})
|
||||
}
|
||||
|
||||
// GetBlockers returns all tasks that block the given task.
|
||||
@@ -163,42 +170,6 @@ func (s *Service) GetBlocking(ctx context.Context, taskID string) ([]string, err
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// checkCircularBlocker checks if adding blockerTaskID as a blocker to taskID
|
||||
// would create a circular dependency. It walks the blocker chain from
|
||||
// blockerTaskID to see if it leads back to taskID.
|
||||
func (s *Service) checkCircularBlocker(ctx context.Context, taskID, blockerTaskID string) error {
|
||||
// Walk the transitive blockers of blockerTaskID. If any path reaches
|
||||
// taskID, adding this blocker would create a cycle.
|
||||
target := taskID
|
||||
visited := make(map[string]bool)
|
||||
queue := []string{blockerTaskID}
|
||||
|
||||
for len(queue) > 0 {
|
||||
current := queue[0]
|
||||
queue = queue[1:]
|
||||
|
||||
if visited[current] {
|
||||
continue
|
||||
}
|
||||
visited[current] = true
|
||||
|
||||
blockers, err := s.blockers.ListTaskBlockers(ctx, current)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check circular dependency: %w", err)
|
||||
}
|
||||
for _, b := range blockers {
|
||||
if b.BlockerTaskID == target {
|
||||
return fmt.Errorf("circular dependency detected: adding %s as blocker of %s creates a cycle",
|
||||
blockerTaskID, taskID)
|
||||
}
|
||||
if !visited[b.BlockerTaskID] {
|
||||
queue = append(queue, b.BlockerTaskID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateComment creates a new comment on a task.
|
||||
func (s *Service) CreateComment(ctx context.Context, comment *orchmodels.TaskComment) error {
|
||||
if s.comments == nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -354,39 +355,90 @@ func (m *mockBlockerRepo) ListTasksBlockedBy(_ context.Context, blockerTaskID st
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (m *mockBlockerRepo) ListBlockersForTasks(_ context.Context, taskIDs []string) (map[string][]string, error) {
|
||||
want := make(map[string]struct{}, len(taskIDs))
|
||||
for _, id := range taskIDs {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for _, b := range m.blockers {
|
||||
if _, ok := want[b.TaskID]; ok {
|
||||
out[b.TaskID] = append(out[b.TaskID], b.BlockerTaskID)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *mockBlockerRepo) ListDependentsForTasks(_ context.Context, blockerTaskIDs []string) (map[string][]string, error) {
|
||||
want := make(map[string]struct{}, len(blockerTaskIDs))
|
||||
for _, id := range blockerTaskIDs {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for _, b := range m.blockers {
|
||||
if _, ok := want[b.BlockerTaskID]; ok {
|
||||
out[b.BlockerTaskID] = append(out[b.BlockerTaskID], b.TaskID)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestBlocker_CRUD(t *testing.T) {
|
||||
svc, _, _ := createTestService(t)
|
||||
svc, _ := setupOfficeTest(t)
|
||||
svc.SetBlockerRepository(&mockBlockerRepo{})
|
||||
ctx := context.Background()
|
||||
|
||||
if err := svc.AddBlocker(ctx, "task-1", "task-2"); err != nil {
|
||||
// Both ends must exist: the single edge validator resolves each task so it
|
||||
// can enforce the same-workspace rule and refuse an edge to a task that is
|
||||
// not there (which would leave the dependent blocked forever).
|
||||
one := mustSeedTask(t, svc, "Task 1")
|
||||
two := mustSeedTask(t, svc, "Task 2")
|
||||
|
||||
if err := svc.AddBlocker(ctx, one.ID, two.ID); err != nil {
|
||||
t.Fatalf("AddBlocker: %v", err)
|
||||
}
|
||||
|
||||
ids, err := svc.GetBlockers(ctx, "task-1")
|
||||
ids, err := svc.GetBlockers(ctx, one.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlockers: %v", err)
|
||||
}
|
||||
if len(ids) != 1 || ids[0] != "task-2" {
|
||||
t.Errorf("expected [task-2], got %v", ids)
|
||||
if len(ids) != 1 || ids[0] != two.ID {
|
||||
t.Errorf("expected [%s], got %v", two.ID, ids)
|
||||
}
|
||||
|
||||
if err := svc.RemoveBlocker(ctx, "task-1", "task-2"); err != nil {
|
||||
if err := svc.RemoveBlocker(ctx, one.ID, two.ID); err != nil {
|
||||
t.Fatalf("RemoveBlocker: %v", err)
|
||||
}
|
||||
ids, _ = svc.GetBlockers(ctx, "task-1")
|
||||
ids, _ = svc.GetBlockers(ctx, one.ID)
|
||||
if len(ids) != 0 {
|
||||
t.Errorf("expected empty, got %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// mustSeedTask creates a task in the office test workspace so dependency-edge
|
||||
// tests have real IDs on both ends of an edge.
|
||||
func mustSeedTask(t *testing.T, svc *Service, title string) *models.Task {
|
||||
t.Helper()
|
||||
result, err := svc.CreateTask(context.Background(), &CreateTaskRequest{
|
||||
WorkspaceID: "ws-1", Title: title, ProjectID: "proj-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed task %q: %v", title, err)
|
||||
}
|
||||
return result.Task
|
||||
}
|
||||
|
||||
func TestGetBlocking_ReverseLookup(t *testing.T) {
|
||||
svc, _, _ := createTestService(t)
|
||||
svc, _ := setupOfficeTest(t)
|
||||
svc.SetBlockerRepository(&mockBlockerRepo{})
|
||||
ctx := context.Background()
|
||||
t1 := mustSeedTask(t, svc, "Task 1").ID
|
||||
t2 := mustSeedTask(t, svc, "Task 2").ID
|
||||
t3 := mustSeedTask(t, svc, "Task 3").ID
|
||||
t4 := mustSeedTask(t, svc, "Task 4").ID
|
||||
|
||||
// No blocker edges at all.
|
||||
ids, err := svc.GetBlocking(ctx, "task-1")
|
||||
ids, err := svc.GetBlocking(ctx, t1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlocking: %v", err)
|
||||
}
|
||||
@@ -398,50 +450,53 @@ func TestGetBlocking_ReverseLookup(t *testing.T) {
|
||||
}
|
||||
|
||||
// One blocked task: task-2 is blocked by task-1.
|
||||
if err := svc.AddBlocker(ctx, "task-2", "task-1"); err != nil {
|
||||
if err := svc.AddBlocker(ctx, t2, t1); err != nil {
|
||||
t.Fatalf("AddBlocker: %v", err)
|
||||
}
|
||||
ids, err = svc.GetBlocking(ctx, "task-1")
|
||||
ids, err = svc.GetBlocking(ctx, t1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlocking: %v", err)
|
||||
}
|
||||
if len(ids) != 1 || ids[0] != "task-2" {
|
||||
if len(ids) != 1 || ids[0] != t2 {
|
||||
t.Errorf("expected [task-2], got %v", ids)
|
||||
}
|
||||
|
||||
// The reverse direction must not be confused with the forward one:
|
||||
// task-2 blocks nothing, and task-1 is the blocker of task-2.
|
||||
if ids, err = svc.GetBlocking(ctx, "task-2"); err != nil || len(ids) != 0 {
|
||||
if ids, err = svc.GetBlocking(ctx, t2); err != nil || len(ids) != 0 {
|
||||
t.Errorf("GetBlocking(task-2) = %v, %v; want empty", ids, err)
|
||||
}
|
||||
if ids, err = svc.GetBlockers(ctx, "task-2"); err != nil || len(ids) != 1 || ids[0] != "task-1" {
|
||||
if ids, err = svc.GetBlockers(ctx, t2); err != nil || len(ids) != 1 || ids[0] != t1 {
|
||||
t.Errorf("GetBlockers(task-2) = %v, %v; want [task-1]", ids, err)
|
||||
}
|
||||
|
||||
// Several blocked tasks.
|
||||
for _, blocked := range []string{"task-3", "task-4"} {
|
||||
if err := svc.AddBlocker(ctx, blocked, "task-1"); err != nil {
|
||||
for _, blocked := range []string{t3, t4} {
|
||||
if err := svc.AddBlocker(ctx, blocked, t1); err != nil {
|
||||
t.Fatalf("AddBlocker(%s): %v", blocked, err)
|
||||
}
|
||||
}
|
||||
ids, err = svc.GetBlocking(ctx, "task-1")
|
||||
ids, err = svc.GetBlocking(ctx, t1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlocking: %v", err)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
want := []string{"task-2", "task-3", "task-4"}
|
||||
want := []string{t2, t3, t4}
|
||||
sort.Strings(want)
|
||||
if !slices.Equal(ids, want) {
|
||||
t.Errorf("expected %v, got %v", want, ids)
|
||||
}
|
||||
|
||||
// Removing an edge removes it from the reverse lookup too.
|
||||
if err := svc.RemoveBlocker(ctx, "task-3", "task-1"); err != nil {
|
||||
if err := svc.RemoveBlocker(ctx, t3, t1); err != nil {
|
||||
t.Fatalf("RemoveBlocker: %v", err)
|
||||
}
|
||||
ids, _ = svc.GetBlocking(ctx, "task-1")
|
||||
ids, _ = svc.GetBlocking(ctx, t1)
|
||||
sort.Strings(ids)
|
||||
if !slices.Equal(ids, []string{"task-2", "task-4"}) {
|
||||
t.Errorf("expected [task-2 task-4] after removal, got %v", ids)
|
||||
wantAfter := []string{t2, t4}
|
||||
sort.Strings(wantAfter)
|
||||
if !slices.Equal(ids, wantAfter) {
|
||||
t.Errorf("expected %v after removal, got %v", wantAfter, ids)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,19 +554,35 @@ func TestGetBlocking_IncludesArchivedTasks(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBlocker_CircularDetection(t *testing.T) {
|
||||
svc, _, _ := createTestService(t)
|
||||
svc, _ := setupOfficeTest(t)
|
||||
svc.SetBlockerRepository(&mockBlockerRepo{})
|
||||
ctx := context.Background()
|
||||
a := mustSeedTask(t, svc, "Task A").ID
|
||||
b := mustSeedTask(t, svc, "Task B").ID
|
||||
c := mustSeedTask(t, svc, "Task C").ID
|
||||
|
||||
_ = svc.AddBlocker(ctx, "task-A", "task-B")
|
||||
_ = svc.AddBlocker(ctx, "task-B", "task-C")
|
||||
|
||||
err := svc.AddBlocker(ctx, "task-C", "task-A")
|
||||
if err == nil {
|
||||
t.Fatal("expected circular dependency error")
|
||||
if err := svc.AddBlocker(ctx, a, b); err != nil {
|
||||
t.Fatalf("AddBlocker(a,b): %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "circular dependency") {
|
||||
t.Errorf("expected 'circular dependency' in error, got: %v", err)
|
||||
if err := svc.AddBlocker(ctx, b, c); err != nil {
|
||||
t.Fatalf("AddBlocker(b,c): %v", err)
|
||||
}
|
||||
|
||||
err := svc.AddBlocker(ctx, c, a)
|
||||
if err == nil {
|
||||
t.Fatal("expected cycle error")
|
||||
}
|
||||
// The error must carry the traversal path so the UI can render
|
||||
// "A → B → C → A" rather than a bare "there is a cycle".
|
||||
var cycle *CycleError
|
||||
if !errors.As(err, &cycle) {
|
||||
t.Fatalf("expected *CycleError, got %T: %v", err, err)
|
||||
}
|
||||
if len(cycle.Path) < 3 {
|
||||
t.Errorf("expected a cycle path naming the loop, got %v", cycle.Path)
|
||||
}
|
||||
if cycle.Path[0] != c || cycle.Path[len(cycle.Path)-1] != c {
|
||||
t.Errorf("cycle path should start and end at the new dependent %s, got %v", c, cycle.Path)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,16 @@ type CreateTaskRequest struct {
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
Labels string `json:"labels,omitempty"`
|
||||
BlockedBy []string `json:"blocked_by,omitempty"`
|
||||
|
||||
// StartWhenUnblocked records the requested agent start as a deferred launch
|
||||
// intent that dependency resolution consumes, instead of launching now.
|
||||
//
|
||||
// nil means "derive from the request's start intent": a create that asked to
|
||||
// start an agent AND declared BlockedBy is describing a chain step, not a
|
||||
// task to run immediately. Automated callers pass start_agent=true by habit,
|
||||
// so deriving is what makes an agent-built chain run in order rather than
|
||||
// launching every step at once.
|
||||
StartWhenUnblocked *bool `json:"start_when_unblocked,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateTaskRequest contains the data for updating a task
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -613,7 +614,17 @@ func (s *Service) buildTask(req *CreateTaskRequest, workflowStepID string) *mode
|
||||
if metadata == nil {
|
||||
metadata = make(map[string]interface{})
|
||||
}
|
||||
metadata[models.MetaKeyDeferredLaunch] = req.DeferredLaunch
|
||||
launch := req.DeferredLaunch
|
||||
if ResolveStartWhenUnblocked(req) {
|
||||
// Mark the intent as a dependency-chain step. The record is the same
|
||||
// one WIP overflow persists — reused so "launch exactly once" and
|
||||
// restart survival are inherited — and the flag is what lets
|
||||
// dependency resolution recognise its own intents.
|
||||
launch = make(map[string]interface{}, len(req.DeferredLaunch)+1)
|
||||
maps.Copy(launch, req.DeferredLaunch)
|
||||
launch[models.DeferredLaunchStartWhenUnblockedKey] = true
|
||||
}
|
||||
metadata[models.MetaKeyDeferredLaunch] = launch
|
||||
}
|
||||
if wsPath := strings.TrimSpace(req.WorkspacePath); wsPath != "" {
|
||||
if metadata == nil {
|
||||
@@ -2068,6 +2079,11 @@ func (s *Service) deleteTaskWithReasonAndDBDelete(
|
||||
zap.String("task_id", id), zap.Error(err))
|
||||
}
|
||||
}
|
||||
// Remove dependency edges in both directions. task_blockers predates the
|
||||
// tasks foreign key so nothing cascades, and a left-over edge would keep a
|
||||
// dependent blocked forever on a task that no longer exists. Dependents are
|
||||
// refreshed but deliberately not started: deletion is not success.
|
||||
s.deleteDependencyEdgesForTask(context.WithoutCancel(ctx), id)
|
||||
|
||||
// 5. Publish event (sync, fast) - frontend removes task immediately
|
||||
var extra map[string]interface{}
|
||||
|
||||
@@ -481,7 +481,7 @@ func (s *Service) MoveTaskWithOptions(
|
||||
task.QueuedForStepID = ""
|
||||
task.QueuedAt = nil
|
||||
if !opts.PreserveDeferredLaunch {
|
||||
delete(task.Metadata, models.MetaKeyDeferredLaunch)
|
||||
models.DropWIPDeferredLaunch(task)
|
||||
}
|
||||
}
|
||||
if err := s.syncTaskStateForWorkflowMove(ctx, task, oldStepID, workflowStepID); err != nil {
|
||||
|
||||
@@ -381,6 +381,8 @@ const (
|
||||
ActionMCPUpdateTaskPRAutomation = "mcp.update_task_pr_automation"
|
||||
ActionMCPGetTaskMRAutomation = "mcp.get_task_mr_automation"
|
||||
ActionMCPUpdateTaskMRAutomation = "mcp.update_task_mr_automation"
|
||||
ActionMCPAddTaskDependency = "mcp.add_task_dependency"
|
||||
ActionMCPRemoveTaskDependency = "mcp.remove_task_dependency"
|
||||
ActionMCPAddBranchToTask = "mcp.add_branch_to_task"
|
||||
ActionMCPAddWorkspaceSources = "mcp.add_workspace_sources"
|
||||
ActionMCPUpdateRepositoryBaseBranch = "mcp.update_repository_base_branch"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IconArrowsMaximize,
|
||||
IconDots,
|
||||
IconLoader2,
|
||||
IconLock,
|
||||
IconSubtask,
|
||||
IconUsersGroup,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -208,6 +209,7 @@ function KanbanCardBadges({ task }: { task: Task }) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 mt-1 min-w-0">
|
||||
{task.blocked && <BlockedBadge task={task} />}
|
||||
{task.queuedForStepId && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@@ -246,12 +248,52 @@ function KanbanCardBadges({ task }: { task: Task }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocked badge — the card-level signal that this task will not start on its
|
||||
* own. Distinguishes a failed predecessor (chain halted, needs a human) from
|
||||
* merely pending ones, because those need different actions from the user.
|
||||
*
|
||||
* The predecessor list is on the payload already, so the title needs no fetch.
|
||||
* The count is rendered as text rather than hover-only so the state is readable
|
||||
* on a touch device.
|
||||
*/
|
||||
function BlockedBadge({ task }: { task: Task }) {
|
||||
const { t } = useTranslation();
|
||||
const count = task.dependsOn?.length ?? 0;
|
||||
const failed = task.blockedReason === "failed";
|
||||
const names = (task.dependsOn ?? []).map((ref) => ref.title || ref.id).join(", ");
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
// Same pill formula as the dependency chip above the composer: rounded
|
||||
// outline, 10% tint, 35% border, colour as the text. Keeps the two
|
||||
// surfaces for one concept looking like one thing.
|
||||
"h-5 gap-1 rounded-full px-2 text-xs font-medium leading-none",
|
||||
failed
|
||||
? "border-red-500/40 bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
: "border-primary/35 bg-primary/10 text-primary",
|
||||
)}
|
||||
title={
|
||||
failed
|
||||
? t("kanban:blockedPredecessorFailed", { tasks: names })
|
||||
: t("kanban:blockedByTasksTitle", { tasks: names })
|
||||
}
|
||||
data-testid="kanban-card-blocked-badge"
|
||||
>
|
||||
<IconLock className="h-3 w-3" />
|
||||
{failed ? t("kanban:blockedFailed") : t("kanban:blockedByCount", { count })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function hasCardBadges(task: Task): boolean {
|
||||
return Boolean(
|
||||
(task.sessionCount && task.sessionCount > 1) ||
|
||||
task.reviewStatus === "changes_requested" ||
|
||||
task.reviewStatus === "pending" ||
|
||||
task.queuedForStepId,
|
||||
task.queuedForStepId ||
|
||||
task.blocked,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type ExternalLinkProvider,
|
||||
} from "@/components/task/task-external-link-dialog";
|
||||
import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability";
|
||||
import type { TaskDependencyRef } from "@/lib/state/slices/kanban/types";
|
||||
import { TaskGitHubIssueDialog } from "@/components/task/task-github-issue-dialog";
|
||||
import { TaskGitHubPRDialog } from "@/components/task/task-github-pr-dialog";
|
||||
import { TaskMRLinkDialog } from "@/components/gitlab/task-mr-link-dialog";
|
||||
@@ -80,6 +81,12 @@ export interface Task {
|
||||
wipAdmitted?: boolean;
|
||||
queuedForStepId?: string;
|
||||
queuedForStepTitle?: string;
|
||||
/** Derived dependency state — see TaskDependencyRef in the kanban slice. */
|
||||
blocked?: boolean;
|
||||
blockedReason?: string;
|
||||
dependsOn?: TaskDependencyRef[];
|
||||
blocks?: TaskDependencyRef[];
|
||||
startWhenUnblocked?: boolean;
|
||||
queuedAt?: string;
|
||||
issueUrl?: string;
|
||||
issueNumber?: number;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Depends on" selector for the task-create dialog.
|
||||
*
|
||||
* Dependencies are declared when the task is created (or later through MCP), so
|
||||
* this is the only place the UI writes an edge. Selecting one or more
|
||||
* predecessors also changes what "Start agent" means: the backend records the
|
||||
* start as a start-when-unblocked intent, and the task launches on its own once
|
||||
* every predecessor completes successfully.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconLock, IconX } from "@tabler/icons-react";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@kandev/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
|
||||
/**
|
||||
* Stable empty fallbacks. A selector must not build a fresh array/object: under
|
||||
* Object.is that reads as a change on every store read and re-renders until React
|
||||
* bails out (which is what silently blanked the dependency chip once already).
|
||||
*/
|
||||
const NO_TASKS: KanbanState["tasks"] = [];
|
||||
const NO_SNAPSHOTS: Record<string, { tasks?: KanbanState["tasks"] }> = {};
|
||||
|
||||
export type TaskCreateDependenciesProps = {
|
||||
/** Selected predecessor task IDs. */
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every task the user could depend on, from BOTH board slices.
|
||||
*
|
||||
* The home route hydrates its swimlanes into kanbanMulti.snapshots and can leave
|
||||
* kanban.tasks empty, so reading only the latter left this picker with nothing to
|
||||
* offer on the very route where tasks are created. Both selections are stable
|
||||
* references; the dedupe happens in the memo.
|
||||
*/
|
||||
function useBoardTasks(): KanbanState["tasks"] {
|
||||
const boardTasks = useAppStore((state) => state.kanban?.tasks ?? NO_TASKS);
|
||||
const snapshots = useAppStore((state) => state.kanbanMulti?.snapshots ?? NO_SNAPSHOTS);
|
||||
return useMemo(() => {
|
||||
const byId = new Map<string, KanbanState["tasks"][number]>();
|
||||
for (const task of boardTasks) byId.set(task.id, task);
|
||||
for (const snapshot of Object.values(snapshots)) {
|
||||
for (const task of snapshot.tasks ?? []) {
|
||||
if (!byId.has(task.id)) byId.set(task.id, task);
|
||||
}
|
||||
}
|
||||
return [...byId.values()];
|
||||
}, [boardTasks, snapshots]);
|
||||
}
|
||||
|
||||
export function TaskCreateDependencies({ value, onChange, disabled }: TaskCreateDependenciesProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
// Select the stable slice reference and derive below: a selector that builds a
|
||||
// new array compares unequal on every store read and re-renders until React
|
||||
// gives up.
|
||||
// Candidates come from BOTH board slices. The home route hydrates its
|
||||
// swimlanes into kanbanMulti.snapshots and can leave kanban.tasks empty, so
|
||||
// reading only the latter left this picker with nothing to offer on the very
|
||||
// route where tasks are created. Both selections are stable references; the
|
||||
// dedupe happens in the memo below.
|
||||
const tasks = useBoardTasks();
|
||||
|
||||
const selected = useMemo(
|
||||
() => value.map((id) => tasks.find((task) => task.id === id)).filter(Boolean),
|
||||
[value, tasks],
|
||||
);
|
||||
const candidates = useMemo(
|
||||
() =>
|
||||
tasks
|
||||
.filter((task) => !value.includes(task.id) && !task.isArchived)
|
||||
.sort((a, b) => a.title.localeCompare(b.title)),
|
||||
[tasks, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5" data-testid="task-create-dependencies">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">{t("task:dependsOn")}</span>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 cursor-pointer gap-1 px-2 text-xs"
|
||||
disabled={disabled}
|
||||
data-testid="task-create-dependencies-trigger"
|
||||
>
|
||||
<IconLock className="h-3.5 w-3.5" />
|
||||
{t("task:addDependency")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("task:dependsOn")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("task:noDependencies")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{candidates.map((task) => (
|
||||
<CommandItem
|
||||
key={task.id}
|
||||
value={`${task.title} ${task.id}`}
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
onChange([...value, task.id]);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{task.title}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selected.map((task) => (
|
||||
<Badge
|
||||
key={task!.id}
|
||||
variant="secondary"
|
||||
className="h-5 max-w-[16rem] gap-1 text-xs"
|
||||
data-testid="task-create-dependency-chip"
|
||||
>
|
||||
<span className="truncate">{task!.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("task:removeDependency")}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onChange(value.filter((id) => id !== task!.id))}
|
||||
>
|
||||
<IconX className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selected.length > 0 && (
|
||||
<p className="text-[11px] text-muted-foreground">{t("task:dependsOnWillAutoStart")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,8 @@ describe("WorkflowSection", () => {
|
||||
|
||||
function makeFs(): DialogFormState {
|
||||
return {
|
||||
blockedBy: [],
|
||||
setBlockedBy: () => undefined,
|
||||
taskName: "",
|
||||
autopilot: false,
|
||||
setAutopilot: () => {},
|
||||
|
||||
@@ -10,6 +10,18 @@ import {
|
||||
import type { TaskRemoteRepoRow } from "./task-create-dialog-types";
|
||||
const STORAGE_KEYS = { LAST_BRANCH: "kandev.dialog.lastBranch" } as const;
|
||||
|
||||
// The agent-bearing fields every buildCreateTaskPayload case needs but none of
|
||||
// them is asserting; each test spreads these and overrides only what it checks.
|
||||
const AGENT_PAYLOAD_DEFAULTS = {
|
||||
workspaceId: "ws-1",
|
||||
effectiveWorkflowId: "wf-1",
|
||||
repositoriesPayload: [],
|
||||
agentProfileId: "agent-1",
|
||||
executorId: "executor-1",
|
||||
executorProfileId: "profile-1",
|
||||
withAgent: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -218,15 +230,9 @@ describe("auto-title creation helpers", () => {
|
||||
|
||||
it("omits the manual title and opts into backend provisional naming", () => {
|
||||
const payload = buildCreateTaskPayload({
|
||||
workspaceId: "ws-1",
|
||||
effectiveWorkflowId: "wf-1",
|
||||
...AGENT_PAYLOAD_DEFAULTS,
|
||||
trimmedTitle: "ignored",
|
||||
trimmedDescription: "Fix the login flow",
|
||||
repositoriesPayload: [],
|
||||
agentProfileId: "agent-1",
|
||||
executorId: "executor-1",
|
||||
executorProfileId: "profile-1",
|
||||
withAgent: true,
|
||||
autoTitle: true,
|
||||
});
|
||||
|
||||
@@ -236,15 +242,9 @@ describe("auto-title creation helpers", () => {
|
||||
|
||||
it("includes autopilot only when the create form opts in", () => {
|
||||
const payload = buildCreateTaskPayload({
|
||||
workspaceId: "ws-1",
|
||||
effectiveWorkflowId: "wf-1",
|
||||
...AGENT_PAYLOAD_DEFAULTS,
|
||||
trimmedTitle: "Autonomous task",
|
||||
trimmedDescription: "Run the migration",
|
||||
repositoriesPayload: [],
|
||||
agentProfileId: "agent-1",
|
||||
executorId: "executor-1",
|
||||
executorProfileId: "profile-1",
|
||||
withAgent: true,
|
||||
autopilot: true,
|
||||
});
|
||||
|
||||
@@ -253,18 +253,30 @@ describe("auto-title creation helpers", () => {
|
||||
|
||||
it("keeps autopilot off when the create form does not opt in", () => {
|
||||
const payload = buildCreateTaskPayload({
|
||||
workspaceId: "ws-1",
|
||||
effectiveWorkflowId: "wf-1",
|
||||
...AGENT_PAYLOAD_DEFAULTS,
|
||||
trimmedTitle: "Manual task",
|
||||
trimmedDescription: "Run the migration",
|
||||
repositoriesPayload: [],
|
||||
agentProfileId: "agent-1",
|
||||
executorId: "executor-1",
|
||||
executorProfileId: "profile-1",
|
||||
withAgent: true,
|
||||
autopilot: false,
|
||||
});
|
||||
|
||||
expect(payload.autopilot).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCreateTaskPayload dependencies", () => {
|
||||
const base = {
|
||||
...AGENT_PAYLOAD_DEFAULTS,
|
||||
trimmedTitle: "Second step",
|
||||
trimmedDescription: "Runs after the first",
|
||||
};
|
||||
|
||||
it("sends the selected predecessors as blocked_by", () => {
|
||||
const payload = buildCreateTaskPayload({ ...base, blockedBy: ["task-a", "task-b"] });
|
||||
expect(payload.blocked_by).toEqual(["task-a", "task-b"]);
|
||||
});
|
||||
|
||||
it("omits blocked_by when nothing is selected", () => {
|
||||
expect(buildCreateTaskPayload({ ...base, blockedBy: [] }).blocked_by).toBeUndefined();
|
||||
expect(buildCreateTaskPayload(base).blocked_by).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,6 +167,8 @@ export type BuildCreatePayloadArgs = {
|
||||
parentId?: string;
|
||||
workspacePath?: string;
|
||||
autopilot?: boolean;
|
||||
/** Task IDs this task must wait for. */
|
||||
blockedBy?: string[];
|
||||
};
|
||||
|
||||
export function buildCreateTaskPayload(args: BuildCreatePayloadArgs): CreateTaskParams {
|
||||
@@ -187,6 +189,10 @@ export function buildCreateTaskPayload(args: BuildCreatePayloadArgs): CreateTask
|
||||
parent_id: args.parentId || undefined,
|
||||
workspace_path: args.workspacePath || undefined,
|
||||
autopilot: args.autopilot || undefined,
|
||||
// Dependencies declared at creation time. With edges present the backend
|
||||
// records the requested agent start as a start-when-unblocked intent rather
|
||||
// than launching now, so a chain runs in order instead of all at once.
|
||||
blocked_by: args.blockedBy && args.blockedBy.length > 0 ? args.blockedBy : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { TaskCreateDialogProps } from "./task-create-dialog";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
agentGeneratedTaskTitles: false,
|
||||
submit: vi.fn(),
|
||||
submitDeps: {} as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/keyboard/constants", () => ({ SHORTCUTS: { SUBMIT: "submit" } }));
|
||||
@@ -33,7 +34,10 @@ vi.mock("@/components/state-provider", () => ({
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/components/task-create-dialog-submit", () => ({
|
||||
useTaskSubmitHandlers: () => ({ handleSubmit: mocks.submit, pendingDiscard: null }),
|
||||
useTaskSubmitHandlers: (deps: Record<string, unknown>) => {
|
||||
mocks.submitDeps = deps;
|
||||
return { handleSubmit: mocks.submit, pendingDiscard: null };
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/task-create-dialog-workflow-context", () => ({
|
||||
useResolvedTaskCreateWorkflowContext: (props: TaskCreateDialogProps) => props,
|
||||
@@ -58,6 +62,8 @@ vi.mock("@/components/task-create-dialog-state", () => ({
|
||||
executorProfileId: "",
|
||||
freshBranchEnabled: false,
|
||||
noRepository: false,
|
||||
blockedBy: ["dep-1"],
|
||||
setBlockedBy: vi.fn(),
|
||||
workspacePath: "",
|
||||
isCreatingSession: false,
|
||||
isCreatingTask: false,
|
||||
@@ -142,3 +148,12 @@ describe("useTaskCreateDialogSetup auto-title mode", () => {
|
||||
expect(result.current.autoTitle).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards the selected dependencies to the submit handlers", () => {
|
||||
// The payload builder handled blocked_by correctly all along; the break was
|
||||
// this hop — useSubmitHandlersWiring never passed blockedBy through, so the
|
||||
// create dialog's selection silently never reached the request. Assert the
|
||||
// hop itself, not just the leaf.
|
||||
renderHook(() => useTaskCreateDialogSetup({ ...props, mode: "create" }));
|
||||
expect(mocks.submitDeps.blockedBy).toEqual(["dep-1"]);
|
||||
});
|
||||
|
||||
@@ -186,6 +186,7 @@ function useSubmitHandlersWiring({
|
||||
repositoryLocalPath,
|
||||
noRepository: fs.noRepository,
|
||||
workspacePath: fs.workspacePath,
|
||||
blockedBy: fs.blockedBy,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ type FormResetters = {
|
||||
setGitHubUrlError: (v: string | null) => void;
|
||||
setFreshBranchEnabled: (v: boolean) => void;
|
||||
setCurrentLocalBranch: (v: string) => void;
|
||||
setBlockedBy: (v: string[]) => void;
|
||||
};
|
||||
|
||||
type FormResetEffectsArgs = {
|
||||
@@ -271,6 +272,9 @@ function resetDiscoveryState(resetters: FormResetters, iv?: TaskCreateDialogInit
|
||||
// mode and reopening for a different task would land in None mode again.
|
||||
resetters.setNoRepository(false);
|
||||
resetters.setWorkspacePath("");
|
||||
// The dialog stays mounted between opens, so without this the previous
|
||||
// create's predecessor selection reappears on the next one.
|
||||
resetters.setBlockedBy([]);
|
||||
}
|
||||
|
||||
/** Hook to manage draft persistence for task creation dialog */
|
||||
@@ -321,6 +325,15 @@ function useWorkflowAgentProfileState() {
|
||||
return { workflowAgentProfileId, setWorkflowAgentProfileId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Predecessor task IDs selected in the dialog. Dependencies are declared at
|
||||
* creation time (or later via MCP); nothing edits them from the task view.
|
||||
*/
|
||||
function useTaskDependencyState() {
|
||||
const [blockedBy, setBlockedBy] = useState<string[]>([]);
|
||||
return { blockedBy, setBlockedBy };
|
||||
}
|
||||
|
||||
function useFreshBranchState() {
|
||||
const [freshBranchEnabled, setFreshBranchEnabled] = useState(false);
|
||||
const [currentLocalBranch, setCurrentLocalBranch] = useState("");
|
||||
@@ -453,6 +466,7 @@ export function useDialogFormState(
|
||||
const repos = useRepositoriesState();
|
||||
const remoteRepos = useRemoteReposState();
|
||||
const freshBranch = useFreshBranchState();
|
||||
const dependencies = useTaskDependencyState();
|
||||
const branchesByUrl = useBranchesByURL(workspaceId);
|
||||
const prInfoByUrl = usePRInfoByURL(workspaceId);
|
||||
|
||||
@@ -467,6 +481,7 @@ export function useDialogFormState(
|
||||
prevOpenRef: form.prevOpenRef,
|
||||
lockedWorkflow,
|
||||
resetters: {
|
||||
setBlockedBy: dependencies.setBlockedBy,
|
||||
setTaskName: form.setTaskName,
|
||||
setHasTitle: form.setHasTitle,
|
||||
setHasDescription: form.setHasDescription,
|
||||
@@ -521,6 +536,7 @@ export function useDialogFormState(
|
||||
...repos,
|
||||
...remoteRepos,
|
||||
...freshBranch,
|
||||
...dependencies,
|
||||
branchesByUrl,
|
||||
prInfoByUrl,
|
||||
clearDraft,
|
||||
|
||||
@@ -111,6 +111,7 @@ export function useTaskSubmitHandlers({
|
||||
repositoryLocalPath,
|
||||
noRepository,
|
||||
workspacePath,
|
||||
blockedBy,
|
||||
transformDescriptionBeforeSubmit,
|
||||
}: SubmitHandlersDeps) {
|
||||
const router = useRouter();
|
||||
@@ -426,6 +427,7 @@ export function useTaskSubmitHandlers({
|
||||
// "empty path string" on the wire.
|
||||
workspacePath: resolveWorkspacePath(noRepository, workspacePath),
|
||||
autopilot,
|
||||
blockedBy,
|
||||
});
|
||||
submittedPayload = payload;
|
||||
return payload;
|
||||
@@ -457,6 +459,7 @@ export function useTaskSubmitHandlers({
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
autoTitle,
|
||||
blockedBy,
|
||||
agentProfileId,
|
||||
executorId,
|
||||
executorProfileId,
|
||||
@@ -689,6 +692,7 @@ export function useTaskSubmitHandlers({
|
||||
attachments,
|
||||
workspacePath: resolveWorkspacePath(noRepository, workspacePath),
|
||||
autopilot,
|
||||
blockedBy,
|
||||
});
|
||||
p.workflow_step_id = requirements.workflowStepId;
|
||||
submittedPayload = p;
|
||||
@@ -735,6 +739,7 @@ export function useTaskSubmitHandlers({
|
||||
toast,
|
||||
descriptionInputRef,
|
||||
setIsCreatingTask,
|
||||
blockedBy,
|
||||
]);
|
||||
|
||||
const editSubmitHandler = isStartedEdit ? handleUpdateWithoutAgent : handleEditSubmit;
|
||||
|
||||
@@ -304,6 +304,9 @@ export type TaskFormInputsHandle = {
|
||||
};
|
||||
|
||||
export type DialogFormState = {
|
||||
/** Predecessor task IDs chosen in the dialog's "Depends on" selector. */
|
||||
blockedBy: string[];
|
||||
setBlockedBy: (v: string[]) => void;
|
||||
taskName: string;
|
||||
setTaskName: (v: string) => void;
|
||||
hasTitle: boolean;
|
||||
@@ -479,6 +482,8 @@ export type SubmitHandlersDeps = {
|
||||
repositoryLocalPath: string;
|
||||
/** When true, the task is created with no repositories (repo-less mode). */
|
||||
noRepository: boolean;
|
||||
/** Predecessor task IDs to link at creation time. */
|
||||
blockedBy?: string[];
|
||||
/** Optional host folder for repo-less tasks; empty means kandev creates a scratch workspace. */
|
||||
workspacePath: string;
|
||||
/**
|
||||
|
||||
@@ -231,6 +231,8 @@ vi.mock("@/components/task-create-dialog-state", () => ({
|
||||
|
||||
function buildMockFs(initialDescription = ORIGINAL_PROMPT): DialogFormState {
|
||||
return {
|
||||
blockedBy: [],
|
||||
setBlockedBy: () => undefined,
|
||||
taskName: "Task title",
|
||||
autopilot: false,
|
||||
setAutopilot: () => undefined,
|
||||
|
||||
@@ -15,12 +15,13 @@ import {
|
||||
WorkflowSection,
|
||||
DialogPromptSection,
|
||||
} from "@/components/task-create-dialog-form-body";
|
||||
import { CreateModeSelectors } from "@/components/task-create-dialog-create-mode-selectors";
|
||||
import { TaskCreateDependencies } from "@/components/task-create-dialog-dependencies";
|
||||
import {
|
||||
AgentSelector,
|
||||
ExecutorProfileSelector,
|
||||
InlineTaskName,
|
||||
} from "@/components/task-create-dialog-selectors";
|
||||
import { CreateModeSelectors } from "@/components/task-create-dialog-create-mode-selectors";
|
||||
import { RepoChipsRow } from "@/components/task-create-dialog-repo-chips";
|
||||
import type {
|
||||
DialogFormBodyProps,
|
||||
@@ -110,6 +111,13 @@ function CreateModeBody(props: DialogFormBodyProps) {
|
||||
onComposerSubmit={props.onComposerSubmit}
|
||||
/>
|
||||
<CreateModeAgentSelectors {...props} />
|
||||
{isCreateMode && !isTaskStarted && (
|
||||
<TaskCreateDependencies
|
||||
value={fs.blockedBy}
|
||||
onChange={fs.setBlockedBy}
|
||||
disabled={props.isCreatingSession}
|
||||
/>
|
||||
)}
|
||||
{props.bottomSlot}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSubmitMessage, shouldRenderChatStatusBar } from "./chat-input-area";
|
||||
import { buildSubmitMessage } from "./chat-input-area";
|
||||
import { resolveStatusRowTaskId, shouldRenderChatStatusBar } from "./chat-status-bar";
|
||||
import type { AgentMessageComment } from "@/lib/state/slices/comments";
|
||||
|
||||
const messageComment: AgentMessageComment = {
|
||||
@@ -49,3 +50,20 @@ describe("shouldRenderChatStatusBar", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveStatusRowTaskId", () => {
|
||||
it("prefers the session-derived task so existing hosts are unchanged", () => {
|
||||
expect(resolveStatusRowTaskId("from-session", "from-host")).toBe("from-session");
|
||||
});
|
||||
|
||||
// The regression: a blocked chain step has no session, so the session-derived
|
||||
// id is null and the status row (and with it the dependency chip) disappeared
|
||||
// on exactly the tasks the chip exists to describe.
|
||||
it("falls back to the host's task when the task has no session yet", () => {
|
||||
expect(resolveStatusRowTaskId(null, "from-host")).toBe("from-host");
|
||||
});
|
||||
|
||||
it("stays null when neither side knows a task", () => {
|
||||
expect(resolveStatusRowTaskId(null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { TodoIndicator } from "./todo-indicator";
|
||||
import { AutoScrollToggleButton } from "./auto-scroll-toggle-button";
|
||||
import { PRMergedBanner, PRClosedBanner } from "./pr-archive-banners";
|
||||
import { PRStatusChip } from "@/components/github/pr-status-chip";
|
||||
import { MRStatusChip } from "@/components/gitlab/mr-status-chip";
|
||||
import { AzureDevOpsTaskPullRequestChip } from "@/components/azure-devops/azure-devops-task-pull-request-chip";
|
||||
import { RegisteredChangeRequestStatus } from "@/components/integrations/registered-change-request-status";
|
||||
import { shareableSessionStateClient } from "@/components/task/share/share-button";
|
||||
import { TranscriptNavGroup } from "@/components/task/chat/transcript-nav-group";
|
||||
import { useCallback, useState } from "react";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
|
||||
import { useMessageHandler, buildTaskMentionsContext } from "@/hooks/use-message-handler";
|
||||
@@ -42,9 +30,8 @@ import type { ChatPanelState } from "./use-chat-panel-state";
|
||||
import { useComposerProps } from "./use-composer-props";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveComposerWorkspaceId } from "./composer-workspace";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { AutopilotChatChip, useTaskAutopilot } from "./task-autopilot-chat-chip";
|
||||
import { ChatStatusBar, resolveStatusRowTaskId } from "./chat-status-bar";
|
||||
|
||||
const PLAN_CONTEXT_PATH = "plan:context";
|
||||
|
||||
@@ -312,172 +299,6 @@ export function useChatPanelHandlers(
|
||||
return { handleCancelTurn };
|
||||
}
|
||||
|
||||
type TodoDisplayItem = {
|
||||
text: string;
|
||||
done?: boolean;
|
||||
status?: "pending" | "in_progress" | "completed" | "failed";
|
||||
};
|
||||
|
||||
export function shouldRenderChatStatusBar({
|
||||
hasTask,
|
||||
hasTodos,
|
||||
hasQueueChip,
|
||||
showRightControls,
|
||||
showProceed,
|
||||
}: {
|
||||
hasTask: boolean;
|
||||
hasTodos: boolean;
|
||||
hasQueueChip: boolean;
|
||||
showRightControls: boolean;
|
||||
showProceed: boolean;
|
||||
}): boolean {
|
||||
return hasTask || hasTodos || hasQueueChip || showRightControls || showProceed;
|
||||
}
|
||||
|
||||
function getRightControlVisibility({
|
||||
taskId,
|
||||
sessionId,
|
||||
sessionState,
|
||||
showAutoScrollControl,
|
||||
showScrollToLastPrompt,
|
||||
showScrollToStart,
|
||||
}: {
|
||||
taskId: string | null;
|
||||
sessionId: string | null;
|
||||
sessionState: string | null;
|
||||
showAutoScrollControl: boolean;
|
||||
showScrollToLastPrompt: boolean | undefined;
|
||||
showScrollToStart: boolean | undefined;
|
||||
}) {
|
||||
const canShare = !!taskId && !!sessionId && shareableSessionStateClient(sessionState);
|
||||
const showRightControls =
|
||||
(showAutoScrollControl && !!sessionId) ||
|
||||
canShare ||
|
||||
!!showScrollToLastPrompt ||
|
||||
!!showScrollToStart;
|
||||
return { canShare, showRightControls };
|
||||
}
|
||||
|
||||
/**
|
||||
* Row above the composer showing todo progress, PR/CI status chips,
|
||||
* merged/closed PR banners, the auto-scroll toggle + Share (right-aligned),
|
||||
* and a "move to next step" action when the workflow allows it.
|
||||
*/
|
||||
type ChatStatusBarProps = {
|
||||
todoItems: TodoDisplayItem[];
|
||||
taskId: string | null;
|
||||
sessionId: string | null;
|
||||
sessionState: string | null;
|
||||
nextStepName: string | null;
|
||||
onProceed: () => void;
|
||||
isAgentBusy: boolean;
|
||||
isMoving: boolean;
|
||||
queueChip?: ReactNode;
|
||||
showScrollToLastPrompt?: boolean;
|
||||
onScrollToLastPrompt?: () => void;
|
||||
lastPromptScrollDirection?: "up" | "down";
|
||||
showScrollToStart?: boolean;
|
||||
onScrollToStart?: () => void;
|
||||
};
|
||||
|
||||
function ChatStatusBar({
|
||||
todoItems,
|
||||
taskId,
|
||||
sessionId,
|
||||
sessionState,
|
||||
nextStepName,
|
||||
onProceed,
|
||||
isAgentBusy,
|
||||
isMoving,
|
||||
queueChip,
|
||||
showScrollToLastPrompt,
|
||||
onScrollToLastPrompt,
|
||||
lastPromptScrollDirection,
|
||||
showScrollToStart,
|
||||
onScrollToStart,
|
||||
}: ChatStatusBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const showTodos = todoItems.length > 0;
|
||||
const showProceed = !!nextStepName && !isAgentBusy;
|
||||
const autopilot = useTaskAutopilot(taskId);
|
||||
const showAutoScrollControl = useAppStore(
|
||||
(state) => state.userSettings.showTranscriptAutoScrollControl,
|
||||
);
|
||||
const { canShare, showRightControls } = getRightControlVisibility({
|
||||
taskId,
|
||||
sessionId,
|
||||
sessionState,
|
||||
showAutoScrollControl,
|
||||
showScrollToLastPrompt,
|
||||
showScrollToStart,
|
||||
});
|
||||
if (
|
||||
!shouldRenderChatStatusBar({
|
||||
hasTask: !!taskId,
|
||||
hasTodos: showTodos,
|
||||
hasQueueChip: !!queueChip,
|
||||
showRightControls,
|
||||
showProceed,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// PRMergedBanner returns null internally when not applicable
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-status-bar"
|
||||
className="flex min-w-0 flex-wrap items-center gap-1.5 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{showTodos && <TodoIndicator todos={todoItems} />}
|
||||
{autopilot && <AutopilotChatChip />}
|
||||
<PRStatusChip taskId={taskId} />
|
||||
<MRStatusChip taskId={taskId} />
|
||||
<AzureDevOpsTaskPullRequestChip taskId={taskId} />
|
||||
<RegisteredChangeRequestStatus taskId={taskId} sessionId={sessionId} surface="composer" />
|
||||
{queueChip}
|
||||
{/* Distinct per-banner keys: the key remounts the banner on task switch
|
||||
so its dismissed state re-initialises, and keeping the two suffixes
|
||||
different avoids a duplicate-sibling-key collision. */}
|
||||
{taskId && <PRMergedBanner key={`${taskId}-merged`} taskId={taskId} />}
|
||||
{taskId && <PRClosedBanner key={`${taskId}-closed`} taskId={taskId} />}
|
||||
{showRightControls && (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{sessionId && <AutoScrollToggleButton sessionId={sessionId} />}
|
||||
<TranscriptNavGroup
|
||||
canShare={canShare}
|
||||
taskId={taskId}
|
||||
sessionId={sessionId}
|
||||
showScrollToLastPrompt={showScrollToLastPrompt}
|
||||
onScrollToLastPrompt={onScrollToLastPrompt}
|
||||
lastPromptScrollDirection={lastPromptScrollDirection}
|
||||
showScrollToStart={showScrollToStart}
|
||||
onScrollToStart={onScrollToStart}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showProceed && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`${showRightControls ? "" : "ml-auto "}h-6 gap-1 px-2.5 text-xs cursor-pointer text-primary`}
|
||||
onClick={onProceed}
|
||||
disabled={isMoving}
|
||||
data-testid="proceed-next-step"
|
||||
>
|
||||
{nextStepName}
|
||||
<IconArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("task:moveTaskToTheNextWorkflow")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ChatInputAreaProps = {
|
||||
chatInputRef: React.RefObject<ChatInputContainerHandle | null>;
|
||||
clarificationKey: number;
|
||||
@@ -511,6 +332,13 @@ type ChatInputAreaProps = {
|
||||
* start of the transcript. */
|
||||
showScrollToStart?: boolean;
|
||||
onScrollToStart?: () => void;
|
||||
/**
|
||||
* Task this composer belongs to, for the status row only. Hosts that mount a
|
||||
* task's chat before any session exists pass it so the dependency / autopilot
|
||||
* chips still render; without it the row is hidden on exactly the tasks a
|
||||
* dependency chip is about.
|
||||
*/
|
||||
statusTaskId?: string | null;
|
||||
};
|
||||
|
||||
/** Resolves whether this session's executor environment is unavailable, and why. */
|
||||
@@ -597,8 +425,10 @@ export function ChatInputArea({
|
||||
lastPromptScrollDirection,
|
||||
showScrollToStart,
|
||||
onScrollToStart,
|
||||
statusTaskId = null,
|
||||
}: ChatInputAreaProps) {
|
||||
const { resolvedSessionId, taskId, isAgentBusy } = panelState;
|
||||
const statusRowTaskId = resolveStatusRowTaskId(taskId, statusTaskId);
|
||||
const composerWorkspaceId = useComposerWorkspaceId(resolvedSessionId, taskId);
|
||||
const sessionState = panelState.session?.state ?? null;
|
||||
const canDrainQueue = canManuallyDrainQueue(panelState.pendingClarification, sessionState);
|
||||
@@ -637,7 +467,7 @@ export function ChatInputArea({
|
||||
renderStatusBar={(queueChip) => (
|
||||
<ChatStatusBar
|
||||
todoItems={panelState.todoItems}
|
||||
taskId={taskId}
|
||||
taskId={statusRowTaskId}
|
||||
sessionId={resolvedSessionId}
|
||||
sessionState={sessionState}
|
||||
nextStepName={proceedStepName}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The status row above the chat composer: todos, the autopilot / dependency /
|
||||
* PR / MR chips, the queue chip, archive banners, transcript navigation, and
|
||||
* the proceed button.
|
||||
*
|
||||
* Split out of `chat-input-area.tsx`, which was at its 600-line limit. The row
|
||||
* is a self-contained unit: it reads the task and session ids and renders
|
||||
* indicators, so nothing here participates in composing or sending a message.
|
||||
*/
|
||||
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import type { ReactNode } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { PRStatusChip } from "@/components/github/pr-status-chip";
|
||||
import { MRStatusChip } from "@/components/gitlab/mr-status-chip";
|
||||
import { TaskDependencyChip } from "@/components/task/task-dependency-chip";
|
||||
import { AzureDevOpsTaskPullRequestChip } from "@/components/azure-devops/azure-devops-task-pull-request-chip";
|
||||
import { RegisteredChangeRequestStatus } from "@/components/integrations/registered-change-request-status";
|
||||
import { shareableSessionStateClient } from "@/components/task/share/share-button";
|
||||
import { TranscriptNavGroup } from "@/components/task/chat/transcript-nav-group";
|
||||
import { TodoIndicator } from "./todo-indicator";
|
||||
import { AutoScrollToggleButton } from "./auto-scroll-toggle-button";
|
||||
import { PRMergedBanner, PRClosedBanner } from "./pr-archive-banners";
|
||||
import { AutopilotChatChip, useTaskAutopilot } from "./task-autopilot-chat-chip";
|
||||
|
||||
type TodoDisplayItem = {
|
||||
text: string;
|
||||
done?: boolean;
|
||||
status?: "pending" | "in_progress" | "completed" | "failed";
|
||||
};
|
||||
|
||||
/**
|
||||
* The status row belongs to the TASK, not to the session.
|
||||
*
|
||||
* `panelState.taskId` is session-derived and is null on a task that has no
|
||||
* session yet, which is exactly the state a blocked dependency-chain step sits
|
||||
* in: with no id the whole row is hidden, taking the dependency chip with it on
|
||||
* the one kind of task the chip is about. Hosts that know their task pass it as
|
||||
* `statusTaskId`. The session-derived id still wins when present, so hosts that
|
||||
* pass nothing are unchanged.
|
||||
*/
|
||||
export function resolveStatusRowTaskId(
|
||||
sessionTaskId: string | null,
|
||||
statusTaskId: string | null,
|
||||
): string | null {
|
||||
return sessionTaskId ?? statusTaskId;
|
||||
}
|
||||
|
||||
export function shouldRenderChatStatusBar({
|
||||
hasTask,
|
||||
hasTodos,
|
||||
hasQueueChip,
|
||||
showRightControls,
|
||||
showProceed,
|
||||
}: {
|
||||
hasTask: boolean;
|
||||
hasTodos: boolean;
|
||||
hasQueueChip: boolean;
|
||||
showRightControls: boolean;
|
||||
showProceed: boolean;
|
||||
}): boolean {
|
||||
return hasTask || hasTodos || hasQueueChip || showRightControls || showProceed;
|
||||
}
|
||||
|
||||
function getRightControlVisibility({
|
||||
taskId,
|
||||
sessionId,
|
||||
sessionState,
|
||||
showAutoScrollControl,
|
||||
showScrollToLastPrompt,
|
||||
showScrollToStart,
|
||||
}: {
|
||||
taskId: string | null;
|
||||
sessionId: string | null;
|
||||
sessionState: string | null;
|
||||
showAutoScrollControl: boolean;
|
||||
showScrollToLastPrompt: boolean | undefined;
|
||||
showScrollToStart: boolean | undefined;
|
||||
}) {
|
||||
const canShare = !!taskId && !!sessionId && shareableSessionStateClient(sessionState);
|
||||
const showRightControls =
|
||||
(showAutoScrollControl && !!sessionId) ||
|
||||
canShare ||
|
||||
!!showScrollToLastPrompt ||
|
||||
!!showScrollToStart;
|
||||
return { canShare, showRightControls };
|
||||
}
|
||||
|
||||
/**
|
||||
* Row above the composer showing todo progress, PR/CI status chips,
|
||||
* merged/closed PR banners, the auto-scroll toggle + Share (right-aligned),
|
||||
* and a "move to next step" action when the workflow allows it.
|
||||
*/
|
||||
export type ChatStatusBarProps = {
|
||||
todoItems: TodoDisplayItem[];
|
||||
taskId: string | null;
|
||||
sessionId: string | null;
|
||||
sessionState: string | null;
|
||||
nextStepName: string | null;
|
||||
onProceed: () => void;
|
||||
isAgentBusy: boolean;
|
||||
isMoving: boolean;
|
||||
queueChip?: ReactNode;
|
||||
showScrollToLastPrompt?: boolean;
|
||||
onScrollToLastPrompt?: () => void;
|
||||
lastPromptScrollDirection?: "up" | "down";
|
||||
showScrollToStart?: boolean;
|
||||
onScrollToStart?: () => void;
|
||||
};
|
||||
|
||||
export function ChatStatusBar({
|
||||
todoItems,
|
||||
taskId,
|
||||
sessionId,
|
||||
sessionState,
|
||||
nextStepName,
|
||||
onProceed,
|
||||
isAgentBusy,
|
||||
isMoving,
|
||||
queueChip,
|
||||
showScrollToLastPrompt,
|
||||
onScrollToLastPrompt,
|
||||
lastPromptScrollDirection,
|
||||
showScrollToStart,
|
||||
onScrollToStart,
|
||||
}: ChatStatusBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const showTodos = todoItems.length > 0;
|
||||
const showProceed = !!nextStepName && !isAgentBusy;
|
||||
const autopilot = useTaskAutopilot(taskId);
|
||||
const showAutoScrollControl = useAppStore(
|
||||
(state) => state.userSettings.showTranscriptAutoScrollControl,
|
||||
);
|
||||
const { canShare, showRightControls } = getRightControlVisibility({
|
||||
taskId,
|
||||
sessionId,
|
||||
sessionState,
|
||||
showAutoScrollControl,
|
||||
showScrollToLastPrompt,
|
||||
showScrollToStart,
|
||||
});
|
||||
if (
|
||||
!shouldRenderChatStatusBar({
|
||||
hasTask: !!taskId,
|
||||
hasTodos: showTodos,
|
||||
hasQueueChip: !!queueChip,
|
||||
showRightControls,
|
||||
showProceed,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// PRMergedBanner returns null internally when not applicable
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-status-bar"
|
||||
className="flex min-w-0 flex-wrap items-center gap-1.5 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{showTodos && <TodoIndicator todos={todoItems} />}
|
||||
{autopilot && <AutopilotChatChip />}
|
||||
<TaskDependencyChip taskId={taskId} />
|
||||
<PRStatusChip taskId={taskId} />
|
||||
<MRStatusChip taskId={taskId} />
|
||||
<AzureDevOpsTaskPullRequestChip taskId={taskId} />
|
||||
<RegisteredChangeRequestStatus taskId={taskId} sessionId={sessionId} surface="composer" />
|
||||
{queueChip}
|
||||
{/* Distinct per-banner keys: the key remounts the banner on task switch
|
||||
so its dismissed state re-initialises, and keeping the two suffixes
|
||||
different avoids a duplicate-sibling-key collision. */}
|
||||
{taskId && <PRMergedBanner key={`${taskId}-merged`} taskId={taskId} />}
|
||||
{taskId && <PRClosedBanner key={`${taskId}-closed`} taskId={taskId} />}
|
||||
{showRightControls && (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{sessionId && <AutoScrollToggleButton sessionId={sessionId} />}
|
||||
<TranscriptNavGroup
|
||||
canShare={canShare}
|
||||
taskId={taskId}
|
||||
sessionId={sessionId}
|
||||
showScrollToLastPrompt={showScrollToLastPrompt}
|
||||
onScrollToLastPrompt={onScrollToLastPrompt}
|
||||
lastPromptScrollDirection={lastPromptScrollDirection}
|
||||
showScrollToStart={showScrollToStart}
|
||||
onScrollToStart={onScrollToStart}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showProceed && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`${showRightControls ? "" : "ml-auto "}h-6 gap-1 px-2.5 text-xs cursor-pointer text-primary`}
|
||||
onClick={onProceed}
|
||||
disabled={isMoving}
|
||||
data-testid="proceed-next-step"
|
||||
>
|
||||
{nextStepName}
|
||||
<IconArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("task:moveTaskToTheNextWorkflow")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,7 @@ function ChatContent({ panelId, params }: { panelId: string; params: Record<stri
|
||||
<TaskChatPanel
|
||||
sessionId={sessionId}
|
||||
taskId={sessionId ? taskId : null}
|
||||
statusTaskId={taskId}
|
||||
onOpenFile={openFile}
|
||||
onOpenFileAtLine={openFile}
|
||||
hideSessionsDropdown
|
||||
|
||||
@@ -215,6 +215,7 @@ function ChatContent({ panelId, params }: { panelId: string; params: Record<stri
|
||||
<TaskChatPanel
|
||||
sessionId={sessionId}
|
||||
taskId={sessionId ? taskId : null}
|
||||
statusTaskId={taskId}
|
||||
onOpenFile={openFile}
|
||||
onOpenFileAtLine={openFile}
|
||||
hideSessionsDropdown
|
||||
|
||||
@@ -155,6 +155,8 @@ const EMPTY_STEPS: StepType[] | null = null;
|
||||
// its own title input directly and doesn't restore drafts. Extracted so the
|
||||
// useMemo body stays under the function-length lint cap.
|
||||
const INERT_TITLE_DRAFT = {
|
||||
blockedBy: [] as string[],
|
||||
setBlockedBy: () => undefined,
|
||||
taskName: "",
|
||||
setTaskName: NOOP,
|
||||
hasTitle: false,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Textarea } from "@kandev/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { PRStatusChip } from "@/components/github/pr-status-chip";
|
||||
import { MRStatusChip } from "@/components/gitlab/mr-status-chip";
|
||||
import { TaskDependencyChip } from "@/components/task/task-dependency-chip";
|
||||
import { AzureDevOpsTaskPullRequestChip } from "@/components/azure-devops/azure-devops-task-pull-request-chip";
|
||||
import { RegisteredChangeRequestStatus } from "@/components/integrations/registered-change-request-status";
|
||||
import { PRMergedBanner } from "./chat/pr-archive-banners";
|
||||
@@ -539,6 +540,7 @@ function PassthroughStatusRow({
|
||||
/>
|
||||
|
||||
<div className="ml-auto flex min-w-0 max-w-full flex-wrap items-center justify-end gap-1.5">
|
||||
<TaskDependencyChip taskId={taskId} />
|
||||
<PRStatusChip taskId={taskId} />
|
||||
<MRStatusChip taskId={taskId} />
|
||||
<AzureDevOpsTaskPullRequestChip taskId={taskId} />
|
||||
|
||||
@@ -170,6 +170,13 @@ type TaskChatPanelProps = {
|
||||
onSend?: (payload: ChatSubmitPayload) => ChatSubmitResult;
|
||||
sessionId?: string | null;
|
||||
taskId?: string | null;
|
||||
/**
|
||||
* Task this panel belongs to, independent of whether it has a session yet.
|
||||
* Only the status row uses it, so a task with no session still shows its
|
||||
* dependency and autopilot chips. `taskId` above stays session-gated because
|
||||
* it also drives plan mode, the composer, and read tracking.
|
||||
*/
|
||||
statusTaskId?: string | null;
|
||||
onOpenFile?: (path: string) => void;
|
||||
showRequestChangesTooltip?: boolean;
|
||||
onRequestChangesTooltipDismiss?: () => void;
|
||||
@@ -193,6 +200,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
onSend,
|
||||
sessionId = null,
|
||||
taskId: taskIdHint = null,
|
||||
statusTaskId = null,
|
||||
onOpenFile,
|
||||
showRequestChangesTooltip = false,
|
||||
onRequestChangesTooltipDismiss,
|
||||
@@ -414,6 +422,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
lastPromptScrollDirection={scrollDirection}
|
||||
showScrollToStart={showScrollToStartButton}
|
||||
onScrollToStart={scrollToStart}
|
||||
statusTaskId={statusTaskId ?? taskIdHint}
|
||||
/>
|
||||
</PanelRoot>
|
||||
);
|
||||
@@ -479,6 +488,7 @@ type ChatFooterProps = {
|
||||
lastPromptScrollDirection: "up" | "down";
|
||||
showScrollToStart: boolean;
|
||||
onScrollToStart: () => void;
|
||||
statusTaskId: string | null;
|
||||
};
|
||||
|
||||
function ChatFooter({
|
||||
@@ -498,6 +508,7 @@ function ChatFooter({
|
||||
lastPromptScrollDirection,
|
||||
showScrollToStart,
|
||||
onScrollToStart,
|
||||
statusTaskId,
|
||||
}: ChatFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
if (isArchived) {
|
||||
@@ -524,6 +535,7 @@ function ChatFooter({
|
||||
lastPromptScrollDirection={lastPromptScrollDirection}
|
||||
showScrollToStart={showScrollToStart}
|
||||
onScrollToStart={onScrollToStart}
|
||||
statusTaskId={statusTaskId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getTaskDependenciesMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/api/domains/task-dependencies-api", () => ({
|
||||
getTaskDependencies: getTaskDependenciesMock,
|
||||
}));
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { TaskDependencyChip } from "./task-dependency-chip";
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
|
||||
beforeEach(() => {
|
||||
getTaskDependenciesMock.mockReset();
|
||||
// Default: the server also reports no edges, so the fetch fallback cannot be
|
||||
// what makes a "renders nothing" assertion pass.
|
||||
getTaskDependenciesMock.mockResolvedValue({ id: "unknown", depends_on: [], blocks: [] });
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type KanbanTask = KanbanState["tasks"][number];
|
||||
|
||||
const task = (over: Partial<KanbanTask> & { id: string }): KanbanTask =>
|
||||
({
|
||||
workflowStepId: "step-1",
|
||||
title: over.id.toUpperCase(),
|
||||
position: 0,
|
||||
...over,
|
||||
}) as KanbanTask;
|
||||
|
||||
const CHIP_TESTID = "task-dependency-chip";
|
||||
|
||||
function renderChip(tasks: KanbanTask[], taskId: string) {
|
||||
return render(
|
||||
<StateProvider initialState={{ kanban: { workflowId: "wf-1", steps: [], tasks } } as never}>
|
||||
<TaskDependencyChip taskId={taskId} />
|
||||
</StateProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TaskDependencyChip", () => {
|
||||
it("renders nothing when the task has no edges in either direction", () => {
|
||||
renderChip([task({ id: "a" })], "a");
|
||||
expect(screen.queryByTestId(CHIP_TESTID)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing for an unknown task", () => {
|
||||
renderChip([task({ id: "a" })], "missing");
|
||||
expect(screen.queryByTestId(CHIP_TESTID)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders when the task only has dependents (it blocks others but is not blocked)", () => {
|
||||
// The reverse direction alone must surface the chip: "two tasks are waiting
|
||||
// on this one" is exactly the context the chip exists to show.
|
||||
renderChip([task({ id: "a", blocks: [{ id: "b" }, { id: "c" }] })], "a");
|
||||
expect(screen.getByTestId(CHIP_TESTID)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders both directions with counts", () => {
|
||||
renderChip(
|
||||
[
|
||||
task({
|
||||
id: "b",
|
||||
blocked: true,
|
||||
blockedReason: "pending",
|
||||
dependsOn: [{ id: "a", title: "A", status: "pending" }],
|
||||
blocks: [{ id: "c", title: "C" }],
|
||||
}),
|
||||
],
|
||||
"b",
|
||||
);
|
||||
const chip = screen.getByTestId(CHIP_TESTID);
|
||||
expect(chip.textContent).toMatch(/1/);
|
||||
});
|
||||
|
||||
it("renders for a resolved dependency so the chain is still visible after it unblocks", () => {
|
||||
renderChip(
|
||||
[
|
||||
task({
|
||||
id: "b",
|
||||
blocked: false,
|
||||
dependsOn: [{ id: "a", title: "A", status: "resolved" }],
|
||||
}),
|
||||
],
|
||||
"b",
|
||||
);
|
||||
expect(screen.getByTestId(CHIP_TESTID)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("drops fetched data as soon as the store can answer 'no edges'", async () => {
|
||||
// Regression: `fromStore ?? fetched` fell through on the store's `null`,
|
||||
// which is an explicit "this task has no edges" answer. Switching from a
|
||||
// task resolved by fetch to one the store knows is edge-free then kept
|
||||
// rendering the previous task's dependencies.
|
||||
getTaskDependenciesMock.mockResolvedValue({
|
||||
id: "fetched-task",
|
||||
blocked: true,
|
||||
depends_on: [{ id: "p", title: "P", status: "pending" }],
|
||||
blocks: [],
|
||||
});
|
||||
// Explicit empty lists = the store's real "no edges" answer. A task with no
|
||||
// projection at all is "unknown", which correctly falls through to a fetch.
|
||||
const store = [task({ id: "a", blocked: false, dependsOn: [], blocks: [] })];
|
||||
const { rerender } = renderChip(store, "fetched-task");
|
||||
await screen.findByTestId(CHIP_TESTID);
|
||||
|
||||
rerender(
|
||||
<StateProvider
|
||||
initialState={{ kanban: { workflowId: "wf-1", steps: [], tasks: store } } as never}
|
||||
>
|
||||
<TaskDependencyChip taskId="a" />
|
||||
</StateProvider>,
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByTestId(CHIP_TESTID)).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TaskDependencyChip — the status-row chip that shows what a task is waiting on
|
||||
* and what is waiting on it.
|
||||
*
|
||||
* Mounted next to `<PRStatusChip>` in the row above the composer. Both
|
||||
* directions live on the task payload already (`dependsOn` / `blocks`, each
|
||||
* entry carrying id/title/state), so opening the chip costs no fetch.
|
||||
*
|
||||
* Follows PRStatusChip's two conventions: return null when the task has no
|
||||
* edges, and use a drawer on touch rather than a hover card.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconLink, IconLock, IconAlertTriangle, IconCheck, IconClock } from "@tabler/icons-react";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@kandev/ui/drawer";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
||||
import { cn } from "@kandev/ui/lib/utils";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
|
||||
import { getTaskDependencies } from "@/lib/api/domains/task-dependencies-api";
|
||||
import type { TaskDependencyRef } from "@/lib/state/slices/kanban/types";
|
||||
|
||||
type DependencyChipData = {
|
||||
blocked: boolean;
|
||||
blockedReason?: string;
|
||||
dependsOn: TaskDependencyRef[];
|
||||
blocks: TaskDependencyRef[];
|
||||
};
|
||||
|
||||
/**
|
||||
* useTaskDependencies reads the derived dependency projection for a task from
|
||||
* the store. Returns null when the task is unknown or has no edges in either
|
||||
* direction, which is also the chip's "render nothing" signal.
|
||||
*
|
||||
* The selector returns the task itself — a stable reference — and the derived
|
||||
* shape is built in a memo afterwards. Returning a freshly-built object FROM the
|
||||
* selector compares unequal under Object.is on every store read, which loops
|
||||
* until React bails out and renders nothing at all.
|
||||
*/
|
||||
export function useTaskDependencies(taskId: string | null): DependencyChipData | null {
|
||||
const fromStore = useTaskDependenciesFromStore(taskId);
|
||||
const storeAnswered = fromStore !== undefined;
|
||||
const fetched = useFetchedTaskDependencies(taskId, storeAnswered);
|
||||
// `??` would be wrong here: the store's `null` is a real answer ("this task
|
||||
// has no edges"), and falling through on it would keep showing the previously
|
||||
// fetched task's dependencies after an edge is removed or after switching to
|
||||
// a task the store knows is edge-free. Only `undefined` means "ask the server".
|
||||
return storeAnswered ? fromStore : fetched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the projection when the store copy has none.
|
||||
*
|
||||
* `undefined` from the store means "this copy carries no dependency fields" (a
|
||||
* lightweight task.updated overwrote them, or landed before hydration); `null`
|
||||
* means "read it, there are no edges". Only the former needs a fetch.
|
||||
*/
|
||||
function useFetchedTaskDependencies(
|
||||
taskId: string | null,
|
||||
storeAnswered: boolean,
|
||||
): DependencyChipData | null {
|
||||
const [data, setData] = useState<DependencyChipData | null>(null);
|
||||
useEffect(() => {
|
||||
if (!taskId || storeAnswered) {
|
||||
// Drop anything fetched for a previous task/state so it can never be
|
||||
// rendered again once the store can answer.
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getTaskDependencies(taskId)
|
||||
.then((task) => {
|
||||
if (cancelled) return;
|
||||
const dependsOn = (task.depends_on ?? []) as TaskDependencyRef[];
|
||||
const blocks = (task.blocks ?? []) as TaskDependencyRef[];
|
||||
setData(
|
||||
dependsOn.length === 0 && blocks.length === 0
|
||||
? null
|
||||
: {
|
||||
blocked: task.blocked ?? false,
|
||||
blockedReason: task.blocked_reason,
|
||||
dependsOn,
|
||||
blocks,
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed read hides the chip rather than showing a wrong state.
|
||||
if (!cancelled) setData(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [taskId, storeAnswered]);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store fast path. Returns `undefined` when the store copy carries no dependency
|
||||
* projection at all, so the caller knows to fetch rather than render nothing.
|
||||
*/
|
||||
function useTaskDependenciesFromStore(
|
||||
taskId: string | null,
|
||||
): DependencyChipData | null | undefined {
|
||||
const task = useAppStore((state) => {
|
||||
if (!taskId) return undefined;
|
||||
// Nil-safe on both slices: this renders inside a task panel that can mount
|
||||
// before board hydration completes.
|
||||
const fromBoard = state.kanban?.tasks?.find((candidate) => candidate.id === taskId);
|
||||
if (fromBoard) return fromBoard;
|
||||
for (const snapshot of Object.values(state.kanbanMulti?.snapshots ?? {})) {
|
||||
const hit = snapshot.tasks?.find((candidate) => candidate.id === taskId);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
return useMemo(() => {
|
||||
if (!task) return undefined;
|
||||
// No projection on this copy: the caller must ask the server.
|
||||
if (task.dependsOn === undefined && task.blocks === undefined) return undefined;
|
||||
const dependsOn = task.dependsOn ?? [];
|
||||
const blocks = task.blocks ?? [];
|
||||
if (dependsOn.length === 0 && blocks.length === 0) return null;
|
||||
return {
|
||||
blocked: task.blocked ?? false,
|
||||
blockedReason: task.blockedReason,
|
||||
dependsOn,
|
||||
blocks,
|
||||
};
|
||||
}, [task]);
|
||||
}
|
||||
|
||||
function statusIcon(status?: string) {
|
||||
if (status === "resolved")
|
||||
return <IconCheck className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />;
|
||||
if (status === "failed")
|
||||
return <IconAlertTriangle className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />;
|
||||
return <IconClock className="h-3.5 w-3.5 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
function DependencyRow({
|
||||
ref: entry,
|
||||
showStatus,
|
||||
}: {
|
||||
ref: TaskDependencyRef;
|
||||
showStatus: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<a
|
||||
href={`/tasks/${entry.id}`}
|
||||
className="flex items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent min-w-0"
|
||||
data-testid="task-dependency-entry"
|
||||
>
|
||||
{showStatus ? (
|
||||
statusIcon(entry.status)
|
||||
) : (
|
||||
<IconLink className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{entry.title || entry.id}</span>
|
||||
{entry.state && (
|
||||
<span className="shrink-0 text-[10px] uppercase text-muted-foreground">
|
||||
{t(`task:state.${entry.state}`, { defaultValue: entry.state })}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function DependencyLists({ data }: { data: DependencyChipData }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.dependsOn.length > 0 && (
|
||||
<section>
|
||||
<h4 className="px-2 pb-1 text-[10px] font-semibold uppercase text-muted-foreground">
|
||||
{t("task:dependencyBlockedBy")}
|
||||
</h4>
|
||||
{data.dependsOn.map((entry) => (
|
||||
<DependencyRow key={entry.id} ref={entry} showStatus />
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{data.blocks.length > 0 && (
|
||||
<section>
|
||||
<h4 className="px-2 pb-1 text-[10px] font-semibold uppercase text-muted-foreground">
|
||||
{t("task:dependencyBlocks")}
|
||||
</h4>
|
||||
{data.blocks.map((entry) => (
|
||||
<DependencyRow key={entry.id} ref={entry} showStatus={false} />
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{data.blockedReason === "failed" && (
|
||||
<p className="px-2 text-[11px] text-red-600 dark:text-red-400">
|
||||
{t("task:dependencyChainHalted")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipTriggerContent({ data }: { data: DependencyChipData }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{data.blocked ? <IconLock className="h-3.5 w-3.5" /> : <IconLink className="h-3.5 w-3.5" />}
|
||||
<span>
|
||||
{data.dependsOn.length > 0 &&
|
||||
t("task:dependencyCountBlockedBy", { count: data.dependsOn.length })}
|
||||
{data.dependsOn.length > 0 && data.blocks.length > 0 && " · "}
|
||||
{data.blocks.length > 0 && t("task:dependencyCountBlocks", { count: data.blocks.length })}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pill styling follows the house formula for status chips: a fully rounded
|
||||
* outline, a 10%-opacity tint of the state colour, a 35%-opacity border of the
|
||||
* same colour, and the colour itself as the text — see the workspace badge in
|
||||
* `app-sidebar/sections/settings/workspaces-group.tsx`. A solid fill with muted
|
||||
* text reads as a disabled button next to the sibling chips in this row.
|
||||
*
|
||||
* Reds use the repo's soft pair `text-red-600 dark:text-red-400` (see
|
||||
* `settings/plugins/plugin-status-badge.tsx`): a flat red-500 is harsh against a
|
||||
* dark surface.
|
||||
*
|
||||
* Colour carries the state: red when the chain has halted on a failed
|
||||
* predecessor (needs a human), primary while merely waiting or purely
|
||||
* informational. Deliberately not amber — that is the Autopilot chip's colour and
|
||||
* both can sit in this row at once.
|
||||
*/
|
||||
const TRIGGER_BASE =
|
||||
"inline-flex h-[26px] shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium leading-none transition-colors";
|
||||
|
||||
const TRIGGER_TONES = {
|
||||
failed: "border-red-500/40 bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/15",
|
||||
waiting: "border-primary/35 bg-primary/10 text-primary hover:bg-primary/15",
|
||||
} as const;
|
||||
|
||||
function triggerTone(data: DependencyChipData): string {
|
||||
return data.blockedReason === "failed" ? TRIGGER_TONES.failed : TRIGGER_TONES.waiting;
|
||||
}
|
||||
|
||||
export function TaskDependencyChip({ taskId }: { taskId: string | null }) {
|
||||
const data = useTaskDependencies(taskId);
|
||||
const usesMobileDrawer = useTouchDrawer();
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
// No edges in either direction: nothing to say. Same null-when-not-applicable
|
||||
// contract as PRStatusChip.
|
||||
if (!data) return null;
|
||||
|
||||
const toneClass = triggerTone(data);
|
||||
|
||||
if (usesMobileDrawer) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={t("task:dependencyChipLabel")}
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(TRIGGER_BASE, toneClass)}
|
||||
data-testid="task-dependency-chip"
|
||||
>
|
||||
<ChipTriggerContent data={data} />
|
||||
</button>
|
||||
<DrawerContent data-testid="task-dependency-chip-drawer" className="max-h-[70vh]">
|
||||
<DrawerHeader className="flex flex-row items-center justify-between border-b py-2">
|
||||
<DrawerTitle className="text-sm">{t("task:dependencies")}</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
{t("task:dependencyChipLabel")}
|
||||
</DrawerDescription>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
{t("common:close")}
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
<div className="overflow-y-auto p-2">
|
||||
<DependencyLists data={data} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("task:dependencyChipLabel")}
|
||||
className={cn(TRIGGER_BASE, toneClass)}
|
||||
data-testid="task-dependency-chip"
|
||||
>
|
||||
<ChipTriggerContent data={data} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 p-2" data-testid="task-dependency-chip-popover">
|
||||
<DependencyLists data={data} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/** Badge form used where a chip trigger is not appropriate (e.g. compact rows). */
|
||||
export function TaskDependencyBadge({ taskId }: { taskId: string | null }) {
|
||||
const data = useTaskDependencies(taskId);
|
||||
const { t } = useTranslation();
|
||||
if (!data?.blocked) return null;
|
||||
return (
|
||||
<Badge variant="outline" className="h-5 gap-1 text-xs">
|
||||
<IconLock className="h-3 w-3" />
|
||||
{t("task:dependencyCountBlockedBy", { count: data.dependsOn.length })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -176,6 +176,25 @@ type CreateTaskOpts = {
|
||||
workspace_mode?: "inherit_parent" | "new_workspace" | "shared_group";
|
||||
workspace_group_id?: string;
|
||||
attachments?: MessageAttachmentInput[];
|
||||
/** Task IDs this task must wait on. Suppresses the immediate agent launch. */
|
||||
blocked_by?: string[];
|
||||
/** Force the start-when-unblocked intent on or off; defaults from start_agent. */
|
||||
start_when_unblocked?: boolean;
|
||||
};
|
||||
|
||||
export type TaskDependencyRef = {
|
||||
id: string;
|
||||
title: string;
|
||||
state: string;
|
||||
/** Only present on `depends_on` entries. */
|
||||
status?: "resolved" | "failed" | "pending" | "missing";
|
||||
};
|
||||
|
||||
export type TaskDependencyProjection = {
|
||||
blocked?: boolean;
|
||||
blocked_reason?: "pending" | "failed" | "unknown";
|
||||
depends_on?: TaskDependencyRef[];
|
||||
blocks?: TaskDependencyRef[];
|
||||
};
|
||||
|
||||
type TaskRepositoryInput = {
|
||||
@@ -225,6 +244,10 @@ function buildCreateTaskBody(
|
||||
setIf(body, "parent_id", options.parent_id);
|
||||
setIf(body, "workspace_mode", options.workspace_mode);
|
||||
setIf(body, "workspace_group_id", options.workspace_group_id);
|
||||
setIf(body, "blocked_by", options.blocked_by);
|
||||
if (options.start_when_unblocked !== undefined) {
|
||||
body.start_when_unblocked = options.start_when_unblocked;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -248,6 +271,10 @@ type OptionalAgentTaskOpts = {
|
||||
workspace_mode?: "inherit_parent" | "new_workspace" | "shared_group";
|
||||
autopilot?: boolean;
|
||||
attachments?: MessageAttachmentInput[];
|
||||
/** Task IDs this task must wait on. Suppresses the immediate agent launch. */
|
||||
blocked_by?: string[];
|
||||
/** Force the start-when-unblocked intent on or off; defaults from start_agent. */
|
||||
start_when_unblocked?: boolean;
|
||||
};
|
||||
|
||||
/** `repositories` (with per-entry branches) takes precedence over the shorthand
|
||||
@@ -272,6 +299,10 @@ function buildOptionalAgentTaskFields(opts?: OptionalAgentTaskOpts): Record<stri
|
||||
setIf(fields, "workspace_mode", opts.workspace_mode);
|
||||
if (opts.autopilot) fields.autopilot = true;
|
||||
setIf(fields, "attachments", opts.attachments);
|
||||
setIf(fields, "blocked_by", opts.blocked_by);
|
||||
if (opts.start_when_unblocked !== undefined) {
|
||||
fields.start_when_unblocked = opts.start_when_unblocked;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
@@ -444,6 +475,10 @@ export class ApiClient {
|
||||
/** Existing group required when workspace_mode is shared_group. */
|
||||
workspace_group_id?: string;
|
||||
attachments?: MessageAttachmentInput[];
|
||||
/** Task IDs this task must wait on. Suppresses the immediate agent launch. */
|
||||
blocked_by?: string[];
|
||||
/** Force the start-when-unblocked intent on or off; defaults from start_agent. */
|
||||
start_when_unblocked?: boolean;
|
||||
},
|
||||
): Promise<CreateTaskResponse> {
|
||||
return this.request("POST", "/api/v1/tasks", buildCreateTaskBody(workspaceId, title, opts));
|
||||
@@ -654,6 +689,10 @@ export class ApiClient {
|
||||
/** Start the task with the immutable autopilot MCP/prompt contract. */
|
||||
autopilot?: boolean;
|
||||
attachments?: MessageAttachmentInput[];
|
||||
/** Task IDs this task must wait on. Suppresses the immediate agent launch. */
|
||||
blocked_by?: string[];
|
||||
/** Force the start-when-unblocked intent on or off; defaults from start_agent. */
|
||||
start_when_unblocked?: boolean;
|
||||
},
|
||||
): Promise<CreateTaskResponse> {
|
||||
return this.request("POST", "/api/v1/tasks", {
|
||||
@@ -1950,6 +1989,43 @@ export class ApiClient {
|
||||
return this.request("GET", `/api/v1/tasks/${taskId}/sessions`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a task's dependency projection. `blocked_reason` is `pending`,
|
||||
* `failed`, or `unknown`; the last one means the store could not be read and
|
||||
* the gate failed closed, so it must not be treated as unblocked.
|
||||
*/
|
||||
async getTaskDependencies(taskId: string): Promise<TaskDependencyProjection> {
|
||||
return this.request("GET", `/api/v1/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
/** Record "taskId is blocked by dependsOnTaskId". */
|
||||
async addTaskDependency(
|
||||
taskId: string,
|
||||
dependsOnTaskId: string,
|
||||
): Promise<TaskDependencyProjection> {
|
||||
return this.request("POST", `/api/v1/tasks/${taskId}/dependencies`, {
|
||||
depends_on_task_id: dependsOnTaskId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw add, for asserting the rejection path. A cycle answers 409 with a
|
||||
* `cycle` array; the typed helper above would throw the body away.
|
||||
*/
|
||||
async rawAddTaskDependency(taskId: string, dependsOnTaskId: string): Promise<Response> {
|
||||
return this.rawRequest("POST", `/api/v1/tasks/${taskId}/dependencies`, {
|
||||
depends_on_task_id: dependsOnTaskId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove an edge. Removing one that is not there is a success no-op. */
|
||||
async removeTaskDependency(
|
||||
taskId: string,
|
||||
dependsOnTaskId: string,
|
||||
): Promise<TaskDependencyProjection> {
|
||||
return this.request("DELETE", `/api/v1/tasks/${taskId}/dependencies/${dependsOnTaskId}`);
|
||||
}
|
||||
|
||||
async setPrimarySession(sessionId: string): Promise<void> {
|
||||
await this.wsRequest("session.set_primary", { session_id: sessionId });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
|
||||
// Task dependencies: a peer "B cannot start until A finishes" relationship,
|
||||
// separate from the parent/child subtask hierarchy. These specs cover the
|
||||
// guarantees that only hold end to end, where the create path, the event bus,
|
||||
// the auto-start chokepoint, and the derived projection all participate:
|
||||
//
|
||||
// - a blocked task is not launched by any automated path,
|
||||
// - only SUCCESS resolves a dependency, so a failed predecessor halts a chain,
|
||||
// - resolution launches the dependent exactly once and never restarts the
|
||||
// predecessor,
|
||||
// - a cycle is refused with the offending path,
|
||||
// - the projection reaches the browser, which is where it kept getting lost.
|
||||
//
|
||||
// Predecessors are seeded WITHOUT an agent and completed over the API. That is
|
||||
// deliberate: driving the terminal transition directly exercises the same
|
||||
// task.state_changed path a real agent turn ends on, without making the assertion
|
||||
// depend on mock-agent timing. The DEPENDENTS carry agent profiles, because
|
||||
// whether they launch is the thing under test.
|
||||
|
||||
/** Sessions a task owns. A blocked task must have none. */
|
||||
async function sessionCount(apiClient: ApiClient, taskId: string): Promise<number> {
|
||||
const { total } = await apiClient.listTaskSessions(taskId);
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Poll until a task owns at least one session, failing with a readable message. */
|
||||
async function expectStarted(apiClient: ApiClient, taskId: string, label: string): Promise<void> {
|
||||
await expect
|
||||
.poll(() => sessionCount(apiClient, taskId), {
|
||||
timeout: 30_000,
|
||||
message: `${label} should have been launched by dependency resolution`,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a task stays sessionless. Dependency resolution is asynchronous, so a
|
||||
* single read would pass before the launch it is meant to catch. Poll for a
|
||||
* fixed window and require the count to stay at zero throughout.
|
||||
*/
|
||||
async function expectStaysUnstarted(
|
||||
apiClient: ApiClient,
|
||||
taskId: string,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + 6_000;
|
||||
while (Date.now() < deadline) {
|
||||
expect(await sessionCount(apiClient, taskId), `${label} must not be started`).toBe(0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Task dependencies", () => {
|
||||
test("a chain runs one step at a time and never restarts a predecessor", async ({
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const a = await apiClient.createTask(seedData.workspaceId, "Chain A", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
const withAgent = {
|
||||
description: 'e2e:message("step done")',
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
};
|
||||
const b = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Chain B",
|
||||
seedData.agentProfileId,
|
||||
{ ...withAgent, blocked_by: [a.id] },
|
||||
);
|
||||
const c = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Chain C",
|
||||
seedData.agentProfileId,
|
||||
{ ...withAgent, blocked_by: [b.id] },
|
||||
);
|
||||
|
||||
// A create that declares dependencies records its start rather than firing
|
||||
// it, even though start_agent defaulted to true.
|
||||
expect(b.session_id, "B must not be launched on create").toBeFalsy();
|
||||
expect(c.session_id, "C must not be launched on create").toBeFalsy();
|
||||
expect(await sessionCount(apiClient, b.id)).toBe(0);
|
||||
expect(await sessionCount(apiClient, c.id)).toBe(0);
|
||||
|
||||
const blockedB = await apiClient.getTaskDependencies(b.id);
|
||||
expect(blockedB.blocked).toBe(true);
|
||||
expect(blockedB.blocked_reason).toBe("pending");
|
||||
expect(blockedB.depends_on?.map((d) => d.id)).toEqual([a.id]);
|
||||
// The reverse direction is derived from the same edge, not stored twice.
|
||||
expect((await apiClient.getTaskDependencies(a.id)).blocks?.map((d) => d.id)).toEqual([b.id]);
|
||||
|
||||
await apiClient.updateTaskState(a.id, "COMPLETED");
|
||||
|
||||
await expectStarted(apiClient, b.id, "B");
|
||||
// C is two hops out. Resolution must not walk the graph transitively.
|
||||
await expectStaysUnstarted(apiClient, c.id, "C while B is still running");
|
||||
|
||||
const bSessions = await sessionCount(apiClient, b.id);
|
||||
await apiClient.updateTaskState(b.id, "COMPLETED");
|
||||
|
||||
await expectStarted(apiClient, c.id, "C");
|
||||
// The predecessors are untouched by their dependents starting. A restarted
|
||||
// A is the failure this chain exists to prevent.
|
||||
expect(await sessionCount(apiClient, a.id), "A must not be restarted").toBe(0);
|
||||
expect(await sessionCount(apiClient, b.id), "B must launch exactly once").toBe(bSessions);
|
||||
});
|
||||
|
||||
test("a failed predecessor halts the chain until it succeeds", async ({
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const predecessor = await apiClient.createTask(seedData.workspaceId, "Halt A", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
const dependent = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Halt B",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: 'e2e:message("should only run after a success")',
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
blocked_by: [predecessor.id],
|
||||
},
|
||||
);
|
||||
|
||||
await apiClient.updateTaskState(predecessor.id, "FAILED");
|
||||
|
||||
// FAILED is terminal for the parent/child completion signal but NOT for a
|
||||
// dependency: the chain stops and waits for a human.
|
||||
await expectStaysUnstarted(apiClient, dependent.id, "the dependent of a failed predecessor");
|
||||
const blocked = await apiClient.getTaskDependencies(dependent.id);
|
||||
expect(blocked.blocked).toBe(true);
|
||||
expect(blocked.blocked_reason).toBe("failed");
|
||||
expect(blocked.depends_on?.[0]?.status).toBe("failed");
|
||||
|
||||
// Retrying the predecessor to success is one of the three ways out, and the
|
||||
// intent must survive the failed attempt rather than having been consumed.
|
||||
await apiClient.updateTaskState(predecessor.id, "COMPLETED");
|
||||
await expectStarted(apiClient, dependent.id, "the dependent after a retry");
|
||||
});
|
||||
|
||||
test("removing the last edge unblocks without launching", async ({ apiClient, seedData }) => {
|
||||
const predecessor = await apiClient.createTask(seedData.workspaceId, "Edge A", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
const dependent = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Edge B",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: 'e2e:message("unreachable")',
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
blocked_by: [predecessor.id],
|
||||
},
|
||||
);
|
||||
|
||||
const after = await apiClient.removeTaskDependency(dependent.id, predecessor.id);
|
||||
expect(after.blocked).toBe(false);
|
||||
expect(after.depends_on ?? []).toEqual([]);
|
||||
|
||||
// Unblocking by edge removal is not success, so the recorded intent must
|
||||
// not fire. Only a resolved predecessor starts a dependent.
|
||||
await expectStaysUnstarted(apiClient, dependent.id, "a dependent unblocked by edge removal");
|
||||
|
||||
// Removing an edge that is not there is a success no-op, not a 404.
|
||||
await expect(
|
||||
apiClient.removeTaskDependency(dependent.id, predecessor.id),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test("a cycle is refused with the offending path", async ({ apiClient, seedData }) => {
|
||||
const base = {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
};
|
||||
const a = await apiClient.createTask(seedData.workspaceId, "Cycle A", base);
|
||||
const b = await apiClient.createTask(seedData.workspaceId, "Cycle B", base);
|
||||
const c = await apiClient.createTask(seedData.workspaceId, "Cycle C", base);
|
||||
|
||||
await apiClient.addTaskDependency(b.id, a.id);
|
||||
await apiClient.addTaskDependency(c.id, b.id);
|
||||
|
||||
// A depending on C would close A -> C -> B -> A.
|
||||
const response = await apiClient.rawAddTaskDependency(a.id, c.id);
|
||||
expect(response.status).toBe(409);
|
||||
const body = (await response.json()) as { cycle?: string[] };
|
||||
expect(body.cycle, "the 409 must carry the path so the UI can render it").toEqual([
|
||||
a.id,
|
||||
c.id,
|
||||
b.id,
|
||||
a.id,
|
||||
]);
|
||||
|
||||
// The rejected edge must not have been written.
|
||||
expect((await apiClient.getTaskDependencies(a.id)).depends_on ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
test("the board and the open task both show the dependency", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const predecessor = await apiClient.createTask(seedData.workspaceId, "Visible A", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
const dependent = await apiClient.createTask(seedData.workspaceId, "Visible B", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
blocked_by: [predecessor.id],
|
||||
});
|
||||
|
||||
await testPage.goto("/");
|
||||
await expect(
|
||||
testPage.getByTestId(`task-card-${dependent.id}`).getByTestId("kanban-card-blocked-badge"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
testPage.getByTestId(`task-card-${predecessor.id}`).getByTestId("kanban-card-blocked-badge"),
|
||||
).toHaveCount(0);
|
||||
|
||||
// The chip has to survive the boot payload, the WS event stream, and the
|
||||
// store projection. Every regression in this feature so far has been a
|
||||
// dropped hop between those, so assert it from the rendered page.
|
||||
await testPage.goto(`/t/${dependent.id}`);
|
||||
const chip = testPage.getByTestId("task-dependency-chip");
|
||||
await expect(chip).toBeVisible();
|
||||
await chip.click();
|
||||
await expect(
|
||||
testPage.getByTestId("task-dependency-entry").filter({ hasText: "Visible A" }),
|
||||
).toBeVisible();
|
||||
|
||||
// A task with no edges in either direction renders no chip at all.
|
||||
const unrelated = await apiClient.createTask(seedData.workspaceId, "Visible C", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
await testPage.goto(`/t/${unrelated.id}`);
|
||||
await expect(testPage.getByTestId("task-dependency-chip")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -221,6 +221,9 @@ export const noLiteralStringOptions = {
|
||||
* the completeness check. See docs/i18n.md.
|
||||
*/
|
||||
export const i18nGuardFiles = [
|
||||
// Task dependencies (added with the feature, per the same-PR rule).
|
||||
"components/task/task-dependency-chip.tsx",
|
||||
"components/task-create-dialog-dependencies.tsx",
|
||||
// The i18n runtime itself.
|
||||
"lib/i18n/**/*.{ts,tsx}",
|
||||
// Settings → Preferences — Appearance, Notifications, Terminal & Editors and
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useMemo } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useUserDisplaySettings } from "@/hooks/use-user-display-settings";
|
||||
import { useTaskListingView } from "@/hooks/use-task-listing-view";
|
||||
import type { TaskListingView } from "@/lib/task-listing/view-preference";
|
||||
import { linkToTaskOverview } from "@/lib/links";
|
||||
import type { WorkflowsState } from "@/lib/state/slices";
|
||||
import { selectWorkflowSwimlanes } from "@/lib/kanban/workflow-swimlanes";
|
||||
@@ -31,7 +32,7 @@ function baseSettingsPayload(settings: UserSettingsFields): UserSettingsFields {
|
||||
};
|
||||
}
|
||||
|
||||
function taskListingViewFor(mode: string): "kanban" | "pipeline" | "list" {
|
||||
function taskListingViewFor(mode: string): TaskListingView {
|
||||
if (mode === "graph2" || mode === "pipeline") return "pipeline";
|
||||
if (mode === "list") return "list";
|
||||
return "kanban";
|
||||
|
||||
@@ -101,6 +101,13 @@ export async function createTask(
|
||||
delivery_mode?: "prompt" | "path";
|
||||
}>;
|
||||
parent_id?: string;
|
||||
/**
|
||||
* Task IDs this task depends on. With these set, an agent-start request is
|
||||
* recorded as a start-when-unblocked intent rather than launching now.
|
||||
*/
|
||||
blocked_by?: string[];
|
||||
/** Explicitly opt out of (false) or into (true) the auto-start-on-unblock intent. */
|
||||
start_when_unblocked?: boolean;
|
||||
workspace_path?: string;
|
||||
priority?: string;
|
||||
project_id?: string;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { fetchJson, type ApiRequestOptions } from "@/lib/api/client";
|
||||
|
||||
const BASE = "/api/v1";
|
||||
|
||||
export type TaskDependencyRefResponse = {
|
||||
id: string;
|
||||
title?: string;
|
||||
state?: string;
|
||||
status?: "resolved" | "failed" | "pending";
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads one task's dependency projection.
|
||||
*
|
||||
* The chip prefers the board store, but a task.updated event can land before or
|
||||
* after hydration carrying no dependency fields, which leaves the store copy
|
||||
* without them. Rather than depend on WS/hydration ordering for a field the
|
||||
* server derives per read, fall back to asking for it — the same
|
||||
* fetch-your-own-data shape `PRStatusChip` already uses via `useTaskPR`.
|
||||
*/
|
||||
export function getTaskDependencies(taskId: string, options?: ApiRequestOptions) {
|
||||
return fetchJson<{
|
||||
id: string;
|
||||
blocked?: boolean;
|
||||
blocked_reason?: string;
|
||||
depends_on?: TaskDependencyRefResponse[];
|
||||
blocks?: TaskDependencyRefResponse[];
|
||||
}>(`${BASE}/tasks/${taskId}`, options);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toKanbanTask } from "./map-task";
|
||||
|
||||
describe("toKanbanTask dependency projection", () => {
|
||||
it("maps both directions with per-entry detail", () => {
|
||||
const task = toKanbanTask({
|
||||
id: "b",
|
||||
workflow_step_id: "step-1",
|
||||
title: "B",
|
||||
blocked: true,
|
||||
blocked_reason: "pending",
|
||||
depends_on: [{ id: "a", title: "A", state: "TODO", status: "pending" }],
|
||||
blocks: [{ id: "c", title: "C", state: "TODO" }],
|
||||
start_when_unblocked: true,
|
||||
});
|
||||
expect(task.blocked).toBe(true);
|
||||
expect(task.blockedReason).toBe("pending");
|
||||
expect(task.dependsOn).toEqual([{ id: "a", title: "A", state: "TODO", status: "pending" }]);
|
||||
expect(task.blocks).toEqual([{ id: "c", title: "C", state: "TODO" }]);
|
||||
expect(task.startWhenUnblocked).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the projection undefined when the payload mentions no dependency key", () => {
|
||||
// Lightweight task.updated events omit these entirely. Inventing empty lists
|
||||
// here erased real edges: such an event can insert the task into the board
|
||||
// store before boot hydration, and hydration then keeps the "fresher" WS copy.
|
||||
const task = toKanbanTask({ id: "a", workflow_step_id: "step-1", title: "A" });
|
||||
expect(task.blocked).toBeUndefined();
|
||||
expect(task.dependsOn).toBeUndefined();
|
||||
expect(task.blocks).toBeUndefined();
|
||||
expect(task.startWhenUnblocked).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats an explicit empty list as a real 'no edges'", () => {
|
||||
const task = toKanbanTask({
|
||||
id: "a",
|
||||
workflow_step_id: "step-1",
|
||||
title: "A",
|
||||
blocked: false,
|
||||
depends_on: [],
|
||||
blocks: [],
|
||||
});
|
||||
expect(task.blocked).toBe(false);
|
||||
expect(task.dependsOn).toEqual([]);
|
||||
expect(task.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears blocked state when the payload reports it resolved", () => {
|
||||
const task = toKanbanTask({
|
||||
id: "b",
|
||||
workflow_step_id: "step-1",
|
||||
title: "B",
|
||||
blocked: false,
|
||||
depends_on: [{ id: "a", title: "A", status: "resolved" }],
|
||||
});
|
||||
expect(task.blocked).toBe(false);
|
||||
expect(task.blockedReason).toBeUndefined();
|
||||
// The edge survives resolution — it is history, not a live gate.
|
||||
expect(task.dependsOn).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a failed reason distinguishable from pending", () => {
|
||||
const task = toKanbanTask({
|
||||
id: "b",
|
||||
workflow_step_id: "step-1",
|
||||
title: "B",
|
||||
blocked: true,
|
||||
blocked_reason: "failed",
|
||||
depends_on: [{ id: "a", title: "A", status: "failed" }],
|
||||
});
|
||||
expect(task.blockedReason).toBe("failed");
|
||||
expect(task.dependsOn?.[0].status).toBe("failed");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
isIssueWatchFromMetadata,
|
||||
issueFieldsFromMetadata,
|
||||
} from "@/lib/metadata-utils";
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
import type { KanbanState, TaskDependencyRef } from "@/lib/state/slices/kanban/types";
|
||||
import type {
|
||||
ForegroundActivity,
|
||||
TaskPendingAction,
|
||||
@@ -68,6 +68,11 @@ export type TaskLike = {
|
||||
wip_admitted?: boolean;
|
||||
queued_for_step_id?: string | null;
|
||||
queued_at?: string | null;
|
||||
blocked?: boolean;
|
||||
blocked_reason?: string | null;
|
||||
depends_on?: TaskDependencyRef[] | null;
|
||||
blocks?: TaskDependencyRef[] | null;
|
||||
start_when_unblocked?: boolean;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
archived_at?: string | null;
|
||||
status_summary?: TaskStatusSummary | null;
|
||||
@@ -132,6 +137,37 @@ function pickWorkspaceFolders(source: TaskLike): KanbanTask["workspaceFolders"]
|
||||
* leave them out of sync again (cf. sidebar filter regressions where the HTTP
|
||||
* snapshot derived `isPRReview` but the WS handler didn't).
|
||||
*/
|
||||
/**
|
||||
* dependencyProjection normalizes the derived dependency fields.
|
||||
*
|
||||
* A payload that carries NO dependency key at all leaves them undefined rather
|
||||
* than defaulting to "no edges". Most `task.updated` publishers are lightweight
|
||||
* and omit them, and inventing empty arrays here is destructive twice over: the
|
||||
* event can insert the task into the board store before boot hydration runs, and
|
||||
* hydration then keeps the "fresher" WS copy — permanently erasing the edges and
|
||||
* blanking the dependency chip. When the keys ARE present (every boot payload and
|
||||
* list read computes them), an empty list is a real "no edges".
|
||||
*/
|
||||
function dependencyProjection(
|
||||
source: TaskLike,
|
||||
): Partial<
|
||||
Pick<KanbanTask, "blocked" | "blockedReason" | "dependsOn" | "blocks" | "startWhenUnblocked">
|
||||
> {
|
||||
const mentionsDependencies =
|
||||
source.blocked !== undefined ||
|
||||
source.depends_on !== undefined ||
|
||||
source.blocks !== undefined ||
|
||||
source.start_when_unblocked !== undefined;
|
||||
if (!mentionsDependencies) return {};
|
||||
return {
|
||||
blocked: source.blocked ?? false,
|
||||
blockedReason: source.blocked_reason ?? undefined,
|
||||
dependsOn: source.depends_on ?? [],
|
||||
blocks: source.blocks ?? [],
|
||||
startWhenUnblocked: source.start_when_unblocked ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function toKanbanTask(source: TaskLike): KanbanTask {
|
||||
return {
|
||||
id: pickId(source),
|
||||
@@ -166,6 +202,7 @@ export function toKanbanTask(source: TaskLike): KanbanTask {
|
||||
wipAdmitted: source.wip_admitted,
|
||||
queuedForStepId: source.queued_for_step_id,
|
||||
queuedAt: source.queued_at,
|
||||
...dependencyProjection(source),
|
||||
statusSummary: source.status_summary,
|
||||
metadata: source.metadata,
|
||||
isArchived: source.archived_at != null,
|
||||
|
||||
@@ -46,14 +46,47 @@ function mergeKanbanTasks(
|
||||
} else {
|
||||
const existingTime = existing.updatedAt ? new Date(existing.updatedAt).getTime() : 0;
|
||||
const incomingTime = incoming.updatedAt ? new Date(incoming.updatedAt).getTime() : 0;
|
||||
const idx = draftTasks.findIndex((t) => t.id === incoming.id);
|
||||
if (incomingTime >= existingTime) {
|
||||
const idx = draftTasks.findIndex((t) => t.id === incoming.id);
|
||||
if (idx >= 0) draftTasks[idx] = incoming;
|
||||
} else if (idx >= 0) {
|
||||
backfillServerDerivedFields(draftTasks[idx], incoming);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields the server derives per read and lightweight WS events omit.
|
||||
*
|
||||
* A task.updated that arrives before hydration inserts a copy with a newer
|
||||
* `updatedAt` and no dependency projection, so the timestamp rule above would
|
||||
* keep that copy and the boot payload's edges would be lost for the rest of the
|
||||
* session — which is exactly how the dependency chip silently vanished on any
|
||||
* task whose agent had produced activity. Backfilling only fills gaps, so a real
|
||||
* WS-side value (including an explicit "no edges") still wins.
|
||||
*/
|
||||
const SERVER_DERIVED_TASK_FIELDS = [
|
||||
"blocked",
|
||||
"blockedReason",
|
||||
"dependsOn",
|
||||
"blocks",
|
||||
"startWhenUnblocked",
|
||||
] as const;
|
||||
|
||||
function backfillServerDerivedFields(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
target: any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
source: any,
|
||||
): void {
|
||||
for (const field of SERVER_DERIVED_TASK_FIELDS) {
|
||||
if (target[field] === undefined && source[field] !== undefined) {
|
||||
target[field] = source[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Hydrate kanban and workspace slices. */
|
||||
function hydrateKanbanAndWorkspace(draft: Draft<AppState>, state: HydrationState): void {
|
||||
if (state.kanban) {
|
||||
|
||||
@@ -19,6 +19,21 @@ export type KanbanStepEvents = {
|
||||
on_agent_error?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* One end of a dependency edge. Carries title and state so the dependency chip
|
||||
* and blocked badge render without fetching each related task.
|
||||
*
|
||||
* `status` is the resolution verdict and is only meaningful on `dependsOn`
|
||||
* entries: "resolved" (finished successfully), "failed" (FAILED/CANCELLED —
|
||||
* halts the chain), or "pending" (anything else, including archived).
|
||||
*/
|
||||
export type TaskDependencyRef = {
|
||||
id: string;
|
||||
title?: string;
|
||||
state?: TaskStatus;
|
||||
status?: "resolved" | "failed" | "pending";
|
||||
};
|
||||
|
||||
export type KanbanState = {
|
||||
workflowId: string | null;
|
||||
steps: Array<{
|
||||
@@ -104,6 +119,19 @@ export type KanbanState = {
|
||||
wipAdmitted?: boolean;
|
||||
queuedForStepId?: string;
|
||||
queuedAt?: string;
|
||||
/**
|
||||
* Task dependencies. Derived on the backend per read (never stored), so
|
||||
* these arrive fresh on every boot payload and task.updated event.
|
||||
*/
|
||||
blocked?: boolean;
|
||||
/** "pending" | "failed" | "unknown"; absent when not blocked. */
|
||||
blockedReason?: string;
|
||||
/** Direct predecessors — the tasks this one is waiting on. Not transitive. */
|
||||
dependsOn?: TaskDependencyRef[];
|
||||
/** Direct dependents — the tasks waiting on this one. Not transitive. */
|
||||
blocks?: TaskDependencyRef[];
|
||||
/** A launch intent is waiting on dependency resolution. */
|
||||
startWhenUnblocked?: boolean;
|
||||
isPRReview?: boolean;
|
||||
isIssueWatch?: boolean;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
|
||||
@@ -18,6 +18,50 @@ function queueFields(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependency fields are derived server-side and re-sent whole on every
|
||||
* task.updated, so an absent list means "no edges", not "unchanged" — falling
|
||||
* back to the existing value would leave a stale badge after the last edge is
|
||||
* removed. Only fall back when the payload omits the field entirely (a partial
|
||||
* update that never touched dependencies).
|
||||
*/
|
||||
const DEPENDENCY_PAYLOAD_KEYS = [
|
||||
"blocked",
|
||||
"blocked_reason",
|
||||
"blockedReason",
|
||||
"depends_on",
|
||||
"dependsOn",
|
||||
"blocks",
|
||||
"start_when_unblocked",
|
||||
"startWhenUnblocked",
|
||||
] as const;
|
||||
|
||||
function touchesDependencies(task: KanbanUpdateTask): boolean {
|
||||
return DEPENDENCY_PAYLOAD_KEYS.some((key) => key in task);
|
||||
}
|
||||
|
||||
function dependencyFields(
|
||||
task: KanbanUpdateTask,
|
||||
existing: KanbanTask | undefined,
|
||||
): Pick<KanbanTask, "blocked" | "blockedReason" | "dependsOn" | "blocks" | "startWhenUnblocked"> {
|
||||
if (!touchesDependencies(task)) {
|
||||
return {
|
||||
blocked: existing?.blocked,
|
||||
blockedReason: existing?.blockedReason,
|
||||
dependsOn: existing?.dependsOn,
|
||||
blocks: existing?.blocks,
|
||||
startWhenUnblocked: existing?.startWhenUnblocked,
|
||||
};
|
||||
}
|
||||
return {
|
||||
blocked: task.blocked ?? false,
|
||||
blockedReason: task.blocked_reason ?? task.blockedReason,
|
||||
dependsOn: task.depends_on ?? task.dependsOn ?? [],
|
||||
blocks: task.blocks ?? [],
|
||||
startWhenUnblocked: task.start_when_unblocked ?? task.startWhenUnblocked ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
type KanbanUpdateTask = {
|
||||
id: string;
|
||||
workflowStepId: string;
|
||||
@@ -34,6 +78,14 @@ type KanbanUpdateTask = {
|
||||
wipAdmitted?: boolean;
|
||||
queuedForStepId?: string;
|
||||
queuedAt?: string;
|
||||
blocked?: boolean;
|
||||
blocked_reason?: string;
|
||||
blockedReason?: string;
|
||||
depends_on?: KanbanTask["dependsOn"];
|
||||
dependsOn?: KanbanTask["dependsOn"];
|
||||
blocks?: KanbanTask["blocks"];
|
||||
start_when_unblocked?: boolean;
|
||||
startWhenUnblocked?: boolean;
|
||||
};
|
||||
|
||||
export function registerKanbanHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
@@ -83,6 +135,7 @@ export function registerKanbanHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
interrupted: existing?.interrupted,
|
||||
foregroundActivity: existing?.foregroundActivity,
|
||||
...queueFields(task, existing),
|
||||
...dependencyFields(task, existing),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { preserveOmittedDependencyFields } from "./task-dependencies";
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
|
||||
type KanbanTask = KanbanState["tasks"][number];
|
||||
|
||||
const existing = (): KanbanTask =>
|
||||
({
|
||||
id: "b",
|
||||
workflowId: "wf-1",
|
||||
workflowStepId: "s",
|
||||
title: "B",
|
||||
position: 0,
|
||||
blocked: true,
|
||||
blockedReason: "pending",
|
||||
dependsOn: [{ id: "a", title: "A", status: "pending" }],
|
||||
blocks: [{ id: "c", title: "C" }],
|
||||
startWhenUnblocked: true,
|
||||
}) as KanbanTask;
|
||||
|
||||
/** What toKanbanTask produces for an event with no dependency fields. */
|
||||
const mergedFromBareEvent = (): KanbanTask =>
|
||||
({
|
||||
id: "b",
|
||||
workflowId: "wf-1",
|
||||
workflowStepId: "s",
|
||||
title: "B",
|
||||
position: 0,
|
||||
blocked: false,
|
||||
dependsOn: [],
|
||||
blocks: [],
|
||||
startWhenUnblocked: false,
|
||||
}) as KanbanTask;
|
||||
|
||||
describe("preserveOmittedDependencyFields", () => {
|
||||
it("restores edges when the event does not mention dependencies", () => {
|
||||
// The regression this exists for: a task.updated fired by ordinary agent
|
||||
// activity carries no dependency fields, so without this the hydrated edges
|
||||
// were replaced by empty arrays and the dependency chip disappeared.
|
||||
const merged = mergedFromBareEvent();
|
||||
preserveOmittedDependencyFields(existing(), merged, { task_id: "b", title: "B" });
|
||||
expect(merged.blocked).toBe(true);
|
||||
expect(merged.blockedReason).toBe("pending");
|
||||
expect(merged.dependsOn).toHaveLength(1);
|
||||
expect(merged.blocks).toHaveLength(1);
|
||||
expect(merged.startWhenUnblocked).toBe(true);
|
||||
});
|
||||
|
||||
it("lets an explicit empty list clear the edges", () => {
|
||||
// Removing the last edge publishes a payload that DOES mention dependencies
|
||||
// with an empty list; that is a real clear and must not be reverted.
|
||||
const merged = mergedFromBareEvent();
|
||||
preserveOmittedDependencyFields(existing(), merged, { depends_on: [], blocks: [] });
|
||||
expect(merged.blocked).toBe(false);
|
||||
expect(merged.dependsOn).toEqual([]);
|
||||
expect(merged.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets an explicit blocked flag through even without the lists", () => {
|
||||
const merged = mergedFromBareEvent();
|
||||
preserveOmittedDependencyFields(existing(), merged, { blocked: false });
|
||||
expect(merged.blocked).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
|
||||
type KanbanTask = KanbanState["tasks"][number];
|
||||
|
||||
/**
|
||||
* Dependency edges must survive a task.updated that does not mention them.
|
||||
*
|
||||
* `toKanbanTask` defaults the projection to "no edges", which is right for a
|
||||
* boot payload (the backend always computes it there) but wrong for a
|
||||
* lightweight event: most task.updated publishers carry no dependency fields, so
|
||||
* defaulting would erase the hydrated edges and make the dependency chip vanish
|
||||
* as soon as the task's agent produced any activity.
|
||||
*/
|
||||
export function preserveOmittedDependencyFields(
|
||||
existing: KanbanTask,
|
||||
merged: KanbanTask,
|
||||
payload: Record<string, unknown>,
|
||||
): void {
|
||||
// "Mentions dependencies" means the key is present at all, even as null —
|
||||
// an explicit empty list is a real clear and must win.
|
||||
const mentions = "depends_on" in payload || "blocks" in payload || "blocked" in payload;
|
||||
if (mentions) return;
|
||||
merged.blocked = existing.blocked;
|
||||
merged.blockedReason = existing.blockedReason;
|
||||
merged.dependsOn = existing.dependsOn;
|
||||
merged.blocks = existing.blocks;
|
||||
merged.startWhenUnblocked = existing.startWhenUnblocked;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { cleanupTaskStorage } from "@/lib/local-storage";
|
||||
import { removeRecentTask } from "@/lib/recent-tasks";
|
||||
import { useContextFilesStore } from "@/lib/state/context-files-store";
|
||||
import { toKanbanTask } from "@/lib/kanban/map-task";
|
||||
import { preserveOmittedDependencyFields } from "@/lib/ws/handlers/task-dependencies";
|
||||
import { isNewerStatusSummary } from "@/lib/task-status-summary";
|
||||
import { sessionId as toSessionId } from "@/lib/types/http";
|
||||
import { mergeTaskRepositoryFields } from "@/lib/ws/handlers/task-repositories";
|
||||
@@ -132,6 +133,7 @@ function mergeTaskUpdate(
|
||||
payloadKey: "status_summary",
|
||||
taskField: "statusSummary",
|
||||
});
|
||||
preserveOmittedDependencyFields(existing, merged, payload);
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
"archiveCount_other": "Archive {{count}}",
|
||||
"archiveTask": "Archive task",
|
||||
"autoStart": "Auto-start",
|
||||
"blockedByCount_one": "Blocked by {{count}}",
|
||||
"blockedByCount_other": "Blocked by {{count}}",
|
||||
"blockedByTasksTitle": "Waiting on: {{tasks}}",
|
||||
"blockedFailed": "Blocker failed",
|
||||
"blockedPredecessorFailed": "A task this one depends on failed: {{tasks}}",
|
||||
"boardNavigator": "Board navigator",
|
||||
"cannotMoveTasksFromDifferentWorkflows": "Cannot move tasks from different workflows",
|
||||
"changesRequested": "Changes Requested",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"addApprover": "+ Add approver",
|
||||
"addAtLeastOneSource": "Add at least one source.",
|
||||
"addBlocker": "+ Add blocker",
|
||||
"addDependency": "Add dependency…",
|
||||
"addDetailsAboutThisChange": "Add details about this change...",
|
||||
"addFolder": "Add folder",
|
||||
"addLabel": "Add label",
|
||||
@@ -330,6 +331,17 @@
|
||||
"deleting": "Deleting...",
|
||||
"deliverTheseCommentsToTheCli": "Deliver these comments to the CLI agent's stdin as a single message; no need to open the chat box. The agent receives the same markdown that prepending to a typed message would produce.",
|
||||
"deny": "Deny",
|
||||
"dependencies": "Dependencies",
|
||||
"dependencyBlockedBy": "Blocked by",
|
||||
"dependencyBlocks": "Blocks",
|
||||
"dependencyChainHalted": "A task this one depends on failed. The chain is stopped until you retry it or remove the dependency.",
|
||||
"dependencyChipLabel": "Task dependencies",
|
||||
"dependencyCountBlockedBy_one": "{{count}} blocker",
|
||||
"dependencyCountBlockedBy_other": "{{count}} blockers",
|
||||
"dependencyCountBlocks_one": "blocks {{count}}",
|
||||
"dependencyCountBlocks_other": "blocks {{count}}",
|
||||
"dependsOn": "Depends on",
|
||||
"dependsOnWillAutoStart": "This task waits for the tasks above. If you start an agent, it runs automatically once every one of them completes successfully.",
|
||||
"describeWhatNeedsToChange": "Describe what needs to change",
|
||||
"describeWhatYouWantTheAgent": "Describe what you want the agent to do... (@ to insert a saved prompt)",
|
||||
"describeYourChanges": "Describe your changes...",
|
||||
@@ -362,11 +374,11 @@
|
||||
"documentCount_other": "{{count}} documents",
|
||||
"documentDeleted": "Document deleted",
|
||||
"documentSaved": "Document saved",
|
||||
"documents": "Documents",
|
||||
"documentsAvailable": "Documents available",
|
||||
"documentSummaryEmpty": "empty",
|
||||
"documentSummary_one": "{{count}} line · {{chars}} chars",
|
||||
"documentSummary_other": "{{count}} lines · {{chars}} chars",
|
||||
"documentSummaryEmpty": "empty",
|
||||
"documents": "Documents",
|
||||
"documentsAvailable": "Documents available",
|
||||
"done": "Done",
|
||||
"doubleClickToEditOrPress": "Double-click to edit (or press Enter)",
|
||||
"doubleClickToKeepThisTab": "Double-click to keep this tab open",
|
||||
@@ -577,7 +589,6 @@
|
||||
"hideSidebar": "Hide sidebar",
|
||||
"hideTheCommentListTheComments": "Hide the comment list. The comments stay queued and will still be sent next time you submit (here or from the chat box).",
|
||||
"host": "Host",
|
||||
"isolatedWorktreeAt": "Isolated worktree at <1>{{path}}</1>",
|
||||
"iUnderstandAnyUncommittedChangesWill": "I understand any uncommitted changes will be lost.",
|
||||
"image": "Image",
|
||||
"imagePreview": "Image preview",
|
||||
@@ -592,6 +603,7 @@
|
||||
"inspectingClickToPin": "Inspecting - click to pin, drag to select an area, Esc to exit",
|
||||
"invalidName": "Invalid name",
|
||||
"isTheMaximumFileSizeIs": "{{name}} is {{size}}. The maximum file size is {{maxSize}}.",
|
||||
"isolatedWorktreeAt": "Isolated worktree at <1>{{path}}</1>",
|
||||
"issue": "Issue",
|
||||
"jiraTicket": "Jira Ticket",
|
||||
"jiraTicketLinked": "Jira ticket linked",
|
||||
@@ -781,6 +793,7 @@
|
||||
"noCompatibleAgentProfilesFor": "No compatible agent profiles for {{target}}.",
|
||||
"noContent": "No content",
|
||||
"noDecisionYet": "No decision yet",
|
||||
"noDependencies": "No dependencies",
|
||||
"noDocumentsFound": "No documents found.",
|
||||
"noDocumentsYet": "No documents yet.",
|
||||
"noEditorsAvailable": "No editors available",
|
||||
@@ -1015,6 +1028,7 @@
|
||||
"remove3": "Remove {{identifier}}",
|
||||
"removeAnnotation": "Remove annotation {{number}}",
|
||||
"removeComment": "Remove comment",
|
||||
"removeDependency": "Remove dependency",
|
||||
"removeFile": "Remove file",
|
||||
"removeFilter": "Remove filter",
|
||||
"removeFromFavorites": "Remove from favorites",
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
"archiveCount_other": "Àŕćĥĩvē {{count}}",
|
||||
"archiveTask": "Àŕćĥĩvē ţàśķ",
|
||||
"autoStart": "Àũţō-śţàŕţ",
|
||||
"blockedByCount_one": "Ɓĺōćķēď ƀŷ {{count}}",
|
||||
"blockedByCount_other": "Ɓĺōćķēď ƀŷ {{count}}",
|
||||
"blockedByTasksTitle": "Ŵàĩţĩńĝ ōń: {{tasks}}",
|
||||
"blockedFailed": "Ɓĺōćķēŕ ƒàĩĺēď",
|
||||
"blockedPredecessorFailed": "À ţàśķ ţĥĩś ōńē ďēƥēńďś ōń ƒàĩĺēď: {{tasks}}",
|
||||
"boardNavigator": "Ɓōàŕď ńàvĩĝàţōŕ",
|
||||
"cannotMoveTasksFromDifferentWorkflows": "Ćàńńōţ ḿōvē ţàśķś ƒŕōḿ ďĩƒƒēŕēńţ ŵōŕķƒĺōŵś",
|
||||
"changesRequested": "Ćĥàńĝēś Ŕēqũēśţēď",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"addApprover": "+ Àďď àƥƥŕōvēŕ",
|
||||
"addAtLeastOneSource": "Àďď àţ ĺēàśţ ōńē śōũŕćē.",
|
||||
"addBlocker": "+ Àďď ƀĺōćķēŕ",
|
||||
"addDependency": "Àďď ďēƥēńďēńćŷ…",
|
||||
"addDetailsAboutThisChange": "Àďď ďēţàĩĺś àƀōũţ ţĥĩś ćĥàńĝē...",
|
||||
"addFolder": "Àďď ƒōĺďēŕ",
|
||||
"addLabel": "Àďď ĺàƀēĺ",
|
||||
@@ -330,6 +331,17 @@
|
||||
"deleting": "Ďēĺēţĩńĝ...",
|
||||
"deliverTheseCommentsToTheCli": "Ďēĺĩvēŕ ţĥēśē ćōḿḿēńţś ţō ţĥē ĆĹĨ àĝēńţ'ś śţďĩń àś à śĩńĝĺē ḿēśśàĝē; ńō ńēēď ţō ōƥēń ţĥē ćĥàţ ƀōx. Ţĥē àĝēńţ ŕēćēĩvēś ţĥē śàḿē ḿàŕķďōŵń ţĥàţ ƥŕēƥēńďĩńĝ ţō à ţŷƥēď ḿēśśàĝē ŵōũĺď ƥŕōďũćē.",
|
||||
"deny": "Ďēńŷ",
|
||||
"dependencies": "Ďēƥēńďēńćĩēś",
|
||||
"dependencyBlockedBy": "Ɓĺōćķēď ƀŷ",
|
||||
"dependencyBlocks": "Ɓĺōćķś",
|
||||
"dependencyChainHalted": "À ţàśķ ţĥĩś ōńē ďēƥēńďś ōń ƒàĩĺēď. Ţĥē ćĥàĩń ĩś śţōƥƥēď ũńţĩĺ ŷōũ ŕēţŕŷ ĩţ ōŕ ŕēḿōvē ţĥē ďēƥēńďēńćŷ.",
|
||||
"dependencyChipLabel": "Ţàśķ ďēƥēńďēńćĩēś",
|
||||
"dependencyCountBlockedBy_one": "{{count}} ƀĺōćķēŕ",
|
||||
"dependencyCountBlockedBy_other": "{{count}} ƀĺōćķēŕś",
|
||||
"dependencyCountBlocks_one": "ƀĺōćķś {{count}}",
|
||||
"dependencyCountBlocks_other": "ƀĺōćķś {{count}}",
|
||||
"dependsOn": "Ďēƥēńďś ōń",
|
||||
"dependsOnWillAutoStart": "Ţĥĩś ţàśķ ŵàĩţś ƒōŕ ţĥē ţàśķś àƀōvē. Ĩƒ ŷōũ śţàŕţ àń àĝēńţ, ĩţ ŕũńś àũţōḿàţĩćàĺĺŷ ōńćē ēvēŕŷ ōńē ōƒ ţĥēḿ ćōḿƥĺēţēś śũććēśśƒũĺĺŷ.",
|
||||
"describeWhatNeedsToChange": "Ďēśćŕĩƀē ŵĥàţ ńēēďś ţō ćĥàńĝē",
|
||||
"describeWhatYouWantTheAgent": "Ďēśćŕĩƀē ŵĥàţ ŷōũ ŵàńţ ţĥē àĝēńţ ţō ďō... (@ ţō ĩńśēŕţ à śàvēď ƥŕōḿƥţ)",
|
||||
"describeYourChanges": "Ďēśćŕĩƀē ŷōũŕ ćĥàńĝēś...",
|
||||
@@ -362,11 +374,11 @@
|
||||
"documentCount_other": "{{count}} ďōćũḿēńţś",
|
||||
"documentDeleted": "Ďōćũḿēńţ ďēĺēţēď",
|
||||
"documentSaved": "Ďōćũḿēńţ śàvēď",
|
||||
"documents": "Ďōćũḿēńţś",
|
||||
"documentsAvailable": "Ďōćũḿēńţś àvàĩĺàƀĺē",
|
||||
"documentSummaryEmpty": "ēḿƥţŷ",
|
||||
"documentSummary_one": "{{count}} ĺĩńē · {{chars}} ćĥàŕś",
|
||||
"documentSummary_other": "{{count}} ĺĩńēś · {{chars}} ćĥàŕś",
|
||||
"documentSummaryEmpty": "ēḿƥţŷ",
|
||||
"documents": "Ďōćũḿēńţś",
|
||||
"documentsAvailable": "Ďōćũḿēńţś àvàĩĺàƀĺē",
|
||||
"done": "Ďōńē",
|
||||
"doubleClickToEditOrPress": "Ďōũƀĺē-ćĺĩćķ ţō ēďĩţ (ōŕ ƥŕēśś Ēńţēŕ)",
|
||||
"doubleClickToKeepThisTab": "Ďōũƀĺē-ćĺĩćķ ţō ķēēƥ ţĥĩś ţàƀ ōƥēń",
|
||||
@@ -577,7 +589,6 @@
|
||||
"hideSidebar": "Ĥĩďē śĩďēƀàŕ",
|
||||
"hideTheCommentListTheComments": "Ĥĩďē ţĥē ćōḿḿēńţ ĺĩśţ. Ţĥē ćōḿḿēńţś śţàŷ qũēũēď àńď ŵĩĺĺ śţĩĺĺ ƀē śēńţ ńēxţ ţĩḿē ŷōũ śũƀḿĩţ (ĥēŕē ōŕ ƒŕōḿ ţĥē ćĥàţ ƀōx).",
|
||||
"host": "Ĥōśţ",
|
||||
"isolatedWorktreeAt": "Ĩśōĺàţēď ŵōŕķţŕēē àţ <1>{{path}}</1>",
|
||||
"iUnderstandAnyUncommittedChangesWill": "Ĩ ũńďēŕśţàńď àńŷ ũńćōḿḿĩţţēď ćĥàńĝēś ŵĩĺĺ ƀē ĺōśţ.",
|
||||
"image": "Ĩḿàĝē",
|
||||
"imagePreview": "Ĩḿàĝē ƥŕēvĩēŵ",
|
||||
@@ -592,6 +603,7 @@
|
||||
"inspectingClickToPin": "Ĩńśƥēćţĩńĝ - ćĺĩćķ ţō ƥĩń, ďŕàĝ ţō śēĺēćţ àń àŕēà, Ēść ţō ēxĩţ",
|
||||
"invalidName": "Ĩńvàĺĩď ńàḿē",
|
||||
"isTheMaximumFileSizeIs": "{{name}} ĩś {{size}}. Ţĥē ḿàxĩḿũḿ ƒĩĺē śĩźē ĩś {{maxSize}}.",
|
||||
"isolatedWorktreeAt": "Ĩśōĺàţēď ŵōŕķţŕēē àţ <1>{{path}}</1>",
|
||||
"issue": "Ĩśśũē",
|
||||
"jiraTicket": "Ĵĩŕà Ţĩćķēţ",
|
||||
"jiraTicketLinked": "Ĵĩŕà ţĩćķēţ ĺĩńķēď",
|
||||
@@ -781,6 +793,7 @@
|
||||
"noCompatibleAgentProfilesFor": "Ńō ćōḿƥàţĩƀĺē àĝēńţ ƥŕōƒĩĺēś ƒōŕ {{target}}.",
|
||||
"noContent": "Ńō ćōńţēńţ",
|
||||
"noDecisionYet": "Ńō ďēćĩśĩōń ŷēţ",
|
||||
"noDependencies": "Ńō ďēƥēńďēńćĩēś",
|
||||
"noDocumentsFound": "Ńō ďōćũḿēńţś ƒōũńď.",
|
||||
"noDocumentsYet": "Ńō ďōćũḿēńţś ŷēţ.",
|
||||
"noEditorsAvailable": "Ńō ēďĩţōŕś àvàĩĺàƀĺē",
|
||||
@@ -1015,6 +1028,7 @@
|
||||
"remove3": "Ŕēḿōvē {{identifier}}",
|
||||
"removeAnnotation": "Ŕēḿōvē àńńōţàţĩōń {{number}}",
|
||||
"removeComment": "Ŕēḿōvē ćōḿḿēńţ",
|
||||
"removeDependency": "Ŕēḿōvē ďēƥēńďēńćŷ",
|
||||
"removeFile": "Ŕēḿōvē ƒĩĺē",
|
||||
"removeFilter": "Ŕēḿōvē ƒĩĺţēŕ",
|
||||
"removeFromFavorites": "Ŕēḿōvē ƒŕōḿ ƒàvōŕĩţēś",
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ Agents running on regular kanban tasks receive a task-scoped MCP surface. The av
|
||||
- **Work with multi-repo tasks:** task responses include attached repositories, base branches, checkout branches, and positions so agents can reason about each worktree separately.
|
||||
- **Create tasks and subtasks:** create top-level tasks or child tasks, optionally auto-starting an agent. Subtasks inherit the parent workspace, workflow, agent profile, executor, and repositories unless the agent supplies an override.
|
||||
- **Target sibling repositories:** create a subtask in a different repository while keeping it under the same parent task and workspace.
|
||||
- **Coordinate dependencies:** create tasks with blocker relationships and inspect related parent, child, sibling, blocker, and blocked-by tasks.
|
||||
- **Coordinate dependencies:** declare that a task waits on other tasks, at creation (`blocked_by`) or afterwards (`add_task_dependency_kandev` / `remove_task_dependency_kandev`), and inspect related parent, child, sibling, blocker, and blocked-by tasks. A blocked task is not started by any automated path; a create that declares dependencies records its start and fires it once every predecessor succeeds, so chained creates build a chain instead of launching at once. Cycles are rejected with the offending path.
|
||||
- **Attach more branches:** add another `(repository, branch)` worktree to an existing worktree task. Use this for multiple PRs from one task, either in the same repository or across repositories.
|
||||
- **Adjust diff bases:** update a task repository's base branch so changes, ahead/behind counts, and review context compare against the right target branch.
|
||||
- **Move, archive, or delete tasks:** move tasks across workflow steps, including optional handoff prompts for the next agent, or clean up tasks when appropriate.
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
---
|
||||
spec: docs/specs/task-dependencies/spec.md
|
||||
created: 2026-08-09
|
||||
status: not_started
|
||||
---
|
||||
|
||||
# Implementation Plan: Task Dependencies and Auto-Start Chains
|
||||
|
||||
## Overview
|
||||
|
||||
Peer-to-peer task dependencies become a core Kanban capability with three
|
||||
observable behaviors: a blocked task is visible, a blocked task is never
|
||||
auto-started, and an unblocked task with a recorded launch intent starts exactly
|
||||
once.
|
||||
|
||||
The work is deliberately additive over four shipped mechanisms rather than a new
|
||||
subsystem:
|
||||
|
||||
- the `task_blockers` edge table and its BFS cycle detector;
|
||||
- the `tasks.metadata.deferred_launch` intent with its atomic claim;
|
||||
- the single auto-start chokepoint `orchestrator.autoStartTaskForStep`;
|
||||
- WIP admission and queue promotion.
|
||||
|
||||
Nothing in this plan adds a second launch path, a denormalized `is_blocked`
|
||||
column, or a chain entity.
|
||||
|
||||
## Confirmed current behavior
|
||||
|
||||
Verified in `apps/backend` and `apps/web` at `d38d9a0b8`:
|
||||
|
||||
- `task_blockers (task_id, blocker_task_id, created_at)` exists with a
|
||||
composite PK and a `CHECK (task_id != blocker_task_id)`. It is created by
|
||||
`internal/office/repository/sqlite/base.go:createTaskExtensionTables` but
|
||||
lives in the same database as `tasks`.
|
||||
- `internal/office/dashboard/service_tasks.go` owns edge creation with
|
||||
self-edge, cross-workspace, and BFS cycle validation, returning
|
||||
`*BlockerCycleError{Path}` which the frontend already renders as `A → B → A`.
|
||||
- `internal/task/service/service_office.go` exposes `AddBlocker` /
|
||||
`RemoveBlocker` with a separate, weaker cycle check, and errors with
|
||||
`blocker repository not configured` when `s.blockers == nil`.
|
||||
- `internal/backendapp/main.go` calls `SetBlockerRepository(repos.Office)`
|
||||
**after** the `if !cfg.Features.Office { return }` early return, so in a
|
||||
Kanban-only install the blocker repository is not wired at all:
|
||||
`create_task` with `blocked_by` fails and `list_related_tasks_kandev`
|
||||
returns empty `blockers` / `blocked_by`.
|
||||
- Edge HTTP routes exist only under the Office group:
|
||||
`POST /api/v1/office/tasks/:id/blockers`,
|
||||
`DELETE /api/v1/office/tasks/:id/blockers/:blockerId`
|
||||
(`internal/office/dashboard/handler.go`).
|
||||
- `internal/office/scheduler/reactivity.go:cascadeBlockersResolved` is the only
|
||||
reaction to a resolved blocker. It runs from the Office `ApplyTaskMutation`
|
||||
path when a status becomes done, resolves "done" as
|
||||
`IsTaskInTerminalStep`, and requires a non-empty
|
||||
`AssigneeAgentProfileID`. It never fires for a Kanban board move or a
|
||||
workflow-engine transition. `normalisedStatus` maps `FAILED` to blocked, not
|
||||
done, so Office already does not cascade on failure.
|
||||
- `internal/orchestrator/event_handlers_workflow.go:autoStartTaskForStep` is
|
||||
the single automated-launch chokepoint. It already returns early when
|
||||
`task.QueuedForStepID != ""`, consumes a `deferred_launch` intent via
|
||||
`launchDeferredTask`, and guards watcher races with
|
||||
`MetaKeyAutoStartGuard` / `claimAutoStart` / `restoreAutoStartClaim`. It is
|
||||
reached from `task.moved` (`handleTaskMovedNoSession`) and
|
||||
`task.queue_promoted` (`handleTaskQueuePromoted`).
|
||||
- `wfmodels.IsTerminalStepName` recognizes `done|complete|completed|approved`;
|
||||
entering such a final step persists `state = COMPLETED`.
|
||||
- The workflow engine declares `TriggerOnBlockerResolved` ("on_blocker_resolved")
|
||||
but `Engine.HandleTrigger` does not wire it; Office routes it through
|
||||
`SetWorkflowEngineDispatcher`.
|
||||
- `create_task_kandev`'s handler struct reads `blocked_by`
|
||||
(`internal/mcp/handlers/handlers.go:576`, `json:"blocked_by"`) and forwards it
|
||||
to `CreateTask`, but the tool's `mcp.NewTool` schema in
|
||||
`internal/mcp/server/server.go:854` never declares the parameter. It is
|
||||
therefore undiscoverable to agents and not reliably passable through a
|
||||
schema-validating client. There is no MCP tool at all for adding or removing a
|
||||
dependency on an existing task.
|
||||
- Frontend: `components/task/simple/components/blockers-picker.tsx` exists with
|
||||
cycle-message handling and optimistic mutation, but is mounted only from
|
||||
`task-properties.tsx` → `OfficeSimplePane`, which the Kanban route
|
||||
(`app/tasks/[id]/kanban-task-shell.tsx`) renders as a stub. Kanban has no
|
||||
dependency UI.
|
||||
- The status row above the composer is `data-testid="chat-status-bar"` in
|
||||
`components/task/chat/chat-input-area.tsx`. It hosts `TodoIndicator`,
|
||||
`PRStatusChip`, `AzureDevOpsTaskPullRequestChip`, a queue chip, PR
|
||||
merged/closed banners, and right-aligned transcript controls. Its visibility is
|
||||
gated by `shouldRenderChatStatusBar({hasTask, hasTodos, hasQueueChip,
|
||||
showRightControls, showProceed})` — a new chip that is not added to that
|
||||
predicate renders nothing when it is the row's only content.
|
||||
- A **second** status row exists: `data-testid="passthrough-status-row"` in
|
||||
`components/task/passthrough-toolbar.tsx`, which mounts its own `PRStatusChip`.
|
||||
Anything that belongs "next to the PR chip" belongs in both rows.
|
||||
- `PRStatusChip` returns null when not applicable and branches on
|
||||
`usesMobileDrawer` to `PRStatusChipDrawer` versus `PRStatusChipHoverCard` —
|
||||
the mobile pattern to mirror rather than reinvent.
|
||||
- `components/kanban-card-content.tsx` already renders a `queuedForStepId`
|
||||
badge — the precedent and the layout neighbour for a blocked badge.
|
||||
- `lib/kanban/view-registry.ts` is a pluggable board-view registry with
|
||||
`kanban` and `graph2` ("Pipeline"). `graph2` visualizes one task across
|
||||
workflow steps, not cross-task edges. `getEffectiveView` forces the Kanban
|
||||
view on mobile.
|
||||
|
||||
## Backend design
|
||||
|
||||
### One owner for the dependency relationship
|
||||
|
||||
Move the authoritative edge operations into the task domain so a Kanban-only
|
||||
install has them:
|
||||
|
||||
- Keep the physical `task_blockers` table where it is. It is in the same
|
||||
database, and relocating live DDL buys nothing. Add the missing indexes via
|
||||
idempotent migrations in `internal/task/repository/sqlite/base_migrations.go`
|
||||
(`(blocker_task_id)` for the reverse walk).
|
||||
- Move the strong validator — self-edge, cross-workspace, BFS cycle with the
|
||||
existing walk limit and `*BlockerCycleError` — into the task service so both
|
||||
the new task-scoped routes and the existing Office routes call one
|
||||
implementation. Delete the weaker `checkCircularBlocker` in
|
||||
`service_office.go` rather than leaving two validators.
|
||||
- Wire `SetBlockerRepository` **before** the `Features.Office` early return in
|
||||
`internal/backendapp/main.go`. This is the smallest change that makes
|
||||
`blocked_by` and `list_related_tasks_kandev` work in Kanban mode.
|
||||
- Edge cleanup on task **delete** is explicit repository work, not a SQLite
|
||||
`ON DELETE CASCADE`, because the table predates the foreign key and must
|
||||
behave identically on PostgreSQL. **Archive** deliberately keeps the edges:
|
||||
an archived predecessor reads as `pending`, so dropping them would silently
|
||||
unblock the chain.
|
||||
|
||||
### Derived blocked state
|
||||
|
||||
Add one service helper that, for a set of task IDs, returns each task's direct
|
||||
predecessors **and direct dependents**, plus a resolution verdict per
|
||||
predecessor:
|
||||
|
||||
- resolved — `state = COMPLETED`, or resident in a final step whose name passes
|
||||
`IsTerminalStepName`;
|
||||
- failed — `state` is `FAILED` or `CANCELLED`;
|
||||
- pending — anything else, including archived.
|
||||
|
||||
`blocked` and `blocked_reason` are computed from that verdict on every read and
|
||||
are never stored. Batch it: the Kanban board reads a whole workflow at once, so
|
||||
a per-task query would add N round trips to the board load. Follow the existing
|
||||
batch-helper convention in `task/service/service_access.go` — callers derive the
|
||||
IDs from an already-authorized task list.
|
||||
|
||||
Both directions ship in the payload because the dependency chip shows both, and
|
||||
the reverse direction is one indexed query rather than a client-side scan the
|
||||
board's partial task list could not answer correctly anyway.
|
||||
|
||||
### MCP surface
|
||||
|
||||
Declaring `blocked_by` on the `create_task_kandev` schema is the smallest fix
|
||||
with the largest effect: the plumbing already exists and only the contract is
|
||||
missing. Two new tools, `add_task_dependency_kandev` and
|
||||
`remove_task_dependency_kandev`, mirror the HTTP routes one-to-one and follow the
|
||||
repo's existing preference for explicit small tools
|
||||
(`add_branch_to_task_kandev`, the three `*_task_plan_kandev` tools) over a
|
||||
mode-flag mutator. `list_related_tasks_kandev` is already the read side.
|
||||
|
||||
The load-bearing semantic decision: `blocked_by` non-empty plus
|
||||
`start_agent: true` records a start-when-unblocked intent instead of launching.
|
||||
`start_agent` defaults to true and its description tells agents to leave it true,
|
||||
so the alternative silently launches the whole chain at once — exactly the
|
||||
collision the feature exists to prevent. The create response reports
|
||||
`started: false` with `start_when_unblocked: true` so the caller is not guessing.
|
||||
|
||||
### The gate
|
||||
|
||||
Add the dependency check to `autoStartTaskForStep` next to the existing
|
||||
`QueuedForStepID` check, before `launchDeferredTask`. Because that function is
|
||||
the only automated-launch entry point, one check covers `task.moved`,
|
||||
`task.queue_promoted`, watcher auto-start, and dependency resolution itself.
|
||||
|
||||
The gate fails **closed**: if the predecessor lookup errors, skip the launch and
|
||||
log a warning. Failing open would launch work whose predecessor may not have
|
||||
run, which is the one outcome this feature exists to prevent.
|
||||
|
||||
Manual start (`StartTask` from a user action) does not consult the gate.
|
||||
|
||||
### Resolution and auto-start
|
||||
|
||||
Subscribe to the task-state and task-move events that can make a predecessor
|
||||
resolved. On each, look up dependents with `ListTasksBlockedBy`, and for each
|
||||
dependent re-evaluate all of its predecessors:
|
||||
|
||||
- any still pending → nothing;
|
||||
- all resolved → publish `task.dependencies_resolved` and call
|
||||
`autoStartTaskForStep` with the dependent's current step;
|
||||
- at least one failed and none pending → publish `task.dependency_failed`
|
||||
(Task 04).
|
||||
|
||||
`start_when_unblocked` on task creation persists the resolved launch inputs into
|
||||
`metadata.deferred_launch` — the same record WIP overflow already writes and
|
||||
`launchDeferredTask` already consumes idempotently. No new table, and the
|
||||
"exactly one launch" property is inherited rather than re-derived. Removing the
|
||||
last edge unblocks the task but must not consume the intent.
|
||||
|
||||
Startup reconciliation: after the queue reconciler runs, sweep non-archived
|
||||
tasks that carry a `deferred_launch` intent and have edges, and launch those
|
||||
whose dependencies are all resolved. This is the recovery path for a restart
|
||||
between a predecessor's completion and the dependent's launch. Bound and batch
|
||||
it so a large graph does not delay readiness.
|
||||
|
||||
### Not touched
|
||||
|
||||
`cascadeBlockersResolved`, Office assignment, `TriggerOnBlockerResolved` wiring,
|
||||
and the Office task pane keep their current behavior. For a task eligible under
|
||||
both reactions, the existing atomic deferred-launch/auto-start claim admits one
|
||||
launcher.
|
||||
|
||||
## Frontend
|
||||
|
||||
- Extend the Kanban task type, boot mapper, and WS handlers with `blocked`,
|
||||
`blockedReason`, `dependsOn`, `blocks`, and `startWhenUnblocked`.
|
||||
- Treat an omitted projection as **unknown**, never as "no edges", at every layer
|
||||
(mapper, WS merge, hydration backfill). The chip re-reads the task when its
|
||||
cached copy has no projection, so no WS/hydration ordering can blank it.
|
||||
- Blocked badge on the shared card next to the existing queued badge. Both must
|
||||
fit without truncating either, on desktop and mobile, with no hover-only
|
||||
disclosure.
|
||||
- Dependency chip in the status row above the composer, next to `PRStatusChip`,
|
||||
in **both** `chat-input-area.tsx` and `passthrough-toolbar.tsx`. It shows both
|
||||
directions (blocked by / blocks), opens to a navigable list with each entry's
|
||||
state, distinguishes failed predecessors, returns null with no edges, and uses
|
||||
the drawer-on-mobile pattern `PRStatusChip` already establishes.
|
||||
`shouldRenderChatStatusBar` must learn about the chip, or a task whose only
|
||||
status content is the chip renders no row.
|
||||
- No "Depends on" picker in the Kanban task detail view. Dependencies are
|
||||
declared in the create dialog or over MCP (see the spec's What); the detail
|
||||
view's dependency surfaces — card badge, chip, list — stay read-only.
|
||||
- "Depends on" field plus a "Start automatically when unblocked" checkbox in the
|
||||
task-create dialog, and an "Add dependency" entry in the Kanban card context
|
||||
menu (`kanban-card-link-submenu.tsx` is the precedent).
|
||||
- No whole-graph board view. One was built and removed before shipping (see the
|
||||
spec's Out of scope): a `VIEW_REGISTRY` entry alone is not reachable anyway —
|
||||
the header toggle speaks the separate `TaskListingView` vocabulary — and
|
||||
without drawn connectors the result read as columns of text.
|
||||
- All new copy goes through `t()` / `<Trans>`, and any newly touched path is
|
||||
appended to `i18nGuardFiles` in the same change.
|
||||
|
||||
## Test strategy
|
||||
|
||||
### Backend
|
||||
|
||||
- Edge creation: cycle at length 2, 3, and N; self-edge; cross-workspace;
|
||||
duplicate edge is idempotent; validator is shared by the task-scoped and
|
||||
Office routes.
|
||||
- Derived state: pending / resolved-by-state / resolved-by-terminal-step /
|
||||
failed / cancelled / archived predecessors produce the right `blocked` and
|
||||
`blocked_reason`; batch helper matches the per-task result.
|
||||
- Gate: a blocked task is not launched by `task.moved`, by
|
||||
`task.queue_promoted`, or by dependency resolution; a predecessor-lookup
|
||||
error skips the launch; manual start is unaffected.
|
||||
- Resolution: last-predecessor-resolves launches exactly once; a non-last
|
||||
resolution launches nothing; no-intent unblocks without launching;
|
||||
last-edge-removed unblocks without launching; a queued dependent launches on
|
||||
WIP promotion, not on resolution.
|
||||
- Chain: A → B → C with intents launches each exactly once, in order, and never
|
||||
restarts A.
|
||||
- Races: dependency resolution and WIP promotion both firing produce one
|
||||
session; a claim loser logs and stops; a failed launch restores the claim.
|
||||
- Restart: a restart between completion and launch yields exactly one session
|
||||
after startup reconciliation.
|
||||
- Failure: `FAILED` / `CANCELLED` predecessor holds the dependent blocked with
|
||||
`reason: failed`, publishes the event once per transition, and raises one
|
||||
notification; retry-then-success unblocks and fires the intent once.
|
||||
- Deletion: deleting a predecessor removes edges both ways, unblocks the
|
||||
dependent, and does not fire its intent.
|
||||
- Kanban-only wiring: with `Features.Office` false, `blocked_by` on create
|
||||
succeeds and `list_related_tasks_kandev` populates `blockers` /
|
||||
`blocked_by`.
|
||||
- MCP: a pinning test asserts `create_task_kandev`'s registered schema declares
|
||||
`blocked_by` and `start_when_unblocked` — the current bug is precisely a
|
||||
handler/schema mismatch that no behavior test would have caught.
|
||||
`blocked_by` + default `start_agent` starts nothing and reports
|
||||
`start_when_unblocked: true`; three chained create calls run in order; the two
|
||||
dependency tools cover default `task_id`, cycle rejection, absent-edge removal,
|
||||
and cross-workspace denial.
|
||||
- PostgreSQL: add the environment-gated behavior test for every changed
|
||||
dialect-sensitive repository method, per `apps/backend/CLAUDE.md`.
|
||||
|
||||
### Frontend unit
|
||||
|
||||
- DTO/store conversion preserves the five new fields across boot and WS.
|
||||
- Blocked badge renders count and reason; coexists with the queued badge.
|
||||
- `shouldRenderChatStatusBar` returns true when the dependency chip is the row's
|
||||
only content.
|
||||
- The chip lists both directions with states, distinguishes a failed
|
||||
predecessor, renders null with no edges, and appears in both status rows.
|
||||
- Picker creates and removes edges optimistically and replaces the optimistic
|
||||
state with the formatted cycle path on rejection.
|
||||
- Create dialog submits `blocked_by` and `start_when_unblocked`.
|
||||
- Dependencies view builds nodes/edges from a graph payload, marks blocked and
|
||||
failed nodes, and is absent from the mobile view set.
|
||||
|
||||
### Browser E2E
|
||||
|
||||
- Desktop chain: seed A → B → C with intents, complete A, assert B starts and C
|
||||
does not; complete B, assert C starts.
|
||||
- Desktop gate: a blocked task moved into an auto-start step gains no session.
|
||||
- Desktop failure: fail A, assert B stays blocked with the failed reason and
|
||||
gains no session.
|
||||
- Desktop view: create and delete an edge from the Dependencies view.
|
||||
- Mobile: blocked badge readable in a focused column, no horizontal overflow,
|
||||
usable tap targets, Dependencies view not offered.
|
||||
|
||||
Seed tasks without an agent and locate by task id — `createTaskWithAgent` on a
|
||||
start step moves the card mid-test.
|
||||
|
||||
## Public documentation
|
||||
|
||||
Update `docs/public/tasks-and-workflows.md` (and `workflow-tips.md` if the
|
||||
auto-start interaction needs a worked example) to cover: declaring a dependency,
|
||||
what counts as resolution, that a failed predecessor halts the chain and needs
|
||||
human action, that dependencies gate automated starts but not manual ones, and
|
||||
that dependency resolution does not bypass WIP limits.
|
||||
|
||||
## Implementation waves
|
||||
|
||||
Tasks 01–04 are strictly sequential: each consumes a contract the previous one
|
||||
introduces. Tasks 05 and 06 need only Task 01's API surface, so either may start
|
||||
once 01 lands. Task 08 closes out. Task 07 was built on 06 and then dropped
|
||||
before shipping, so it is not part of the executable set and Task 08 does not
|
||||
wait on it.
|
||||
|
||||
- [x] [Task 01: Promote dependency edges to a core relationship](task-01-core-dependency-relationship.md)
|
||||
- [x] [Task 02: Gate automated launches on unresolved dependencies](task-02-gate-automated-launch.md)
|
||||
- [x] [Task 03: Resolve dependencies and auto-start on unblock](task-03-resolve-and-auto-start.md)
|
||||
- [x] [Task 04: Halt chains on a failed predecessor](task-04-failed-predecessor-halt.md)
|
||||
- [x] [Task 05: MCP dependency tools](task-05-mcp-dependency-tools.md)
|
||||
- [x] [Task 06: Kanban dependency UI](task-06-kanban-dependency-ui.md)
|
||||
- [x] [Task 08: Verify browser flows and documentation](task-08-verify-flows-and-docs.md)
|
||||
|
||||
No task is marked `parallel-safe`; waves do not authorize subagent execution.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Two validators.** `office/dashboard` has the strong BFS cycle check;
|
||||
`task/service` has a weaker one. Adding a third for the new routes would let
|
||||
a cycle in through whichever path is weakest. One validator, or the DAG
|
||||
invariant is not real.
|
||||
- **Failing open on lookup error.** A blocked-state read that errors and returns
|
||||
"not blocked" launches exactly the work this feature prevents. The gate must
|
||||
fail closed and the test must prove it by breaking the store.
|
||||
- **A denormalized `is_blocked` column.** Tempting for board queries, but a
|
||||
stale value gates launches wrongly. Batch the derived read instead.
|
||||
- **Two launch paths.** Dependency resolution and WIP promotion can both make
|
||||
the same task launchable within milliseconds. Only the shared atomic claim
|
||||
keeps that to one session; a new bespoke launch call site would reintroduce
|
||||
the double-agent bug that `wip-limit-pull-system` documents.
|
||||
- **Divergent "terminal" definitions.** `on_children_completed` treats `FAILED`
|
||||
as terminal; dependency resolution must not. Sharing a helper between them
|
||||
would silently make chains proceed on failure.
|
||||
- **Office double-reaction.** With Office enabled, `cascadeBlockersResolved` and
|
||||
the new Kanban reaction can both fire for one task. Verify one session, and do
|
||||
not "fix" it by disabling either reaction.
|
||||
- **Board load cost.** A per-card dependency query multiplies board queries by
|
||||
card count. The batch helper is load-bearing, not an optimization.
|
||||
- **Graph view scope creep.** The Dependencies view is the largest single piece
|
||||
of UI. It must not become a general graph editor; edges and nodes only.
|
||||
- **`start_agent: true` launching a blocked task.** Agents pass it by default.
|
||||
If create does not convert it to a start-when-unblocked intent, every chain an
|
||||
agent builds launches at once — the exact failure the feature prevents, and it
|
||||
would look like the gate is broken rather than the create semantics.
|
||||
- **A schema/handler mismatch that no behavior test catches.** `blocked_by`
|
||||
already works end to end in the handler and is still unusable because the tool
|
||||
schema omits it. The pinning test on the registered schema is what stops the
|
||||
same class of gap recurring for `start_when_unblocked`.
|
||||
- **Two status rows.** `chat-input-area.tsx` and `passthrough-toolbar.tsx` both
|
||||
mount `PRStatusChip`. Adding the dependency chip to one leaves it missing in
|
||||
the other layout, and the omission is invisible in the default view.
|
||||
- **`shouldRenderChatStatusBar`.** A chip added without extending that predicate
|
||||
is dead in exactly the case it matters most: a blocked task with no PR, no
|
||||
todos, and no queued prompts.
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
id: "01-core-dependency-relationship"
|
||||
title: "Promote dependency edges to a core relationship"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/task-dependencies/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: Promote Dependency Edges to a Core Relationship
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The dependency edge store is reachable in a Kanban-only install:
|
||||
`SetBlockerRepository` is wired before the `Features.Office` early return in
|
||||
`internal/backendapp/main.go`, so `create_task` with `blocked_by` succeeds and
|
||||
`list_related_tasks_kandev` populates `blockers` / `blocked_by` with
|
||||
`Features.Office` false.
|
||||
- Exactly one edge validator exists. Self-edge, cross-workspace, and BFS cycle
|
||||
detection (with the existing walk limit and `*BlockerCycleError{Path}`) live in
|
||||
the task domain, and both the new task-scoped routes and the existing Office
|
||||
routes call it. `checkCircularBlocker` in `service_office.go` is deleted, not
|
||||
left as a second path.
|
||||
- A `(blocker_task_id)` index on `task_blockers` is added through an idempotent
|
||||
migration in `internal/task/repository/sqlite/base_migrations.go`.
|
||||
- **Deleting** a task removes its edges in both directions, on SQLite and on
|
||||
PostgreSQL, via explicit repository work rather than an `ON DELETE CASCADE`
|
||||
the table does not have. **Archiving** does not: an archived predecessor still
|
||||
blocks its dependents and reads as `pending` (Task 04), so its edges survive
|
||||
and reappear if the task is unarchived.
|
||||
- One batch service helper resolves, for a list of task IDs, each task's direct
|
||||
predecessors **and direct dependents**, and a per-predecessor verdict of
|
||||
`resolved` / `failed` / `pending`, using `state = COMPLETED` or
|
||||
`IsTerminalStepName` for resolved and `FAILED` / `CANCELLED` for failed.
|
||||
Archived predecessors are `pending`. The reverse direction uses the
|
||||
`(blocker_task_id)` index added above; it is one batched query, not one per
|
||||
task.
|
||||
- Task DTOs over HTTP, WebSocket boot, WebSocket events, and MCP carry
|
||||
`blocked`, `blocked_reason`, `depends_on`, `blocks`, and
|
||||
`start_when_unblocked`. No `is_blocked` column is persisted.
|
||||
- `depends_on` and `blocks` entries carry id, title, and state so the dependency
|
||||
chip renders without a per-entry fetch. Neither list is transitive.
|
||||
- New task-scoped routes exist and behave as specified:
|
||||
`POST /api/v1/tasks/:id/dependencies`,
|
||||
`DELETE /api/v1/tasks/:id/dependencies/:depId`. `POST` returns `409` with a
|
||||
`cycle` array on a cycle and `400` on a self-edge or cross-workspace edge;
|
||||
`DELETE` of an absent edge succeeds. Both return the mutated task's dependency
|
||||
projection so a caller does not re-fetch. There is no graph-wide listing
|
||||
route: every reader gets its edges from the dependency fields already on the
|
||||
task payload.
|
||||
- `task.updated` with `fields: ["dependencies"]` is published on edge add and
|
||||
remove, through the task event publisher (not a bare repository write).
|
||||
- `POST /api/v1/tasks` and `create_task_kandev` accept `start_when_unblocked`;
|
||||
when true **and the request declares at least one dependency**, the resolved
|
||||
launch inputs are persisted as `metadata.deferred_launch` in the same atomic
|
||||
boundary as the task row and no session, workspace, or executor is prepared.
|
||||
`start_when_unblocked: true` with no dependencies records no intent: the task
|
||||
is already unblocked, so an agent-start request on it launches immediately
|
||||
exactly as it would without the flag, and a create with neither launches
|
||||
nothing. The flag defers a start; it never invents one.
|
||||
- Edge routes authorize both tasks with the existing `authorize*` helpers, and
|
||||
the payload field names stay `task_id` / `session_id` compatible with the
|
||||
gateway dispatch backstop.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Failing test: with `Features.Office` false, `CreateTask` with `blocked_by`
|
||||
succeeds and `list_related_tasks_kandev` returns the edge.
|
||||
2. Failing tests for the shared validator: cycles of length 2, 3, and N return
|
||||
`*BlockerCycleError` with the full path; self-edge and cross-workspace return
|
||||
the typed errors; a duplicate edge is idempotent; both the task-scoped and
|
||||
Office entry points hit the same validator.
|
||||
3. Failing tests for the batch derived-state helper across pending,
|
||||
resolved-by-state, resolved-by-terminal-step, failed, cancelled, and archived
|
||||
predecessors, asserting the batch result equals the per-task result, and that
|
||||
the dependents (`blocks`) direction is returned for the same call.
|
||||
4. Failing DTO/event tests for the five new fields on HTTP, boot, WS, and MCP,
|
||||
including the id/title/state shape of `depends_on` and `blocks` entries.
|
||||
5. Failing handler tests for the three routes including the `409` `cycle` body
|
||||
shape and the authorization denials.
|
||||
6. Failing tests for edge cleanup on task delete, for edge *survival* across
|
||||
archive, and for zero-dependency `start_when_unblocked`, plus the
|
||||
environment-gated PostgreSQL equivalents.
|
||||
7. Implement: wiring move, migration/index, validator consolidation, batch
|
||||
helper, DTO fields, routes, event publication, `start_when_unblocked`
|
||||
persistence.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
go test -tags fts5 ./internal/task/... ./internal/office/dashboard ./internal/mcp/... ./internal/backendapp -run 'Test.*(Dependenc|Blocker|BlockedBy|StartWhenUnblocked)' -count=1
|
||||
golangci-lint run ./... --new-from-rev=origin/main --timeout=5m
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/backendapp/main.go`
|
||||
- `apps/backend/internal/task/repository/sqlite/base_migrations.go`
|
||||
- `apps/backend/internal/task/repository/sqlite/task.go`
|
||||
- `apps/backend/internal/task/service/service_office.go`
|
||||
- `apps/backend/internal/task/service/service_tasks.go`
|
||||
- `apps/backend/internal/task/service/service_requests.go`
|
||||
- `apps/backend/internal/task/service/handoff_service.go`
|
||||
- `apps/backend/internal/task/dto/dto.go`, `requests.go`
|
||||
- `apps/backend/internal/task/handlers/task_http_handlers.go`
|
||||
- `apps/backend/internal/office/dashboard/service_tasks.go`
|
||||
- `apps/backend/internal/mcp/handlers/handlers.go`
|
||||
- `apps/backend/pkg/api/v1/task.go`
|
||||
- focused service, repository, handler, DTO, and MCP tests
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Output contract
|
||||
|
||||
Mark this task `in_progress` before the RED tests and `done` only after the
|
||||
listed commands pass. Record where the single validator now lives, the exact
|
||||
migration/index statements, the batch helper's query shape, and test results in
|
||||
this file and `plan.md`.
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
id: "02-gate-automated-launch"
|
||||
title: "Gate automated launches on unresolved dependencies"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-core-dependency-relationship"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/task-dependencies/spec.md"
|
||||
---
|
||||
|
||||
# Task 02: Gate Automated Launches on Unresolved Dependencies
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `orchestrator.autoStartTaskForStep` skips the launch when the task has any
|
||||
unresolved dependency. The check sits next to the existing
|
||||
`task.QueuedForStepID != ""` early return and **before**
|
||||
`launchDeferredTask`, so a blocked task neither starts a session nor consumes
|
||||
its deferred launch intent.
|
||||
- One check covers every automated entry point. Verified for `task.moved`
|
||||
(`handleTaskMovedNoSession`), `task.queue_promoted`
|
||||
(`handleTaskQueuePromoted`), and the watcher auto-start path. No second gate
|
||||
is added at any call site.
|
||||
- The gate fails **closed**: if the dependency lookup returns an error, the
|
||||
launch is skipped and a warning is logged. A test proves this by making the
|
||||
store return an error, not by asserting the happy path.
|
||||
- A skipped launch leaves the task otherwise untouched — no state change, no
|
||||
claim taken, no claim restored, no queue metadata mutation — so a later
|
||||
resolution or promotion can launch it.
|
||||
- Manual start (`StartTask` reached from a user action, HTTP, or WS) is not
|
||||
gated. A user can start a blocked task.
|
||||
- The skip is logged at debug/info with the task id, the step id, and the number
|
||||
of unresolved predecessors, following the existing `eventName+": ..."` log
|
||||
convention in the file.
|
||||
- No new goroutine, event subscription, or launch path is introduced by this
|
||||
task.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Failing test: a task with one pending predecessor and an auto-start step
|
||||
gains no session on `task.moved`.
|
||||
2. Failing test: the same task gains no session on `task.queue_promoted`.
|
||||
3. Failing test: a task with a `deferred_launch` intent and a pending
|
||||
predecessor keeps its intent — the claim is not taken.
|
||||
4. Failing test: with the dependency store returning an error, no session is
|
||||
created and a warning is logged.
|
||||
5. Failing test: manual `StartTask` on a blocked task does create a session.
|
||||
6. Failing test: a task whose predecessors are all resolved is launched exactly
|
||||
as it is today (no regression on the ungated path).
|
||||
7. Implement the single gate and the log line.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
go test -tags fts5 ./internal/orchestrator -run 'Test.*(AutoStart|Dependenc|Blocked)' -count=1
|
||||
go test -tags fts5 ./internal/orchestrator -count=1
|
||||
golangci-lint run ./... --new-from-rev=origin/main --timeout=5m
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/orchestrator/event_handlers_workflow.go`
|
||||
- `apps/backend/internal/orchestrator/service.go` (dependency-reader seam)
|
||||
- `apps/backend/internal/orchestrator/event_handlers_github.go` (watcher path
|
||||
assertion only, if a change proves necessary)
|
||||
- focused orchestrator tests
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01 — needs the batch derived-state helper and the wired blocker
|
||||
repository.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Output contract
|
||||
|
||||
Mark this task `in_progress` before the RED tests and `done` only after the
|
||||
listed commands pass. Record the exact placement of the gate relative to the
|
||||
`QueuedForStepID` check and `launchDeferredTask`, the seam used to read
|
||||
dependency state from the orchestrator, and the fail-closed test's mechanism in
|
||||
this file and `plan.md`.
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
id: "03-resolve-and-auto-start"
|
||||
title: "Resolve dependencies and auto-start on unblock"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["02-gate-automated-launch"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/task-dependencies/spec.md"
|
||||
---
|
||||
|
||||
# Task 03: Resolve Dependencies and Auto-Start on Unblock
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A subscriber reacts to the task-state changes and workflow-step moves that can
|
||||
make a predecessor resolved, looks up dependents with `ListTasksBlockedBy`,
|
||||
and re-evaluates all predecessors of each dependent.
|
||||
- When the last unresolved predecessor of a task resolves successfully,
|
||||
`task.dependencies_resolved` is published with
|
||||
`{task_id, resolved_by_task_id}` and the dependent is handed to
|
||||
`autoStartTaskForStep`. No bespoke launch call is added.
|
||||
- A dependent with no `deferred_launch` intent unblocks and starts nothing.
|
||||
- A dependent that is not WIP-admitted starts nothing on resolution; its intent
|
||||
survives and the existing WIP promotion path launches it once, later.
|
||||
- A dependent whose step has no `on_enter: auto_start_agent` and no intent
|
||||
unblocks and starts nothing.
|
||||
- Removing the last blocking edge unblocks the task and does **not** consume its
|
||||
intent or launch it.
|
||||
- A chain A → B → C, each with an intent, launches each task exactly once in
|
||||
order and never restarts an earlier task.
|
||||
- Exactly one session is created when dependency resolution and WIP promotion
|
||||
race, guaranteed by the existing atomic deferred-launch/auto-start claim. A
|
||||
claim loser logs and stops; a failed launch restores the claim so the task
|
||||
stays retryable and visibly unstarted.
|
||||
- Startup reconciliation, running after the existing WIP queue reconciler,
|
||||
sweeps non-archived tasks that hold a `deferred_launch` intent and have edges,
|
||||
and launches those whose predecessors are all resolved. It is bounded and
|
||||
batched so a large graph does not delay readiness, and it is idempotent across
|
||||
repeated runs.
|
||||
- A restart between a predecessor's completion and the dependent's launch yields
|
||||
exactly one session after startup.
|
||||
- `cascadeBlockersResolved`, Office assignment, and `TriggerOnBlockerResolved`
|
||||
wiring are unchanged. With `Features.Office` true and a task eligible under
|
||||
both reactions, exactly one session is created.
|
||||
- Any goroutine this task starts has a single owner with idempotent
|
||||
`Start`/`Stop`, cancels on `ctx.Done()`, and is covered by the package's
|
||||
`goleak` `TestMain`.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Failing test: last-predecessor-resolves publishes
|
||||
`task.dependencies_resolved` once and launches the dependent exactly once
|
||||
with the intent's recorded agent profile, executor, executor profile, prompt,
|
||||
and plan-mode choice.
|
||||
2. Failing test: a non-last resolution publishes nothing and launches nothing.
|
||||
3. Failing test: resolution via a move into a final `Done` step counts, not just
|
||||
`state = COMPLETED`.
|
||||
4. Failing test: a dependent without an intent unblocks and starts nothing.
|
||||
5. Failing test: a queued (not WIP-admitted) dependent starts nothing on
|
||||
resolution and starts exactly once on later promotion.
|
||||
6. Failing test: removing the last edge unblocks without launching.
|
||||
7. Failing test: A → B → C chain launches each once, in order.
|
||||
8. Failing test: simultaneous resolution and promotion produce one session; the
|
||||
loser logs; a launch failure restores the claim.
|
||||
9. Failing test: restart between completion and launch yields one session after
|
||||
startup reconciliation; a second reconciliation run launches nothing more.
|
||||
10. Failing test: with Office enabled, a task eligible under both reactions gets
|
||||
one session.
|
||||
11. Implement the subscriber, the event, and the startup reconciliation.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
go test -tags fts5 ./internal/orchestrator ./internal/task/service ./internal/office/scheduler -run 'Test.*(DependenciesResolved|DependencyChain|AutoStartUnblock|StartupReconcil)' -count=1
|
||||
go test -tags fts5 -race ./internal/orchestrator ./internal/task/service -count=1
|
||||
golangci-lint run ./... --new-from-rev=origin/main --timeout=5m
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/orchestrator/event_handlers_workflow.go`
|
||||
- `apps/backend/internal/orchestrator/event_handlers_task.go` or the equivalent
|
||||
state-change subscriber
|
||||
- `apps/backend/internal/task/events/` (new event type)
|
||||
- `apps/backend/internal/task/service/service_events.go`
|
||||
- `apps/backend/internal/backendapp/main.go` (startup reconciliation ordering)
|
||||
- focused orchestrator and task-service tests
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 02 — the gate must exist first, otherwise resolution-driven launches would
|
||||
bypass it and the WIP interaction could not be asserted.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Output contract
|
||||
|
||||
Mark this task `in_progress` before the RED tests and `done` only after the
|
||||
listed commands pass. Record which events the subscriber listens to, where
|
||||
startup reconciliation runs relative to the WIP queue reconciler, its batch
|
||||
bound, and the race-test mechanism in this file and `plan.md`.
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
id: "04-failed-predecessor-halt"
|
||||
title: "Halt chains on a failed predecessor"
|
||||
status: done
|
||||
wave: 4
|
||||
depends_on: ["03-resolve-and-auto-start"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/task-dependencies/spec.md"
|
||||
---
|
||||
|
||||
# Task 04: Halt Chains on a Failed Predecessor
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A predecessor entering `FAILED` or `CANCELLED` never resolves its edge. The
|
||||
dependent stays blocked and its `blocked_reason` becomes `"failed"` once no
|
||||
predecessor is still pending.
|
||||
- `task.dependency_failed` is published with
|
||||
`{task_id, failed_task_id, failed_state}`, once per
|
||||
(dependent, predecessor) failure transition. A repeated write of the same
|
||||
failed state does not re-publish.
|
||||
- A notification event type `task.dependency_failed` is added following the
|
||||
existing constants in `internal/notifications/service`, and fires once per
|
||||
transition.
|
||||
- No session is created for the dependent, and its `deferred_launch` intent is
|
||||
neither consumed nor deleted — it remains available for a retry.
|
||||
- Retrying the failed predecessor to successful completion unblocks the
|
||||
dependent and fires its intent exactly once.
|
||||
- Removing the edge unblocks the dependent without firing its intent.
|
||||
- An archived predecessor reads as `pending`, not `failed` and not `resolved`;
|
||||
the dependent stays blocked and the predecessor is listed as pending.
|
||||
- Deleting a predecessor removes the edges in both directions, may unblock the
|
||||
dependent, and does **not** fire its intent — deletion is not success.
|
||||
- Kandev never auto-retries a failed predecessor and never auto-removes an edge.
|
||||
- A dependent that already launched is not stopped or rolled back when a
|
||||
predecessor is later reopened; it reports blocked again and its running
|
||||
session is untouched.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Failing test: `FAILED` predecessor leaves the dependent blocked with
|
||||
`blocked_reason: "failed"`, no session, intent intact.
|
||||
2. Failing test: `CANCELLED` predecessor behaves identically.
|
||||
3. Failing test: `task.dependency_failed` publishes once; a duplicate terminal
|
||||
write does not re-publish.
|
||||
4. Failing test: one notification is raised per transition.
|
||||
5. Failing test: retry-to-success unblocks and launches exactly once.
|
||||
6. Failing test: edge removal unblocks without launching.
|
||||
7. Failing test: archived predecessor reads as pending.
|
||||
8. Failing test: predecessor deletion removes both edge directions, unblocks,
|
||||
and launches nothing.
|
||||
9. Failing test: reopening a predecessor after the dependent launched leaves the
|
||||
running session alive and reports blocked again.
|
||||
10. Implement the failure branch, the event, and the notification type.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
go test -tags fts5 ./internal/orchestrator ./internal/task/service ./internal/notifications/... -run 'Test.*(DependencyFailed|PredecessorFailed|DependencyArchived|DependencyDeleted)' -count=1
|
||||
golangci-lint run ./... --new-from-rev=origin/main --timeout=5m
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/orchestrator/event_handlers_workflow.go` or the
|
||||
state-change subscriber added in Task 03
|
||||
- `apps/backend/internal/task/events/`
|
||||
- `apps/backend/internal/task/service/service_events.go`
|
||||
- `apps/backend/internal/task/service/service_tasks.go` (delete/archive edge
|
||||
cleanup interaction)
|
||||
- `apps/backend/internal/notifications/service/service.go`
|
||||
- focused orchestrator, task-service, and notification tests
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 03 — the resolution subscriber is where the failure branch lives.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Output contract
|
||||
|
||||
Mark this task `in_progress` before the RED tests and `done` only after the
|
||||
listed commands pass. Record the once-per-transition mechanism, the notification
|
||||
constant, and the delete-versus-archive difference in this file and `plan.md`.
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
id: "05-mcp-dependency-tools"
|
||||
title: "MCP dependency tools"
|
||||
status: done
|
||||
wave: 5
|
||||
depends_on: ["01-core-dependency-relationship"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/task-dependencies/spec.md"
|
||||
---
|
||||
|
||||
# Task 05: MCP Dependency Tools
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `create_task_kandev` **declares** `blocked_by` (array of task IDs) and
|
||||
`start_when_unblocked` (boolean) in its `mcp.NewTool` schema in
|
||||
`internal/mcp/server/server.go`. Today the handler struct at
|
||||
`internal/mcp/handlers/handlers.go:576` reads `blocked_by` but the schema never
|
||||
declares it, so no agent can discover or reliably pass it. A pinning test
|
||||
asserts both parameters are present in the registered tool schema.
|
||||
- `blocked_by` non-empty plus `start_agent: true` (including the default) records
|
||||
a start-when-unblocked intent and launches nothing now. The create response
|
||||
reports `started: false` and `start_when_unblocked: true`.
|
||||
- `blocked_by` non-empty plus explicit `start_when_unblocked: false` creates the
|
||||
edges with no launch intent; completing the predecessor starts nothing.
|
||||
- `add_task_dependency_kandev` is registered: `{task_id?, depends_on_task_id}`,
|
||||
`task_id` defaults to the calling task, returns the resulting `depends_on`
|
||||
list. It rejects cycles with the cycle path, self-edges, and cross-workspace
|
||||
edges, using the single validator from Task 01.
|
||||
- `remove_task_dependency_kandev` is registered with the same shape. Removing an
|
||||
absent edge succeeds. Removing the last edge unblocks the task without
|
||||
launching it.
|
||||
- Both new tools are registered in the same modes as the other task-mutation
|
||||
tools, and neither widens the `ModeExternal` surface beyond what
|
||||
`create_task_kandev` already exposes there.
|
||||
- An agent cannot set `start_when_unblocked` on a task it did not create.
|
||||
- Tool descriptions state: a subtask means "part of" and a dependency means "not
|
||||
until"; decomposing a plan into ordered phases is N sibling tasks chained with
|
||||
`blocked_by` + `start_when_unblocked`, not N subtasks started at once; and a
|
||||
failed predecessor halts the chain and needs human action.
|
||||
- `list_related_tasks_kandev` populates `blockers` and `blocked_by` with
|
||||
`Features.Office` false (inherited from Task 01; asserted here at the MCP
|
||||
boundary).
|
||||
- Both tools authorize through the existing MCP scoping path — the owning task
|
||||
comes from the `AgentExecution`, never from an agent-supplied `task_id` used to
|
||||
reach another workspace's tasks.
|
||||
- The MCP tool list in the root `CLAUDE.md` / `AGENTS.md` "Kandev Task Creation"
|
||||
guidance is updated if the new tools change how an agent should decompose work.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Failing pinning test: the registered `create_task_kandev` schema declares
|
||||
`blocked_by` and `start_when_unblocked`.
|
||||
2. Failing test: `blocked_by` + default `start_agent` creates the edge, starts no
|
||||
session, and returns `started: false`, `start_when_unblocked: true`.
|
||||
3. Failing test: `blocked_by` + `start_when_unblocked: false` records no intent
|
||||
and completing the predecessor starts nothing.
|
||||
4. Failing test: three chained `create_task_kandev` calls run in order —
|
||||
completing the first starts the second and not the third.
|
||||
5. Failing tests for `add_task_dependency_kandev`: default `task_id`, returned
|
||||
`depends_on`, cycle rejection with path, self-edge, cross-workspace.
|
||||
6. Failing tests for `remove_task_dependency_kandev`: removal, absent-edge
|
||||
success, last-edge removal unblocks without launching.
|
||||
7. Failing test: an agent cannot set `start_when_unblocked` on a task it did not
|
||||
create.
|
||||
8. Failing test: cross-workspace `task_id` on either tool is denied by the
|
||||
scoping path.
|
||||
9. Failing test: with `Features.Office` false, `create_task_kandev` +
|
||||
`blocked_by` succeeds and `list_related_tasks_kandev` reports the edge.
|
||||
10. Implement schema declarations, the two tools, the start-intent semantics, and
|
||||
the descriptions.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
go test -tags fts5 ./internal/mcp/... -run 'Test.*(Dependenc|BlockedBy|StartWhenUnblocked|ToolSchema)' -count=1
|
||||
go test -tags fts5 ./internal/mcp/... -count=1
|
||||
golangci-lint run ./... --new-from-rev=origin/main --timeout=5m
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/mcp/server/server.go`
|
||||
- `apps/backend/internal/mcp/handlers/handlers.go`
|
||||
- `apps/backend/internal/mcp/server/handoff_handlers.go`
|
||||
- `apps/backend/internal/mcp/scope/` (if either tool needs a scope entry)
|
||||
- `CLAUDE.md` / `AGENTS.md` (task-decomposition guidance)
|
||||
- focused MCP handler and schema-pinning tests
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01 — needs the wired blocker repository, the single validator, and the
|
||||
`start_when_unblocked` create-request field. It does not need Tasks 02–04, so it
|
||||
may run alongside Task 06.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Output contract
|
||||
|
||||
Mark this task `in_progress` before the RED tests and `done` only after the
|
||||
listed commands pass. Record the two new tool names and their registered modes,
|
||||
the exact `blocked_by` + `start_agent` resolution rule as implemented, the
|
||||
schema-pinning test name, and test results in this file and `plan.md`.
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
id: "06-kanban-dependency-ui"
|
||||
title: "Kanban dependency UI"
|
||||
status: done
|
||||
wave: 6
|
||||
depends_on: ["01-core-dependency-relationship"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/task-dependencies/spec.md"
|
||||
---
|
||||
|
||||
# Task 06: Kanban Dependency UI
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The Kanban task type, boot-payload mapper, and WS handlers carry `blocked`,
|
||||
`blockedReason`, `dependsOn`, `blocks`, and `startWhenUnblocked`, surviving
|
||||
both boot hydration and incremental `task.updated` events.
|
||||
|
||||
### Dependency chip above the composer
|
||||
|
||||
- A dependency chip renders in the chat status row directly above the composer,
|
||||
alongside `PRStatusChip`, in `components/task/chat/chat-input-area.tsx`
|
||||
(`data-testid="chat-status-bar"`).
|
||||
- The same chip renders in `components/task/passthrough-toolbar.tsx`
|
||||
(`data-testid="passthrough-status-row"`), which mounts its own copy of
|
||||
`PRStatusChip`. Both rows must show it or the chip disappears in passthrough
|
||||
layouts.
|
||||
- `shouldRenderChatStatusBar(...)` is extended with the chip's presence.
|
||||
Without that, a task whose only status content is the dependency chip renders
|
||||
no row at all — the predicate currently accounts only for todos, a queue chip,
|
||||
right-hand controls, and proceed.
|
||||
- The chip reports both directions and opens to a list of blocked-by and blocks
|
||||
entries with each entry's title and state, each navigable to that task.
|
||||
- Failed predecessors are visually distinguished from pending ones in the list.
|
||||
- The chip returns null when the task has no edges in either direction, matching
|
||||
`PRStatusChip`'s own null-when-not-applicable contract.
|
||||
- Mobile uses the drawer pattern, not a hover card, mirroring
|
||||
`PRStatusChipDrawer` / `usesMobileDrawer` in
|
||||
`components/github/pr-status-chip.tsx`.
|
||||
- The chip reads `dependsOn` / `blocks` from the task payload; it does not fetch
|
||||
each related task individually.
|
||||
- A blocked badge renders on the shared task card next to the existing
|
||||
`queuedForStepId` badge. It shows the predecessor count and distinguishes
|
||||
`pending` from `failed`. Hovering or tapping it lists the predecessor titles
|
||||
and their states.
|
||||
- Both badges can appear on one card without truncating either, on desktop and
|
||||
on a narrow mobile viewport, with no horizontal page overflow and no
|
||||
hover-only disclosure of the blocked state itself.
|
||||
- The Kanban task detail view mounts **no** edge editor. Dependencies are
|
||||
declared in the create dialog or over MCP, so every detail-view surface that
|
||||
reads them (card badge, chip, chip list) is read-only and no `BlockersPicker`
|
||||
is repointed at the task-scoped routes.
|
||||
- The task-create dialog gains a "Depends on" field and a "Start automatically
|
||||
when unblocked" checkbox, submitting `blocked_by` and `start_when_unblocked`.
|
||||
The checkbox is disabled when no dependency is selected.
|
||||
- The Kanban card context menu gains an "Add dependency" entry, following the
|
||||
`kanban-card-link-submenu.tsx` pattern.
|
||||
- All new copy goes through `t()` / `<Trans>`; no hardcoded literal, including
|
||||
inside SCREAMING_CASE config tables, which are reviewed by eye. Every path
|
||||
touched here is appended to `i18nGuardFiles` in `eslint.i18n.options.mjs` in
|
||||
the same change. `t()` is never called at module scope.
|
||||
- The pseudo-locale (Settings → General → Appearance) shows no English text in
|
||||
any surface added by this task.
|
||||
- Mobile parity: the picker and the create-dialog field are reachable and usable
|
||||
on a narrow viewport using native mobile interaction patterns, not a
|
||||
compressed desktop layout.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Failing tests: DTO/store conversion preserves the five fields from boot and
|
||||
from a `task.updated` event with `fields: ["dependencies"]`.
|
||||
2. Failing test: the blocked badge renders count and reason, and coexists with
|
||||
the queued badge.
|
||||
2a. Failing test: `shouldRenderChatStatusBar` returns true for a task whose only
|
||||
status content is the dependency chip.
|
||||
2b. Failing test: the chip lists blocked-by and blocks entries with states,
|
||||
distinguishes a failed predecessor, and renders null with no edges.
|
||||
2c. Failing test: the chip appears in both `chat-status-bar` and
|
||||
`passthrough-status-row`.
|
||||
3. Failing test: the picker creates and removes an edge optimistically.
|
||||
4. Failing test: a `409` with a `cycle` body renders the formatted cycle path
|
||||
and reverts the optimistic edge.
|
||||
5. Failing test: the create dialog submits `blocked_by` and
|
||||
`start_when_unblocked`, and the checkbox is disabled with no selection.
|
||||
6. Failing test: the context-menu entry opens the picker for the right task.
|
||||
7. Implement types, mappers, badge, picker mount, dialog fields, and menu entry.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
pnpm test -- --run components/kanban-card-content components/task/simple/components/blockers-picker components/task-create-dialog components/task/chat/chat-input-area components/task/dependency-chip lib/ws/handlers/kanban
|
||||
pnpm run typecheck
|
||||
pnpm run lint
|
||||
pnpm run i18n:check
|
||||
pnpm run i18n:ratchet
|
||||
```
|
||||
|
||||
Use `pnpm test` / `pnpm run typecheck`, not `pnpm exec` — `exec` skips the
|
||||
generator pre-hooks and produces spurious generated-JSON failures.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/lib/state/slices/kanban/types.ts`
|
||||
- `apps/web/lib/ws/handlers/kanban.ts`
|
||||
- `apps/web/components/kanban-card.tsx`, `kanban-card-content.tsx`
|
||||
- `apps/web/components/kanban-card-context-menu.tsx`
|
||||
- `apps/web/components/task/simple/components/blockers-picker.tsx`
|
||||
- `apps/web/components/task/dependency-chip.tsx` (new)
|
||||
- `apps/web/components/task/chat/chat-input-area.tsx`
|
||||
- `apps/web/components/task/passthrough-toolbar.tsx`
|
||||
- `apps/web/app/tasks/[id]/kanban-task-shell.tsx`
|
||||
- `apps/web/components/task-create-dialog*.tsx`
|
||||
- `apps/web/lib/api/domains/` (task-scoped dependency client)
|
||||
- `apps/web/eslint.i18n.options.mjs`
|
||||
- `apps/web/public/locales/**` (or the repo's catalog location)
|
||||
- focused `*.test.tsx` / `*.test.ts` files
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01 — needs the task-scoped routes and the five DTO fields. It does not need
|
||||
Tasks 02–05, so it may start as soon as 01 lands, alongside Task 05.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Output contract
|
||||
|
||||
Mark this task `in_progress` before the RED tests and `done` only after the
|
||||
listed commands pass. Record the badge layout decision for the two-badge case,
|
||||
the i18n paths appended to the guard list, the pseudo-locale check result, and
|
||||
test results in this file and `plan.md`.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
id: "07-dependencies-board-view"
|
||||
title: "Dependencies board view"
|
||||
status: dropped
|
||||
wave: 7
|
||||
depends_on: ["06-kanban-dependency-ui"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/task-dependencies/spec.md"
|
||||
---
|
||||
|
||||
# Task 07: Dependencies Board View
|
||||
|
||||
> **Dropped.** This view was implemented and then removed before shipping. Two
|
||||
> reasons: it rendered edges as per-node text lists rather than drawn connectors,
|
||||
> so a chain had to be read rather than followed; and it was scoped to one
|
||||
> workflow, so it could not show a chain that crossed workflows. The per-task
|
||||
> chip plus `list_related_tasks_kandev` cover reading the graph for now. If this
|
||||
> is revived, draw real connectors with the existing
|
||||
> `components/kanban/graph2-connector.tsx` primitives and add a focus-one-chain
|
||||
> filter — and remember that a `VIEW_REGISTRY` entry is not reachable until the
|
||||
> `TaskListingView` vocabulary and the header toggle learn about it.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A third `VIEW_REGISTRY` entry, "Dependencies", is registered in
|
||||
`apps/web/lib/kanban/view-registry.ts` alongside `kanban` and `graph2`, with
|
||||
its own stored value, label, and icon.
|
||||
- The view renders the dependency DAG for the active workflow from
|
||||
`GET /api/v1/workflows/:id/dependencies` plus the tasks already in the store:
|
||||
one node per task, one directed connector per edge, drawn in dependency order.
|
||||
- Blocked nodes are visually distinguished from unblocked ones, and
|
||||
`blocked_reason: "failed"` is distinguished from `"pending"`.
|
||||
- Edges that leave the active workflow but stay in the workspace are marked as
|
||||
such rather than dropped, so a cross-workflow dependency is not invisible.
|
||||
- Dragging from a node's outgoing handle to another node creates "target depends
|
||||
on source". Deleting a connector removes the edge. Both go through the same
|
||||
task-scoped endpoints and the same optimistic-mutation and cycle-error
|
||||
handling as the picker.
|
||||
- A rejected edge (cycle, cross-workspace) reverts the optimistic connector and
|
||||
surfaces the formatted cycle path.
|
||||
- A node click opens the task; the view does not become a second task editor.
|
||||
- The view is desktop-only. `getEffectiveView` already forces the Kanban view on
|
||||
mobile; a test pins that the Dependencies view is not in the mobile view set.
|
||||
- All new copy goes through `t()` / `<Trans>`, the new paths are appended to
|
||||
`i18nGuardFiles` in the same change, and the pseudo-locale shows no English
|
||||
in the view.
|
||||
- No new charting or graph-layout dependency is added without first checking
|
||||
whether the existing connector primitives in `components/kanban/graph2-*.tsx`
|
||||
cover the need.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Failing test: the view builder turns an edge payload plus store tasks into
|
||||
nodes and directed edges in dependency order.
|
||||
2. Failing test: blocked, failed, and unblocked nodes get distinct markers.
|
||||
3. Failing test: a cross-workflow edge is marked, not dropped.
|
||||
4. Failing test: drag-to-connect calls the create endpoint with the correct
|
||||
direction and applies the edge optimistically.
|
||||
5. Failing test: a `409` with a `cycle` body reverts the connector and shows the
|
||||
formatted path.
|
||||
6. Failing test: connector delete calls the delete endpoint and removes the
|
||||
edge.
|
||||
7. Failing test: the Dependencies view is absent from the mobile view set.
|
||||
8. Implement the registry entry, the view component, and the graph interactions.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
pnpm test -- --run lib/kanban/view-registry components/kanban/dependencies
|
||||
pnpm run typecheck
|
||||
pnpm run lint
|
||||
pnpm run i18n:check
|
||||
pnpm run i18n:ratchet
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/lib/kanban/view-registry.ts`
|
||||
- `apps/web/components/kanban/dependencies-*.tsx` (new)
|
||||
- `apps/web/components/kanban/graph2-connector.tsx` (reuse, if extended)
|
||||
- `apps/web/hooks/use-kanban-display-settings.ts`,
|
||||
`apps/web/lib/task-listing/view-preference.ts`
|
||||
- `apps/web/lib/api/domains/` (workflow dependency-graph client)
|
||||
- `apps/web/eslint.i18n.options.mjs`
|
||||
- focused `*.test.tsx` / `*.test.ts` files
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 06 — reuses its API client, mutation handling, and cycle-error formatting.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Output contract
|
||||
|
||||
Mark this task `in_progress` before the RED tests and `done` only after the
|
||||
listed commands pass. Record whether existing connector primitives were reused
|
||||
or a new layout approach was needed and why, the stored view value, the i18n
|
||||
paths appended, and test results in this file and `plan.md`.
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
id: "08-verify-flows-and-docs"
|
||||
title: "Verify browser flows and documentation"
|
||||
status: done
|
||||
wave: 8
|
||||
depends_on: ["04-failed-predecessor-halt", "06-kanban-dependency-ui"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/task-dependencies/spec.md"
|
||||
---
|
||||
|
||||
# Task 08: Verify Browser Flows and Documentation
|
||||
|
||||
## Acceptance
|
||||
|
||||
Browser E2E, covering the spec's scenarios end to end rather than in units:
|
||||
|
||||
- **Desktop chain**: seed A → B → C with start-when-unblocked intents; complete
|
||||
A and assert B starts and C does not; complete B and assert C starts and A is
|
||||
not restarted.
|
||||
- **Desktop gate**: a task with a pending predecessor moved into an auto-start
|
||||
step gains no session.
|
||||
- **Desktop WIP interaction**: a dependent queued for a full WIP step gains no
|
||||
session when its dependency resolves, and starts exactly once after capacity
|
||||
opens.
|
||||
- **Desktop failure**: fail A and assert B stays blocked with the failed reason,
|
||||
gains no session, and shows the notification; retry A to success and assert B
|
||||
starts exactly once.
|
||||
- **Desktop cycle**: attempting to close a cycle surfaces the cycle path and
|
||||
creates no edge.
|
||||
- **Desktop chip**: open task B where B depends on A and D depends on B; assert
|
||||
the dependency chip appears in the status row above the composer, lists A under
|
||||
blocked-by and D under blocks with their states, and navigates to A on click.
|
||||
Assert the chip is absent on a task with no edges.
|
||||
- **Desktop chip with no PR**: a blocked task with no PR, no todos, and no queued
|
||||
prompts still renders the status row with the chip.
|
||||
- **MCP chain**: drive `create_task_kandev` three times with `blocked_by` and the
|
||||
default `start_agent`, assert no session starts on creation, then complete the
|
||||
first and assert the second starts and the third does not. Then exercise
|
||||
`add_task_dependency_kandev` / `remove_task_dependency_kandev` against a live
|
||||
task and assert the board reflects both.
|
||||
- **Mobile**: the blocked badge is readable in a focused column with no
|
||||
horizontal page overflow and usable tap targets, and tapping the dependency
|
||||
chip opens the drawer rather than a hover card. There is no Dependencies view
|
||||
and no detail-view picker to reach (Task 07 dropped; see `plan.md`).
|
||||
|
||||
Seed tasks **without** an agent and locate them by task id. `createTaskWithAgent`
|
||||
on a start step moves the card mid-test and races column assertions.
|
||||
|
||||
Documentation:
|
||||
|
||||
- `docs/public/tasks-and-workflows.md` documents declaring a dependency, what
|
||||
counts as resolution (successful completion or a final Done/Complete/
|
||||
Completed/Approved step), that a failed or cancelled predecessor halts the
|
||||
chain and needs human action, that dependencies gate automated starts but not
|
||||
manual ones, and that resolution does not bypass WIP limits.
|
||||
- `docs/public/workflow-tips.md` gains a worked A → B → C example if the
|
||||
auto-start interaction needs one.
|
||||
- `docs/features.md` lists task dependencies in the live feature inventory.
|
||||
- The spec's `status` moves to `shipped` and its Open questions section is
|
||||
empty or deleted.
|
||||
- `docs/specs/INDEX.md` and this plan's statuses are accurate.
|
||||
- `apps/web/AGENTS.md` is untouched: Task 07's board view was dropped, so no
|
||||
view-registry convention changed.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Write each E2E spec red against the implemented backend/frontend, one flow at
|
||||
a time, and fix what they catch.
|
||||
2. Run the mobile project explicitly; do not infer mobile behavior from the
|
||||
desktop run.
|
||||
3. Update public docs and the spec/plan statuses last, once behavior is settled.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
pnpm e2e:raw --grep 'dependenc'
|
||||
pnpm e2e:raw --project=mobile-chrome --grep 'dependenc'
|
||||
cd ../backend && go test -tags fts5 ./... -count=1
|
||||
```
|
||||
|
||||
A full backend suite in a task worktree fails roughly four packages with
|
||||
`parent directory cannot be accessed`; that is the sandbox, not this change.
|
||||
Confirm those are the only failures and that they reproduce on a clean checkout
|
||||
of `main`.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/task/task-dependencies.spec.ts` (new)
|
||||
- `apps/web/e2e/helpers/` (seeding helper for edges/intents)
|
||||
- `docs/public/tasks-and-workflows.md`
|
||||
- `docs/public/workflow-tips.md`
|
||||
- `docs/features.md`
|
||||
- `docs/specs/task-dependencies/spec.md`
|
||||
- `docs/specs/INDEX.md`
|
||||
- `docs/plans/task-dependencies/plan.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tasks 04 and 06: the last backend and frontend behaviors respectively.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Recorded results
|
||||
|
||||
`apps/web/e2e/tests/task/task-dependencies.spec.ts`, chromium, 5 passed:
|
||||
|
||||
- a chain runs one step at a time and never restarts a predecessor
|
||||
- a failed predecessor halts the chain until it succeeds
|
||||
- removing the last edge unblocks without launching
|
||||
- a cycle is refused with the offending path
|
||||
- the board and the open task both show the dependency
|
||||
|
||||
The first run failed 3 of the 5, and both causes were real product bugs that
|
||||
every unit test had missed because neither crosses a single package:
|
||||
|
||||
1. **WIP admission erased the launch intent.** The SQLite repository deleted
|
||||
`metadata.deferred_launch` on every admitted create, because a WIP-overflow
|
||||
record is stale once a task is admitted. The start-when-unblocked intent
|
||||
shares that record, and a chain step is admitted at create time like any
|
||||
other task, so the intent was gone before any predecessor could resolve it
|
||||
and no chain ever ran. Admission now routes through
|
||||
`models.DropWIPDeferredLaunch`, which keeps a chain intent and drops only an
|
||||
overflow one (`internal/task/models/deferred_launch_test.go`, mutation-
|
||||
verified).
|
||||
2. **The status row was hidden on session-less tasks.** The dockview hosts pass
|
||||
the panel's task only when it has a session, so `ChatStatusBar` saw a null
|
||||
task id and rendered nothing, taking the dependency chip with it on exactly
|
||||
the tasks the chip describes: a blocked step has no session by construction.
|
||||
The hosts now pass a status-row-only `statusTaskId`
|
||||
(`resolveStatusRowTaskId`), leaving the session-derived id, plan mode, and
|
||||
read tracking untouched.
|
||||
|
||||
Backend: `go test ./internal/task/... ./internal/orchestrator/... ./internal/mcp/...`
|
||||
fails only the 12 known worktree-sandbox tests (`parent directory cannot be
|
||||
accessed`, local-repository initialization and directory listing), the identical
|
||||
set before and after this change. Changed-file lint against the PR base is
|
||||
clean. Frontend: 1363 files / 11090 tests pass, lint clean, `i18n:check` and
|
||||
`i18n:ratchet` clean.
|
||||
|
||||
Not run: the mobile project. The mobile assertions in the acceptance list above
|
||||
are still outstanding.
|
||||
|
||||
## Output contract
|
||||
|
||||
Mark this task `in_progress` before the first E2E run and `done` only after the
|
||||
listed commands pass. Record each E2E spec name, the mobile run result, the
|
||||
exact set of pre-existing backend failures with evidence they are environmental,
|
||||
and the docs updated in this file and `plan.md`.
|
||||
@@ -187,6 +187,39 @@ Task tools use normal client discovery. When `step_complete_kandev` is required
|
||||
|
||||
`create_task_kandev` advertises `prompt` for instructions delivered to a newly started agent. Older callers may still send `description` when `prompt` is absent, but sending both is an error; the compatibility name is intentionally omitted from the advertised schema.
|
||||
|
||||
### Task dependencies over MCP
|
||||
|
||||
`create_task_kandev` accepts `blocked_by`, a list of task IDs the new task must
|
||||
wait on. Two further tools manage links on an existing task:
|
||||
|
||||
- `add_task_dependency_kandev` with `depends_on_task_id`, and an optional
|
||||
`task_id`.
|
||||
- `remove_task_dependency_kandev` with the same arguments. Removing a link that
|
||||
is not there succeeds.
|
||||
|
||||
In a task-bound session, `task_id` defaults to the agent's own task and may only
|
||||
name that task; use `depends_on_task_id` for the other end. The link is
|
||||
authorized against both tasks, and a link that would close a cycle is rejected
|
||||
with the offending path.
|
||||
|
||||
**Use a dependency for ordering, a subtask for decomposition.** Reaching for
|
||||
`parent_id` to express "do this one first" gets neither the start gate nor the
|
||||
chain: children run whenever they are started, and a parent's completion signal
|
||||
fires when all children reach any terminal state, including failure.
|
||||
|
||||
An agent decomposing a plan into ordered steps should create each step with
|
||||
`blocked_by` pointing at the previous one and leave `start_agent` at its
|
||||
default. A create that declares dependencies does not launch immediately: the
|
||||
requested start is recorded and fires once every predecessor has completed
|
||||
successfully, so three chained creates produce a chain rather than three agents
|
||||
in the same repository at once. Pass `start_when_unblocked: false` to create the
|
||||
links with no launch intent at all. With no `blocked_by`, `start_when_unblocked`
|
||||
records nothing and the task starts or does not start purely per `start_agent`.
|
||||
|
||||
Only success resolves a link. A predecessor that fails or is cancelled leaves
|
||||
its dependents blocked with a reason naming it, and Kandev does not retry it or
|
||||
drop the link on its own.
|
||||
|
||||
### Autopilot tasks and MCP profiles
|
||||
|
||||
Task creation accepts one optional boolean:
|
||||
|
||||
@@ -90,6 +90,10 @@
|
||||
"apps/backend/internal/mcp/handlers/stop_task.go",
|
||||
"apps/backend/internal/mcp/server/handlers.go",
|
||||
"apps/backend/internal/mcp/server/server.go",
|
||||
"apps/backend/internal/mcp/server/dependency_handlers.go",
|
||||
"apps/backend/internal/mcp/handlers/dependency_handlers.go",
|
||||
"apps/backend/internal/task/service/service_dependencies.go",
|
||||
"apps/backend/internal/orchestrator/event_handlers_dependencies.go",
|
||||
"apps/backend/internal/gateway/websocket/client.go"
|
||||
],
|
||||
"tests": [
|
||||
@@ -102,16 +106,20 @@
|
||||
"apps/backend/internal/mcp/server/sysprompt_sync_test.go",
|
||||
"apps/backend/internal/backendapp/mcp_stop_composition_test.go",
|
||||
"apps/backend/internal/integration/mcp_stop_task_test.go",
|
||||
"apps/backend/internal/gateway/websocket/client_mcp_actions_test.go"
|
||||
"apps/backend/internal/gateway/websocket/client_mcp_actions_test.go",
|
||||
"apps/backend/internal/task/service/service_dependencies_test.go",
|
||||
"apps/backend/internal/orchestrator/event_handlers_dependencies_test.go"
|
||||
],
|
||||
"mcpTools": [
|
||||
"add_branch_to_task_kandev",
|
||||
"add_task_dependency_kandev",
|
||||
"add_workspace_sources_kandev",
|
||||
"archive_task_kandev",
|
||||
"create_task_kandev",
|
||||
"delete_task_kandev",
|
||||
"list_tasks_kandev",
|
||||
"move_task_kandev",
|
||||
"remove_task_dependency_kandev",
|
||||
"set_task_title_kandev",
|
||||
"step_complete_kandev",
|
||||
"stop_task_kandev",
|
||||
|
||||
@@ -228,6 +228,75 @@ The preference only gates opening a task. Choosing **Start agent** (or a
|
||||
workflow step transition) always starts the agent as usual, and a failed or
|
||||
interrupted session still shows its recovery actions.
|
||||
|
||||
## Task dependencies
|
||||
|
||||
A task can declare that it **depends on** one or more other tasks. This is a
|
||||
peer relationship and is separate from the parent/child subtask hierarchy: a
|
||||
subtask says "B is part of A", a dependency says "B cannot start until A
|
||||
finishes". The two can be combined freely, including a dependency between a
|
||||
task and its own child.
|
||||
|
||||
Dependencies form a graph, not just a line. A task can wait on several
|
||||
predecessors and can itself block several dependents. A link that would close a
|
||||
cycle is rejected when you try to create it, and the offending path is shown so
|
||||
you can see which link to drop.
|
||||
|
||||
### Declare dependencies
|
||||
|
||||
Dependencies are declared in the **New Task** dialog under **Depends on**, or
|
||||
by an agent over MCP. There is deliberately no editor in the open task: a
|
||||
dependency records how the work was planned, so the surfaces that display it
|
||||
stay read-only. To change one after the fact, use the MCP tools or delete and
|
||||
recreate the task.
|
||||
|
||||
### What blocked means
|
||||
|
||||
A task with at least one unfinished predecessor is **blocked**. Blocked tasks
|
||||
show a badge on their Kanban card and a dependency chip in the status row above
|
||||
the chat box, next to the pull request chip. The chip reports both directions,
|
||||
the tasks this one waits on and the tasks waiting on it, and each entry links
|
||||
to that task.
|
||||
|
||||
While a task is blocked, no automated path starts it. That covers workflow
|
||||
**On Enter** auto-start, promotion out of a WIP queue, integration watchers,
|
||||
and dependency resolution itself. You can still press **Start agent**
|
||||
yourself; a manual start is an explicit override, not an error.
|
||||
|
||||
### Chains that run themselves
|
||||
|
||||
A task created with dependencies and an agent start request does not launch
|
||||
immediately. It records the start as an intent, and Kandev launches it once
|
||||
every predecessor has completed successfully. Setting that up along a path
|
||||
produces a chain:
|
||||
|
||||
1. Create task A normally.
|
||||
2. Create task B with **Depends on** set to A.
|
||||
3. Create task C with **Depends on** set to B.
|
||||
|
||||
Starting A is the only manual step. When A completes, B starts. When B
|
||||
completes, C starts. A is never restarted.
|
||||
|
||||
Auto-start grants eligibility, never a bypass. If a task's dependencies have
|
||||
resolved but the target step is at its WIP limit, the task stays queued and
|
||||
launches when the queue promotes it, exactly as any other queued task would.
|
||||
|
||||
### When a predecessor does not succeed
|
||||
|
||||
Only successful completion resolves a dependency. A predecessor that ends in
|
||||
**Failed** or **Cancelled** leaves its dependents blocked, and the blocked
|
||||
reason names the failed task rather than reporting a generic wait. The chain
|
||||
stops there and waits for you. Kandev never retries a failed predecessor on its
|
||||
own and never quietly drops the link.
|
||||
|
||||
Three things clear it, all of them deliberate: retry the predecessor until it
|
||||
succeeds, remove the link over MCP, or start the dependent manually.
|
||||
|
||||
An **archived** predecessor is treated as unfinished, not as failed and not as
|
||||
resolved, so archiving a task does not release the work waiting on it.
|
||||
**Deleting** a task does remove its links in both directions, and any dependent
|
||||
that was waiting only on it becomes unblocked. That dependent is not started:
|
||||
deletion is not success.
|
||||
|
||||
## Find and organize tasks
|
||||
|
||||
On desktop and tablet, the header switches between **Kanban**, **Pipeline**, and **List**. Kanban and Pipeline show the same workflow steps in different layouts. Kandev remembers the last selected view in that browser on the current device. Phones offer **Kanban** and **List** only; a saved desktop Pipeline preference is kept but shown as Kanban on the phone.
|
||||
@@ -409,9 +478,9 @@ Revision history is not an immutable record of every autosave. Consecutive write
|
||||
| One versioned task plan | Available | Available in Office-specific surfaces where enabled |
|
||||
| Multiple named task documents | Not exposed | In-progress Office capability |
|
||||
| Task label editor and label filters | Not exposed | In-progress Office capability |
|
||||
| Blocked-by / blocking property editor | Not exposed | In-progress Office capability |
|
||||
| Blocked-by / blocking property editor | Set at task creation or over MCP; read-only afterwards | In-progress Office capability |
|
||||
|
||||
Stored related-task data can include blocker relationships, but regular Kanban has no blocker editor or blocker filter. Use workflow gates, direct-child completion, and explicit messages for supported Kanban coordination. Do not treat Office's named documents, labels, or blocker UI as a stable public contract yet.
|
||||
Regular Kanban reads and enforces blocker relationships (see [Task dependencies](#task-dependencies)) but has no blocker filter and no in-place editor: dependencies are declared when the task is created or over MCP. Office additionally exposes named documents, labels, and its own blocker property editor. Do not treat those Office surfaces as a stable public contract yet.
|
||||
|
||||
## Archive, unarchive, and delete
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su
|
||||
| [subtask-detachment](tasks/subtask-detachment.md) | shipped |
|
||||
| [subtask-reparenting-drag-drop](tasks/subtask-reparenting-drag-drop.md) | building |
|
||||
| [subtask-completion-trigger](tasks/subtask-completion-trigger.md) | draft |
|
||||
| [task-dependencies](task-dependencies/spec.md) | draft |
|
||||
| [subtree-controls](tasks/subtree-controls.md) | shipped |
|
||||
| [blocked-task-escalation](tasks/blocked-task-escalation.md) | draft |
|
||||
| [runtime-cleanup](tasks/runtime-cleanup.md) | draft |
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
---
|
||||
status: shipped
|
||||
created: 2026-08-09
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
# Task Dependencies and Auto-Start Chains
|
||||
|
||||
## Why
|
||||
|
||||
A user who splits work into ordered steps — "scaffold the schema", then "write
|
||||
the API on top of it", then "wire the UI" — has no way to express that order in
|
||||
Kandev. The parent/child subtask hierarchy says *"B is part of A"*, never *"B
|
||||
cannot start until A finishes"*. So the user either starts everything at once
|
||||
and lets agents collide in the same repository, or babysits the board and
|
||||
presses Start by hand each time a predecessor lands. Both defeat the point of
|
||||
running agents unattended.
|
||||
|
||||
## What
|
||||
|
||||
- A task SHALL be able to declare that it **depends on** one or more other
|
||||
tasks. The relationship is peer-to-peer and independent of `parent_id`: a
|
||||
dependency may exist between two unrelated tasks, between siblings, or
|
||||
between a task and its own child.
|
||||
- The dependency graph is a **general DAG**, not a linear chain. A task may
|
||||
have any number of predecessors and any number of dependents. Cycles SHALL be
|
||||
rejected at edge-creation time with the offending path surfaced to the user.
|
||||
- A task with at least one unresolved dependency is **blocked**. Blocked SHALL
|
||||
be visible on the Kanban card, in the task detail view, and in the task API
|
||||
payload.
|
||||
- A blocked task SHALL NOT be auto-started by any automated path — workflow
|
||||
`on_enter: auto_start_agent`, WIP queue promotion, integration watchers, or
|
||||
dependency resolution itself. The gate applies to every automated launch, not
|
||||
only to the dependency-resolution path.
|
||||
- A user MAY still start a blocked task manually. Manual start is an explicit
|
||||
override, not a bug; the UI names it as such.
|
||||
- A task MAY carry the intent **"start automatically when unblocked"**. When
|
||||
every dependency of such a task resolves successfully, Kandev launches it
|
||||
exactly once with the agent profile, executor, executor profile, prompt, and
|
||||
plan-mode choice recorded when the intent was created.
|
||||
- Setting that intent on each link of a chain produces sequential execution:
|
||||
A finishes → B starts → B finishes → C starts. No separate "chain" entity
|
||||
exists; a chain is a path through the DAG.
|
||||
- Auto-start on unblock respects every existing admission rule. A task whose
|
||||
dependencies have resolved but which is not WIP-admitted (`wip_admitted =
|
||||
false`) stays queued and launches when the WIP queue promotes it. Dependency
|
||||
resolution grants **eligibility**, never a bypass.
|
||||
- A dependency resolves only on **successful** completion of the predecessor.
|
||||
`FAILED`, `CANCELLED`, and archival do not resolve a dependency. The
|
||||
dependent task stays blocked and its blocked reason changes to name the
|
||||
failed predecessor, so a chain never proceeds on a failed step.
|
||||
- A dependency-failed state SHALL raise a notification and SHALL require
|
||||
explicit human action to clear (retry the predecessor, remove the edge, or
|
||||
start the dependent manually). Kandev never auto-retries a failed
|
||||
predecessor and never silently drops the edge.
|
||||
- Users declare dependencies in the **task-create dialog** ("Depends on") and
|
||||
manage them afterwards over MCP. The task detail view deliberately has no edge
|
||||
editor: dependencies describe how work was planned, and the surfaces that read
|
||||
them (card badge, chip, graph) stay read-only.
|
||||
- An open task SHALL show a **dependency chip** in the status row directly above
|
||||
its chat composer, alongside the PR status chip. The chip reports both
|
||||
directions — the tasks this task is blocked by, and the tasks it blocks — and
|
||||
opens to a list of them with their states, each navigable to that task. It is
|
||||
absent when the task has no edges in either direction.
|
||||
- Agents SHALL be able to declare and manage dependencies over MCP: set them
|
||||
when creating a task, and add or remove them on an existing task. The tools
|
||||
SHALL teach when a dependency is the right relationship rather than a subtask,
|
||||
because an agent that reaches for `parent_id` to express ordering gets neither
|
||||
the gate nor the chain.
|
||||
- Creating a task with dependencies and an agent-start request SHALL record the
|
||||
start as a start-when-unblocked intent rather than launching immediately. An
|
||||
automated caller that asks for both is describing a chain step, not a task to
|
||||
run now.
|
||||
- Deleting a task removes its edges in both directions. Dependents whose last
|
||||
unresolved dependency was the deleted task become unblocked, and a
|
||||
`start-when-unblocked` intent on such a dependent SHALL NOT fire — deletion
|
||||
is not success.
|
||||
|
||||
### Relationship to existing mechanisms
|
||||
|
||||
This feature deliberately extends four shipped mechanisms rather than adding
|
||||
parallel ones. The reuse is part of the contract because the guarantees below
|
||||
(single launch, restart survival, WIP interaction) are inherited from them.
|
||||
|
||||
| Existing mechanism | Role here |
|
||||
|---|---|
|
||||
| `task_blockers` table + BFS cycle detection (`office/dashboard.AddTaskBlocker`) | The dependency edge store and cycle validator, promoted from Office-only to a core task relationship. |
|
||||
| Deferred launch intent (`tasks.metadata.deferred_launch`, claimed atomically) | Storage and idempotent consumption of "start automatically when unblocked". |
|
||||
| The single auto-start chokepoint (`orchestrator.autoStartTaskForStep`) | Where the dependency gate lives and where dependency-triggered launches enter, so the existing auto-start claim guard prevents double launches. |
|
||||
| WIP admission and queue promotion ([`tasks/wip-limit-pull-system`](../tasks/wip-limit-pull-system.md)) | Decides whether an unblocked, auto-start-intent task launches now or on promotion. |
|
||||
|
||||
Two existing behaviors are intentionally *not* changed:
|
||||
|
||||
- **Parent/child completion signalling** ([`tasks/subtask-completion-trigger`](../tasks/subtask-completion-trigger.md))
|
||||
fires `on_children_completed` when all children reach any terminal state,
|
||||
including `FAILED`. Dependency resolution uses a stricter definition
|
||||
(success only). The two must not be unified.
|
||||
- **Office's blocker reaction** (`office/scheduler.cascadeBlockersResolved`)
|
||||
queues an Office run to the blocked task's *assignee* when a blocker's status
|
||||
becomes done. It stays as-is. For a task that qualifies for both reactions,
|
||||
the shared auto-start claim guarantees exactly one launch.
|
||||
|
||||
## Data model
|
||||
|
||||
### `task_blockers` (existing, reused)
|
||||
|
||||
```text
|
||||
task_id TEXT the dependent task (blocked)
|
||||
blocker_task_id TEXT the predecessor task (blocking)
|
||||
created_at TIMESTAMP
|
||||
PRIMARY KEY (task_id, blocker_task_id)
|
||||
CHECK (task_id != blocker_task_id)
|
||||
```
|
||||
|
||||
- The row reads "`task_id` is blocked by `blocker_task_id`".
|
||||
- Edges are workspace-local: both tasks MUST belong to the same workspace.
|
||||
- No new column is added. Edge deletion on task delete/archive is enforced by
|
||||
the owning repository, not by a SQLite `ON DELETE CASCADE`, because the table
|
||||
predates the foreign key.
|
||||
- The table currently ships from the Office repository's
|
||||
`createTaskExtensionTables`. Reading and writing it becomes a core task
|
||||
concern; the Office reader methods stay valid against the same rows.
|
||||
|
||||
### `tasks.metadata.deferred_launch` (existing, reused)
|
||||
|
||||
The start-when-unblocked intent is a `deferred_launch` metadata record on the
|
||||
**dependent** task, identical in shape to the one WIP overflow already
|
||||
persists: resolved agent profile, executor, executor profile, prompt,
|
||||
plan-mode choice, priority, and attachments. It is claimed atomically before
|
||||
launch and restored on launch failure.
|
||||
|
||||
A task therefore has at most one launch intent regardless of how many
|
||||
predecessors it has. "Which edge triggers the start" is not a question this
|
||||
model can ask — the answer is always "the last one to resolve".
|
||||
|
||||
### Derived state (not persisted)
|
||||
|
||||
`blocked`, `blocked_reason`, and the predecessor list are computed from
|
||||
`task_blockers` plus the predecessors' own state on every read. There is no
|
||||
denormalized `is_blocked` column: a stale copy of it would gate launches
|
||||
incorrectly, which is the one failure this feature must not have.
|
||||
|
||||
A predecessor is **resolved** when it satisfies the existing successful-completion
|
||||
convention: `state = COMPLETED`, or resident in a final workflow step whose name
|
||||
is `Done`, `Complete`, `Completed`, or `Approved` (`wfmodels.IsTerminalStepName`,
|
||||
which also persists `state = COMPLETED`).
|
||||
|
||||
A predecessor is **failed** when `state` is `FAILED` or `CANCELLED`.
|
||||
|
||||
A predecessor is **pending** in every other case, including archived.
|
||||
|
||||
## API surface
|
||||
|
||||
### HTTP
|
||||
|
||||
Existing Office-scoped routes are promoted to task-scoped equivalents. The
|
||||
Office paths remain for compatibility with the Office task pane.
|
||||
|
||||
```text
|
||||
POST /api/v1/tasks/:id/dependencies {"depends_on_task_id": "<id>"}
|
||||
DELETE /api/v1/tasks/:id/dependencies/:depId
|
||||
```
|
||||
|
||||
- `POST` returns `409` with `{"error": "...", "cycle": ["A","B","C","A"]}` when
|
||||
the edge would close a cycle. The `cycle` body shape matches what
|
||||
`BlockersPicker` already parses.
|
||||
- `POST` returns `400` for a self-edge or a cross-workspace edge.
|
||||
- `DELETE` of an absent edge is a success no-op.
|
||||
|
||||
### Task payload
|
||||
|
||||
Task DTOs returned over HTTP, WebSocket boot, WebSocket events, and MCP gain:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocked": true,
|
||||
"blocked_reason": "pending",
|
||||
"depends_on": [
|
||||
{ "id": "task-a", "title": "Add the schema migration", "state": "COMPLETED", "status": "resolved" },
|
||||
{ "id": "task-b", "title": "Expose the edges on the API", "state": "TODO", "status": "pending" }
|
||||
],
|
||||
"blocks": [{ "id": "task-d", "title": "Render the chip", "state": "TODO" }],
|
||||
"start_when_unblocked": true
|
||||
}
|
||||
```
|
||||
|
||||
- `blocked_reason` is one of `"pending"` (at least one predecessor unfinished),
|
||||
`"failed"` (no predecessor unfinished, at least one failed), `"unknown"` (the
|
||||
dependency store could not be read, so the gate failed closed), or omitted
|
||||
when `blocked` is false. A client renders `"unknown"` as blocked without a
|
||||
named cause; it must not present it as resolved.
|
||||
- `depends_on` lists direct predecessors only, in `created_at` order.
|
||||
- `blocks` lists direct dependents only, in `created_at` order. It exists so the
|
||||
dependency chip can show both directions without a second round trip, and so
|
||||
an agent can see what is waiting on it.
|
||||
- Both lists carry enough per-entry detail for the chip to render without
|
||||
fetching each task: id, title, and state. Neither list is transitive.
|
||||
- The projection is **absent, not empty, when a payload does not compute it**.
|
||||
Every boot payload and list read computes it; lightweight `task.updated` events
|
||||
do not. A client that reads an omitted projection as "no edges" erases the real
|
||||
edges, so absent MUST mean "unknown" and an explicit empty list MUST mean "no
|
||||
edges". A reader that needs certainty re-reads the task.
|
||||
- `start_when_unblocked` reflects the presence of a launch intent tied to
|
||||
dependency resolution. It is read-only on the task payload; it is set through
|
||||
the create request or the dependency picker.
|
||||
|
||||
### Task create
|
||||
|
||||
`POST /api/v1/tasks` already accepts `blocked_by: ["<id>", …]`. It gains:
|
||||
|
||||
```json
|
||||
{ "blocked_by": ["task-a"], "start_when_unblocked": true }
|
||||
```
|
||||
|
||||
When `start_when_unblocked` is true **and `blocked_by` is non-empty**, the
|
||||
create request's agent/executor/prompt resolution is recorded as the deferred
|
||||
launch intent instead of launching now.
|
||||
|
||||
`start_when_unblocked: true` with an empty `blocked_by` records no intent. The
|
||||
task is born unblocked, so there is no later moment at which it would fire; a
|
||||
create that also asked to start an agent launches immediately, exactly as it
|
||||
would without the flag, and one that did not launches nothing. The flag defers
|
||||
a start, it never invents one. Adding a dependency to that task afterwards
|
||||
gates its *automated* starts but does not retroactively create an intent.
|
||||
|
||||
When `blocked_by` is non-empty and `start_when_unblocked` is omitted, it defaults
|
||||
to the request's agent-start intent: a create that asked to start an agent
|
||||
records the intent, and a create that did not records no intent. The response
|
||||
reports `started` and `start_when_unblocked` so the caller never has to infer
|
||||
which happened. This rule is what makes an automated caller's habitual
|
||||
`start_agent: true` build a chain instead of launching every step at once.
|
||||
|
||||
### WebSocket events
|
||||
|
||||
- `task.updated` with `fields: ["dependencies"]` on edge add/remove.
|
||||
- `task.dependencies_resolved` — published when the last unresolved dependency
|
||||
of a task resolves successfully. Payload: `{task_id, resolved_by_task_id}`.
|
||||
This is the event whose handler enters the auto-start chokepoint.
|
||||
- `task.dependency_failed` — published when a predecessor of a blocked task
|
||||
reaches `FAILED` or `CANCELLED`. Payload:
|
||||
`{task_id, failed_task_id, failed_state}`.
|
||||
|
||||
### Notifications
|
||||
|
||||
A new notification event type `task.dependency_failed` follows the existing
|
||||
constants in `internal/notifications/service`. It fires once per
|
||||
(dependent, predecessor) failure transition.
|
||||
|
||||
### MCP
|
||||
|
||||
`create_task_kandev`'s handler already reads a `blocked_by` array from its
|
||||
payload, but the tool schema never declares it, so no agent can discover or pass
|
||||
it. Declaring it is the minimum; the tools below make chains expressible.
|
||||
|
||||
- `create_task_kandev` declares `blocked_by` (array of task IDs) and
|
||||
`start_when_unblocked` (boolean).
|
||||
- **`blocked_by` plus `start_agent: true` records a start-when-unblocked intent
|
||||
and launches nothing now.** `start_agent` defaults to true and every agent
|
||||
passes it by habit, so the alternative — launching a blocked task immediately —
|
||||
would silently break every chain an agent tries to build. The create response
|
||||
reports `started: false` with `start_when_unblocked: true` so the caller can
|
||||
see what happened. Passing `start_when_unblocked: false` alongside
|
||||
`blocked_by` creates the edges with no launch intent at all.
|
||||
- `add_task_dependency_kandev` — `{task_id?, depends_on_task_id}`. `task_id`
|
||||
defaults to the calling task. Returns the resulting `depends_on` list.
|
||||
Rejects a cycle with the cycle path, a self-edge, and a cross-workspace edge.
|
||||
- `remove_task_dependency_kandev` — `{task_id?, depends_on_task_id}`. Removing
|
||||
an absent edge succeeds. Removing the last edge unblocks the task without
|
||||
launching it.
|
||||
- `list_related_tasks_kandev` is the read side and already returns `blockers` and
|
||||
`blocked_by`. Those groups become populated in Kanban-only installs, where
|
||||
they are empty today because the blocker repository is wired inside the
|
||||
Office-enabled branch.
|
||||
- Tool descriptions SHALL state when to use a dependency instead of a subtask: a
|
||||
subtask means "part of", a dependency means "not until". Decomposing a plan
|
||||
into ordered phases is N sibling tasks chained with `blocked_by` and
|
||||
`start_when_unblocked`, not N subtasks started at once. The description SHALL
|
||||
also state that a failed predecessor halts the chain and needs human action, so
|
||||
an agent does not build a chain expecting it to self-heal.
|
||||
- An agent cannot set `start_when_unblocked` on a task it did not create; it uses
|
||||
the dependency tools for edges and leaves launch intent to the task's owner.
|
||||
|
||||
## State machine
|
||||
|
||||
Dependency state is derived, so the machine below describes the *dependent*
|
||||
task's gating status rather than a persisted column.
|
||||
|
||||
```text
|
||||
edge added predecessor resolved (not last)
|
||||
unblocked ──────────────────────> blocked ──────────────────────────> blocked
|
||||
^ │ ^ │
|
||||
│ last edge removed │ │ predecessor reopened │
|
||||
│ or last predecessor resolved │ │ │
|
||||
└──────────────────────────────┘ └──────────────────────────────────┘
|
||||
│
|
||||
predecessor FAILED/CANCELLED
|
||||
v
|
||||
blocked (reason=failed)
|
||||
│
|
||||
predecessor retried and resolved, OR edge removed
|
||||
v
|
||||
unblocked
|
||||
```
|
||||
|
||||
Transitions and their actors:
|
||||
|
||||
| From | To | Trigger | Actor |
|
||||
|---|---|---|---|
|
||||
| unblocked | blocked | edge created | user, agent (`blocked_by`), API |
|
||||
| blocked | blocked | a predecessor resolves, others remain | system |
|
||||
| blocked | blocked (reason=failed) | a predecessor enters `FAILED`/`CANCELLED` | system |
|
||||
| blocked | unblocked | last unresolved predecessor resolves successfully | system |
|
||||
| blocked | unblocked | last blocking edge removed | user, agent, API |
|
||||
| unblocked | blocked | a resolved predecessor is reopened to a non-terminal state | user, system |
|
||||
|
||||
On the `blocked -> unblocked` transition caused by successful resolution, and
|
||||
only then, Kandev publishes `task.dependencies_resolved` and evaluates
|
||||
auto-start:
|
||||
|
||||
```text
|
||||
task.dependencies_resolved
|
||||
-> task has deferred launch intent? no -> stop (task is merely unblocked)
|
||||
-> task is WIP-admitted? no -> stop; WIP promotion will retry
|
||||
-> claim deferred launch intent lost -> stop; the winner launches
|
||||
-> launch with the recorded parameters
|
||||
```
|
||||
|
||||
Removing the last edge unblocks the task but does **not** fire auto-start: the
|
||||
intent is consumed by dependency *resolution*, not by the absence of
|
||||
dependencies. A user who removes the edge is taking manual control.
|
||||
|
||||
## Permissions
|
||||
|
||||
- Creating or removing an edge requires the same authorization as updating both
|
||||
tasks; both must be in a workspace the caller can access. Cross-workspace
|
||||
edges are rejected, so no new cross-tenant surface exists.
|
||||
- Agents create edges through task creation (`blocked_by`), the two MCP tools
|
||||
(`add_task_dependency_kandev`, `remove_task_dependency_kandev`), and the
|
||||
existing Office blocker endpoints. They cannot set `start_when_unblocked` on
|
||||
a task they did not create.
|
||||
- Every one of those paths authorizes BOTH task IDs against the caller's
|
||||
identity before any read or write, and denials surface as not-found so an
|
||||
edge attempt cannot be used to probe for another user's tasks. In a
|
||||
task-bound MCP session the dependent end is the session's own task, taken
|
||||
from the `AgentExecution` rather than from an agent-supplied identifier; the
|
||||
predecessor is necessarily another task and is authorized on its own.
|
||||
- The dependency gate is not a permission: a user with start rights can start a
|
||||
blocked task manually.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Condition | Required behavior |
|
||||
|---|---|
|
||||
| Edge would create a cycle | `409` with the cycle path; no row written. |
|
||||
| Edge is a self-edge or crosses workspaces | `400`; no row written. |
|
||||
| Predecessor enters `FAILED`/`CANCELLED` | Dependent stays blocked with `blocked_reason: "failed"`; `task.dependency_failed` published; notification raised; no launch. |
|
||||
| Predecessor is archived while a dependent is blocked | Treated as pending, not resolved. The dependent stays blocked and the reason names the archived predecessor. |
|
||||
| Predecessor is deleted | Edge is removed. The dependent may become unblocked but its launch intent does not fire. |
|
||||
| Dependent has no deferred launch intent | It unblocks and waits for a user or for its step's normal `on_enter` behavior on a later move. |
|
||||
| Dependent is not WIP-admitted when its dependencies resolve | No launch. The existing WIP promotion path launches it once capacity opens; the intent survives. |
|
||||
| Dependent's step has a WIP limit that is full | Same as above — the dependency gate and the WIP gate are independent and both must pass. |
|
||||
| Two paths race to launch the same dependent (dependency resolution and WIP promotion) | The existing atomic deferred-launch claim admits one. The loser logs and stops. |
|
||||
| Launch fails after the claim | The claim is restored so the task remains retryable and visibly unstarted, matching the WIP-overflow contract. |
|
||||
| Predecessor lookup fails while evaluating resolution | No launch, warning logged. A later state change on any predecessor re-evaluates. |
|
||||
| Blocker repository is unavailable | Edge writes fail with an error. Reads that fail return the task as **blocked with reason unknown** rather than unblocked — the gate fails closed, because failing open would launch work whose predecessor may not have run. |
|
||||
| A predecessor is reopened after the dependent already launched | No retroactive stop. The dependent shows blocked again; the running session is untouched. |
|
||||
| Dependency graph is large | Cycle detection keeps its existing BFS walk limit; a walk that exceeds the limit rejects the edge rather than accepting an unverified one. |
|
||||
|
||||
## Persistence guarantees
|
||||
|
||||
- Dependency edges are durable and survive restart.
|
||||
- Start-when-unblocked intents are durable (task metadata) and survive restart,
|
||||
including across a restart that happens between a predecessor's completion
|
||||
and the dependent's launch: startup reconciliation re-evaluates blocked tasks
|
||||
with intents and launches those whose dependencies are satisfied.
|
||||
- Derived `blocked` state is never persisted and is recomputed on read, so a
|
||||
restart cannot resurrect a stale gate.
|
||||
- An intent is consumed at most once. A restart between claim and launch leaves
|
||||
the task unstarted with the claim restored, not double-started.
|
||||
- `task.dependencies_resolved` and `task.dependency_failed` are in-memory
|
||||
events and are not replayed after restart; the startup reconciliation above
|
||||
is the recovery path, and a failed-predecessor state is re-derived on read.
|
||||
|
||||
## Scenarios
|
||||
|
||||
Edges and blocking:
|
||||
|
||||
- **GIVEN** tasks A and B in the same workspace, **WHEN** the user adds "B
|
||||
depends on A", **THEN** B's card shows a blocked badge naming one
|
||||
predecessor and B's payload reports `blocked: true`, `blocked_reason:
|
||||
"pending"`, `depends_on: ["A"]`.
|
||||
- **GIVEN** "B depends on A", **WHEN** the user tries to add "A depends on B",
|
||||
**THEN** the request is rejected with the cycle path `A → B → A` and no edge
|
||||
is created.
|
||||
- **GIVEN** "B depends on A" and "C depends on B", **WHEN** the user tries to
|
||||
add "A depends on C", **THEN** the request is rejected with the cycle path
|
||||
`A → C → B → A`.
|
||||
- **GIVEN** tasks in two different workspaces, **WHEN** the user tries to link
|
||||
them, **THEN** the request is rejected and no edge is created.
|
||||
- **GIVEN** B depends on A and on X, **WHEN** A completes successfully,
|
||||
**THEN** B remains blocked with `depends_on: ["A","X"]` and
|
||||
`blocked_reason: "pending"`.
|
||||
|
||||
Gating:
|
||||
|
||||
- **GIVEN** B depends on an unfinished A and B's workflow step has
|
||||
`on_enter: auto_start_agent`, **WHEN** B is moved into that step, **THEN** no
|
||||
session is created and the skip is logged.
|
||||
- **GIVEN** B depends on an unfinished A and B is queued for a WIP-limited
|
||||
auto-start step, **WHEN** capacity opens and B is promoted, **THEN** B is
|
||||
admitted, its queued badge clears, and no session is created.
|
||||
- **GIVEN** B depends on an unfinished A, **WHEN** the user clicks Start on B,
|
||||
**THEN** B starts, because manual start is an explicit override.
|
||||
|
||||
Auto-start and chaining:
|
||||
|
||||
- **GIVEN** B depends on A, B has a start-when-unblocked intent, and B is
|
||||
WIP-admitted, **WHEN** A completes successfully, **THEN** exactly one session
|
||||
is created for B using the intent's recorded agent profile, executor,
|
||||
executor profile, prompt, and plan-mode choice.
|
||||
- **GIVEN** A → B → C each with a start-when-unblocked intent, **WHEN** A
|
||||
completes, **THEN** B starts; **WHEN** B completes, **THEN** C starts, and A
|
||||
is never restarted.
|
||||
- **GIVEN** B depends on A and B has **no** start-when-unblocked intent,
|
||||
**WHEN** A completes, **THEN** B unblocks, no session is created, and B's
|
||||
card shows no blocked badge.
|
||||
- **GIVEN** B depends on A, B has an intent, and B is queued (not WIP-admitted)
|
||||
for a full step, **WHEN** A completes, **THEN** no session is created and B
|
||||
stays queued; **WHEN** capacity later opens and B is promoted, **THEN**
|
||||
exactly one session is created.
|
||||
- **GIVEN** B depends on A and B has an intent, **WHEN** A completes and the
|
||||
backend restarts before B launches, **THEN** after startup exactly one
|
||||
session is created for B.
|
||||
- **GIVEN** B depends on A and on X, both with A and X unfinished, **WHEN** A
|
||||
completes and then X completes, **THEN** B launches exactly once, on X's
|
||||
completion.
|
||||
- **GIVEN** B depends on A and B has an intent, **WHEN** the user removes the
|
||||
edge instead of A completing, **THEN** B unblocks and no session is created.
|
||||
|
||||
Failure paths:
|
||||
|
||||
- **GIVEN** B depends on A and B has an intent, **WHEN** A ends `FAILED`,
|
||||
**THEN** B stays blocked with `blocked_reason: "failed"`, no session is
|
||||
created, `task.dependency_failed` is published, and a notification is raised.
|
||||
- **GIVEN** B is blocked with `blocked_reason: "failed"` after A failed,
|
||||
**WHEN** A is retried and completes successfully, **THEN** B unblocks and its
|
||||
intent fires exactly once.
|
||||
- **GIVEN** B depends on A and A is `CANCELLED`, **WHEN** the dependency state
|
||||
is read, **THEN** B is blocked with `blocked_reason: "failed"`.
|
||||
- **GIVEN** B depends on A, **WHEN** A is archived, **THEN** B stays blocked
|
||||
and A is reported as a pending predecessor.
|
||||
- **GIVEN** B depends on A and B has an intent, **WHEN** A is deleted, **THEN**
|
||||
the edge is removed, B unblocks, and no session is created.
|
||||
- **GIVEN** A has completed and B has launched, **WHEN** A is reopened to
|
||||
`IN_PROGRESS`, **THEN** B reports blocked again and B's running session is
|
||||
not stopped.
|
||||
- **GIVEN** the blocker store returns an error, **WHEN** an automated path
|
||||
evaluates whether a task may auto-start, **THEN** the launch is skipped and a
|
||||
warning is logged.
|
||||
|
||||
UI:
|
||||
|
||||
- **GIVEN** a blocked task on the Kanban board, **WHEN** the user hovers or taps
|
||||
its blocked badge, **THEN** the predecessor titles and their states are
|
||||
listed, and the badge coexists with the WIP queued badge without truncating
|
||||
either.
|
||||
- **GIVEN** the task-create dialog, **WHEN** the user opens "Depends on", **THEN**
|
||||
the board's other tasks are offered as candidates, selecting one shows a
|
||||
removable chip, and the copy states that starting an agent will run the task
|
||||
automatically once every selection completes.
|
||||
- **GIVEN** a task created with dependencies and an agent start, **WHEN** creation
|
||||
succeeds, **THEN** no session is started and the response reports
|
||||
`start_when_unblocked: true`.
|
||||
- **GIVEN** an open task B where B depends on A and D depends on B, **WHEN** the
|
||||
task view renders, **THEN** a dependency chip appears in the status row above
|
||||
the composer next to the PR status chip, and opening it lists A under
|
||||
blocked-by and D under blocks, each with its state and each navigable.
|
||||
- **GIVEN** an open task with no dependency edges in either direction, **WHEN**
|
||||
the task view renders, **THEN** no dependency chip appears, and a status row
|
||||
that would otherwise be empty still does not render.
|
||||
- **GIVEN** an open task whose board copy carries no dependency projection (a
|
||||
lightweight `task.updated` overwrote it, or arrived before hydration), **WHEN**
|
||||
the task view renders, **THEN** the chip still shows the task's edges, because a
|
||||
reader that cannot trust its cached copy re-reads them.
|
||||
- **GIVEN** an open blocked task whose predecessor failed, **WHEN** the user opens
|
||||
the dependency chip, **THEN** the failed predecessor is distinguished from
|
||||
merely pending ones.
|
||||
- **GIVEN** a narrow mobile viewport, **WHEN** the user taps the dependency chip,
|
||||
**THEN** the list opens in the same drawer pattern the PR status chip uses on
|
||||
mobile, not a hover card.
|
||||
|
||||
MCP:
|
||||
|
||||
- **GIVEN** an agent calls `create_task_kandev` with `blocked_by: ["A"]` and no
|
||||
explicit `start_agent`, **THEN** the task is created with the edge, no session
|
||||
is started, and the response reports `started: false` and
|
||||
`start_when_unblocked: true`.
|
||||
- **GIVEN** an agent calls `create_task_kandev` with `blocked_by: ["A"]` and
|
||||
`start_when_unblocked: false`, **THEN** the edge is created, no launch intent
|
||||
is recorded, and completing A starts nothing.
|
||||
- **GIVEN** an agent calls `create_task_kandev` with `start_when_unblocked: true`
|
||||
and no `blocked_by`, **THEN** no launch intent is recorded and the task starts
|
||||
or does not start purely per `start_agent`; the response reports
|
||||
`start_when_unblocked: false`.
|
||||
- **GIVEN** an agent decomposes a plan into three chained sibling tasks over
|
||||
three `create_task_kandev` calls, **WHEN** the first task completes
|
||||
successfully, **THEN** the second starts and the third does not.
|
||||
- **GIVEN** an agent calls `add_task_dependency_kandev` omitting `task_id`,
|
||||
**THEN** the edge is added to the calling task and the resulting `depends_on`
|
||||
list is returned.
|
||||
- **GIVEN** an agent calls `add_task_dependency_kandev` with an edge that would
|
||||
close a cycle, **THEN** the call fails with the cycle path and no edge is
|
||||
created.
|
||||
- **GIVEN** an agent calls `remove_task_dependency_kandev` for an edge that does
|
||||
not exist, **THEN** the call succeeds.
|
||||
- **GIVEN** a Kanban-only install with `Features.Office` false, **WHEN** an agent
|
||||
calls `create_task_kandev` with `blocked_by`, **THEN** the task and edge are
|
||||
created and `list_related_tasks_kandev` reports them.
|
||||
- **GIVEN** a narrow mobile viewport, **WHEN** the user opens a column
|
||||
containing a blocked task, **THEN** the blocked state and predecessor count
|
||||
are readable on the card without hover-only disclosure and without horizontal
|
||||
overflow.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **A whole-graph board view.** A layered DAG view was built and then removed
|
||||
before shipping: without drawn connectors it read as columns of text, and it
|
||||
could only show one workflow at a time, so it did not answer "show me this
|
||||
chain" any better than the per-task chip does. The chip (both directions, one
|
||||
hop) and `list_related_tasks_kandev` are the shipped ways to read the graph.
|
||||
A future view should draw real connectors, using the existing
|
||||
`components/kanban/graph2-connector.tsx` primitives, and be able to focus a
|
||||
single chain.
|
||||
|
||||
- **Office mode changes.** Office is feature-flagged and not in the live
|
||||
feature inventory ([`docs/features.md`](../../features.md), "In Progress:
|
||||
Office Mode"), and it already has its own blocker reaction and run queue.
|
||||
v1 makes the relationship and the Kanban reaction work in Kanban mode and
|
||||
leaves `cascadeBlockersResolved`, Office agent assignment, and Office task
|
||||
delegation untouched. Unifying the two reactions is a follow-up that should
|
||||
wait until Office ships.
|
||||
- Optional or "soft" dependencies (start-anyway-after-N-hours, OR semantics
|
||||
across predecessors). v1 is AND-only over all direct predecessors.
|
||||
- Cross-workspace dependencies.
|
||||
- Passing a predecessor's output (summary, diff, PR link) into the dependent's
|
||||
prompt. The dependent launches with the prompt recorded in its intent. Agents
|
||||
that need predecessor context read it through `list_related_tasks_kandev`.
|
||||
- Stopping or rolling back a dependent that already launched when its
|
||||
predecessor is reopened or reverted.
|
||||
- Automatic retry of a failed predecessor, and automatic edge removal on
|
||||
failure.
|
||||
- A dependency-aware scheduling policy (critical-path ordering, priority
|
||||
inheritance along a chain, WIP reservation for a whole chain).
|
||||
- Templated chains — saving "A → B → C" as a reusable pipeline definition.
|
||||
A chain is a set of edges between concrete tasks in v1.
|
||||
- Gantt or timeline rendering, and estimated-duration modelling.
|
||||
- Editing dependencies from the multi-select toolbar or via bulk operations.
|
||||
- An edge editor on the task detail view. Declaring dependencies is a planning
|
||||
act that belongs to task creation (or to MCP for an agent decomposing work);
|
||||
the task view only reports them.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
See [`../../plans/task-dependencies/plan.md`](../../plans/task-dependencies/plan.md).
|
||||
Reference in New Issue
Block a user