fix: make task creation atomic when a reference fails to resolve (#3148)

Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Alassane
2026-08-30 10:34:40 +00:00
committed by GitHub
parent 4d8763e4de
commit 0591936e16
4 changed files with 807 additions and 122 deletions
@@ -0,0 +1,393 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/stretchr/testify/require"
orchmodels "github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/task/models"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
"github.com/kandev/kandev/internal/task/service"
ws "github.com/kandev/kandev/pkg/websocket"
)
// unknownReferenceUUID is the id from the incident report: syntactically valid,
// but a task_repositories join-row id rather than a repositories.id, so it
// resolves to nothing.
const unknownReferenceUUID = "1f63b0c4-892d-44ea-a30c-f2a0300298bb"
type atomicityFixture struct {
svc *service.Service
blockers *memBlockerRepo
repo *sqliterepo.Repository
handlers *Handlers
workspaceID string
workflowID string
}
type noOpContributionDestinationPreparer struct{}
func (*noOpContributionDestinationPreparer) PrepareContributionDestination(
context.Context,
*service.CreateTaskRequest,
*models.Workflow,
[]*models.Repository,
) error {
return nil
}
func newAtomicityFixture(t *testing.T) *atomicityFixture {
t.Helper()
svc, repo := newTestTaskService(t)
blockers := &memBlockerRepo{}
svc.SetBlockerRepository(blockers)
ctx := context.Background()
workspaces, err := svc.ListWorkspaces(ctx)
require.NoError(t, err)
workflows, err := svc.ListWorkflows(ctx, workspaces[0].ID, false)
require.NoError(t, err)
return &atomicityFixture{
svc: svc,
blockers: blockers,
repo: repo,
handlers: NewHandlers(svc, nil, nil, nil, nil, repo, repo, nil, nil, nil, nil, nil, testLogger(t)),
workspaceID: workspaces[0].ID,
workflowID: workflows[0].ID,
}
}
// newRepository inserts a repository row directly. Service-level
// CreateRepository additionally validates that local_path is a real git
// checkout, which is beside the point here: these tests only need an id that
// resolves.
func (f *atomicityFixture) newRepository(t *testing.T, name string) *models.Repository {
t.Helper()
repository := &models.Repository{WorkspaceID: f.workspaceID, Name: name}
require.NoError(t, f.repo.CreateRepository(context.Background(), repository))
return repository
}
// createdTaskID creates a task through the handler and returns its id.
func (f *atomicityFixture) createdTaskID(t *testing.T, title string) string {
t.Helper()
resp := f.create(t, map[string]interface{}{"title": title})
require.NotEqual(t, ws.MessageTypeError, resp.Type, "setup create failed: %s", string(resp.Payload))
var created map[string]interface{}
require.NoError(t, json.Unmarshal(resp.Payload, &created))
id, _ := created["id"].(string)
require.NotEmpty(t, id)
return id
}
func (f *atomicityFixture) taskCount(t *testing.T) int {
t.Helper()
tasks, err := f.svc.ListTasks(context.Background(), f.workflowID)
require.NoError(t, err)
return len(tasks)
}
func (f *atomicityFixture) create(t *testing.T, overrides map[string]interface{}) *ws.Message {
return f.createWithContext(t, context.Background(), overrides)
}
func (f *atomicityFixture) createWithContext(
t *testing.T,
ctx context.Context,
overrides map[string]interface{},
) *ws.Message {
t.Helper()
payload := map[string]interface{}{
"workspace_id": f.workspaceID,
"workflow_id": f.workflowID,
"title": "Task",
"description": "Do the thing",
"agent_profile_id": "profile-1",
"start_agent": false,
}
for k, v := range overrides {
payload[k] = v
}
resp, err := f.handlers.handleCreateTask(ctx, makeWSMessage(t, ws.ActionMCPCreateTask, payload))
require.NoError(t, err)
require.NotNil(t, resp)
return resp
}
// memBlockerRepo is an in-memory BlockerRepository. The SQLite task repository
// does not implement the interface (blocker rows are Office-owned), and these
// tests only need edges to be creatable and readable back.
type memBlockerRepo struct {
edges []*orchmodels.TaskBlocker
// failCreateAfter makes the (failCreateAfter+1)-th CreateTaskBlocker fail,
// standing in for a blocker deleted between pre-insert validation and the
// post-insert write. Zero disables the injection.
failCreateAfter int
creates int
cancelOnFailure context.CancelFunc
cleanupCtxErr error
cleanupDeadline bool
}
func (m *memBlockerRepo) CreateTaskBlocker(_ context.Context, blocker *orchmodels.TaskBlocker) error {
m.creates++
if m.failCreateAfter > 0 && m.creates > m.failCreateAfter {
if m.cancelOnFailure != nil {
m.cancelOnFailure()
}
return errors.New("blocker vanished")
}
m.edges = append(m.edges, blocker)
return nil
}
// DeleteTaskBlockersForTask satisfies the optional taskDependencyCleaner seam
// the service uses to tear down edges for a deleted task.
func (m *memBlockerRepo) DeleteTaskBlockersForTask(ctx context.Context, taskID string) error {
m.cleanupCtxErr = ctx.Err()
_, m.cleanupDeadline = ctx.Deadline()
kept := m.edges[:0]
for _, edge := range m.edges {
if edge.TaskID != taskID && edge.BlockerTaskID != taskID {
kept = append(kept, edge)
}
}
m.edges = kept
return nil
}
func (m *memBlockerRepo) ListTaskBlockers(_ context.Context, taskID string) ([]*orchmodels.TaskBlocker, error) {
var out []*orchmodels.TaskBlocker
for _, edge := range m.edges {
if edge.TaskID == taskID {
out = append(out, edge)
}
}
return out, nil
}
func (m *memBlockerRepo) DeleteTaskBlocker(_ context.Context, taskID, blockerTaskID string) error {
for i, edge := range m.edges {
if edge.TaskID == taskID && edge.BlockerTaskID == blockerTaskID {
m.edges = append(m.edges[:i], m.edges[i+1:]...)
return nil
}
}
return nil
}
func (m *memBlockerRepo) ListTasksBlockedBy(_ context.Context, blockerTaskID string) ([]string, error) {
var out []string
for _, edge := range m.edges {
if edge.BlockerTaskID == blockerTaskID {
out = append(out, edge.TaskID)
}
}
return out, nil
}
func (m *memBlockerRepo) ListBlockersForTasks(_ context.Context, taskIDs []string) (map[string][]string, error) {
out := make(map[string][]string)
for _, id := range taskIDs {
for _, edge := range m.edges {
if edge.TaskID == id {
out[id] = append(out[id], edge.BlockerTaskID)
}
}
}
return out, nil
}
func (m *memBlockerRepo) ListDependentsForTasks(_ context.Context, blockerTaskIDs []string) (map[string][]string, error) {
out := make(map[string][]string)
for _, id := range blockerTaskIDs {
for _, edge := range m.edges {
if edge.BlockerTaskID == id {
out[id] = append(out[id], edge.TaskID)
}
}
}
return out, nil
}
// TestHandleCreateTask_UnknownRepositoryIDLeavesNoOrphan is the regression for
// the reported defect. The handler inserted the task row, THEN resolved the
// repository, THEN returned an error — so a caller that correctly retried on
// failure ended up with two cards.
//
// Asserting only the error is insufficient: the whole defect is the row that
// survives it, so the row count is asserted directly on both sides of the call.
func TestHandleCreateTask_UnknownRepositoryIDLeavesNoOrphan(t *testing.T) {
f := newAtomicityFixture(t)
before := f.taskCount(t)
resp := f.create(t, map[string]interface{}{
"repositories": []map[string]interface{}{{"repository_id": unknownReferenceUUID}},
})
failure := errorPayload(t, resp)
require.Equal(t, before, f.taskCount(t), "failed create must leave no orphan task row")
// The error must be actionable on its own: a caller who cannot read the
// backend log still has to be able to fix their call from this message.
require.Equal(t, ws.ErrorCodeValidation, failure.Code, "an unresolvable caller-supplied id is a validation failure")
require.NotEqual(t, "Failed to create task", failure.Message, "the bare message names neither field nor value")
require.Contains(t, failure.Message, "repositories[0].repository_id", "error must name the failing field")
require.Contains(t, failure.Message, unknownReferenceUUID, "error must name the offending value")
}
// TestHandleCreateTask_UnknownBlockerLeavesNoOrphan covers the other reference
// resolved after the insert. blocked_by went through AddBlocker in
// finalizeCreatedTask, so an unknown blocker stranded a task exactly as an
// unknown repository did.
func TestHandleCreateTask_UnknownBlockerLeavesNoOrphan(t *testing.T) {
f := newAtomicityFixture(t)
before := f.taskCount(t)
resp := f.create(t, map[string]interface{}{
"blocked_by": []string{unknownReferenceUUID},
})
failure := errorPayload(t, resp)
require.Equal(t, before, f.taskCount(t), "failed create must leave no orphan task row")
require.Equal(t, ws.ErrorCodeValidation, failure.Code)
require.Contains(t, failure.Message, "blocked_by[0]", "error must name the failing field")
require.Contains(t, failure.Message, unknownReferenceUUID, "error must name the offending value")
}
// TestHandleCreateTask_UnknownRepositoryIDIsNotPartiallyApplied guards the
// mixed case: a good repository listed before a bad one must not leave the
// task, or the first association, behind.
func TestHandleCreateTask_UnknownRepositoryIDIsNotPartiallyApplied(t *testing.T) {
f := newAtomicityFixture(t)
good := f.newRepository(t, "good-repo")
before := f.taskCount(t)
resp := f.create(t, map[string]interface{}{
"repositories": []map[string]interface{}{
{"repository_id": good.ID},
{"repository_id": unknownReferenceUUID},
},
})
failure := errorPayload(t, resp)
require.Equal(t, before, f.taskCount(t), "a later bad reference must not strand the task")
require.Equal(t, ws.ErrorCodeValidation, failure.Code)
require.Contains(t, failure.Message, "repositories[1].repository_id", "error must name the failing index")
}
// TestHandleCreateTask_UnknownRepositoryInTemplatedWorkflowIsValidation guards
// the contribution-destination preparation path. That path runs before the
// main reference resolver for templated workflows, so it must use the same
// caller-facing reference error when its repository lookup fails.
func TestHandleCreateTask_UnknownRepositoryInTemplatedWorkflowIsValidation(t *testing.T) {
f := newAtomicityFixture(t)
workflow, err := f.repo.GetWorkflow(context.Background(), f.workflowID)
require.NoError(t, err)
templateID := "templated-workflow"
workflow.WorkflowTemplateID = &templateID
require.NoError(t, f.repo.UpdateWorkflow(context.Background(), workflow))
f.svc.SetContributionDestinationPreparer(&noOpContributionDestinationPreparer{})
resp := f.create(t, map[string]interface{}{
"repositories": []map[string]interface{}{{"repository_id": unknownReferenceUUID}},
})
failure := errorPayload(t, resp)
require.Equal(t, ws.ErrorCodeValidation, failure.Code)
require.Contains(t, failure.Message, "repositories[0].repository_id")
require.Contains(t, failure.Message, unknownReferenceUUID)
require.Equal(t, 0, f.taskCount(t), "failed create must leave no orphan task row")
}
// TestHandleCreateTask_BlockedByWithDeferredLaunchStillSucceeds is the
// no-behaviour-change guard for the exact request shape that produced the
// orphan on the live board: blocked_by plus a deferred launch. Moving
// reference resolution ahead of the insert must not disturb it.
func TestHandleCreateTask_BlockedByWithDeferredLaunchStillSucceeds(t *testing.T) {
f := newAtomicityFixture(t)
blockerResp := f.create(t, map[string]interface{}{"title": "Blocker"})
require.NotEqual(t, ws.MessageTypeError, blockerResp.Type, "setup create failed: %s", string(blockerResp.Payload))
var blocker map[string]interface{}
require.NoError(t, json.Unmarshal(blockerResp.Payload, &blocker))
blockerID, _ := blocker["id"].(string)
require.NotEmpty(t, blockerID)
repository := f.newRepository(t, "repo")
before := f.taskCount(t)
resp := f.create(t, map[string]interface{}{
"title": "Blocked child",
"blocked_by": []string{blockerID},
"start_agent": true,
"repositories": []map[string]interface{}{{"repository_id": repository.ID}},
})
require.NotEqual(t, ws.MessageTypeError, resp.Type, "create failed: %s", string(resp.Payload))
var created map[string]interface{}
require.NoError(t, json.Unmarshal(resp.Payload, &created))
require.Equal(t, before+1, f.taskCount(t), "a successful create must still insert exactly one task")
// The repository association must still be persisted after the insert.
taskID, _ := created["id"].(string)
require.NotEmpty(t, taskID)
task, err := f.svc.GetTask(context.Background(), taskID)
require.NoError(t, err)
require.Len(t, task.Repositories, 1, "resolved repositories must still be written")
require.Equal(t, repository.ID, task.Repositories[0].RepositoryID)
blockers, err := f.svc.GetBlockers(context.Background(), taskID)
require.NoError(t, err)
require.Equal(t, []string{blockerID}, blockers, "blocker edge must still be created")
}
// TestHandleCreateTask_RollbackAlsoRemovesDependencyEdges covers the residual
// TOCTOU path, where a blocker passes pre-insert validation and is gone by the
// time the edge is written.
//
// blocked_by is written one edge at a time, so failing on the second entry
// leaves the first already persisted. task_blockers predates the tasks foreign
// key and nothing cascades, so a rollback that deleted only the task row would
// swap an orphan task for an orphan edge pointing at a task that no longer
// exists.
func TestHandleCreateTask_RollbackAlsoRemovesDependencyEdges(t *testing.T) {
f := newAtomicityFixture(t)
first := f.createdTaskID(t, "Blocker one")
second := f.createdTaskID(t, "Blocker two")
before := f.taskCount(t)
f.blockers.failCreateAfter = 1
resp := f.create(t, map[string]interface{}{
"title": "Blocked child",
"blocked_by": []string{first, second},
})
errorPayload(t, resp)
require.Equal(t, before, f.taskCount(t), "failed create must leave no orphan task row")
require.Empty(t, f.blockers.edges, "rollback must also remove the edge that was already written")
}
func TestHandleCreateTask_RollbackSurvivesCallerCancellation(t *testing.T) {
f := newAtomicityFixture(t)
first := f.createdTaskID(t, "Blocker one")
second := f.createdTaskID(t, "Blocker two")
ctx, cancel := context.WithCancel(context.Background())
f.blockers.failCreateAfter = 1
f.blockers.cancelOnFailure = cancel
before := f.taskCount(t)
resp := f.createWithContext(t, ctx, map[string]interface{}{
"title": "Blocked child",
"blocked_by": []string{first, second},
})
errorPayload(t, resp)
require.Equal(t, before, f.taskCount(t), "rollback must delete the partial task after caller cancellation")
require.NoError(t, f.blockers.cleanupCtxErr, "rollback cleanup must not inherit caller cancellation")
require.True(t, f.blockers.cleanupDeadline, "rollback cleanup must remain bounded")
}
@@ -1018,6 +1018,12 @@ func classifyCreateTaskError(err error) string {
case errors.Is(err, service.ErrSubtaskDepthExceeded),
errors.Is(err, service.ErrInvalidTaskWorkflow),
errors.Is(err, service.ErrExternalIDInvalid),
// A reference the caller supplied that does not resolve is a
// validation failure, not an internal one. Classifying it as
// INTERNAL_ERROR discarded err.Error() and left the caller with a
// bare "Failed to create task" naming neither the field nor the
// offending value — the cause reached the backend log and nowhere else.
errors.Is(err, service.ErrTaskReferenceNotFound),
isMCPWorkflowNotFoundError(err):
return ws.ErrorCodeValidation
default:
@@ -0,0 +1,132 @@
package service
import (
"context"
"errors"
"fmt"
"time"
"go.uber.org/zap"
taskrepo "github.com/kandev/kandev/internal/task/repository"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
)
// ErrTaskReferenceNotFound marks a create rejected because a caller-supplied
// reference (a repository, a blocker) does not resolve.
//
// It exists so transports can classify the failure as VALIDATION rather than
// INTERNAL_ERROR. A bad id in the request is the caller's to fix, and telling
// them only "Failed to create task" — as this path used to — leaves the actual
// cause visible nowhere but the backend log, which most callers cannot read.
var ErrTaskReferenceNotFound = errors.New("task reference not found")
// unknownRepositoryReferenceError describes a repositories[i].repository_id
// that does not resolve, naming both the field and the offending value.
//
// The hint is not decoration: a task's repositories[] entry exposes BOTH an
// `id` (the task_repositories join row) and a `repository_id` (the repository
// itself), and passing the former is the mistake this error most often
// reports.
func unknownRepositoryReferenceError(index int, repositoryID string) error {
return fmt.Errorf(
"%w: unknown repositories[%d].repository_id %q; no repository with that id exists in this workspace. "+
"If you copied it from a task's repositories[] entry, pass repository_id (the repository) rather than id (the task-repository link)",
ErrTaskReferenceNotFound, index, repositoryID,
)
}
// unknownBlockerReferenceError describes a blocked_by entry that does not
// resolve to a task in this workspace.
func unknownBlockerReferenceError(index int, blockerID string) error {
return fmt.Errorf(
"%w: unknown blocked_by[%d] %q; no task with that id exists in this workspace",
ErrTaskReferenceNotFound, index, blockerID,
)
}
// validateBlockerReferences checks every blocked_by id BEFORE the task row is
// inserted.
//
// AddBlocker remains the authoritative validator for dependency edges (see
// AddDependency's single-validator contract); this is not a second one. It
// resolves only what can be known without the new task existing — existence,
// authorization, and workspace agreement — so the request is rejected while
// there is still nothing to roll back. Cycle detection is deliberately absent:
// a task that does not exist yet has no outgoing edges and cannot close a
// cycle, and self-reference is impossible against an id not yet assigned.
func (s *Service) validateBlockerReferences(ctx context.Context, req *CreateTaskRequest) error {
if len(req.BlockedBy) == 0 {
return nil
}
if s.blockers == nil {
return ErrDependencyRepositoryUnavailable
}
for index, blockerID := range req.BlockedBy {
if blockerID == "" {
return fmt.Errorf("%w: blocked_by[%d] is empty", ErrTaskReferenceNotFound, index)
}
if err := s.authorizeTaskID(ctx, blockerID); err != nil {
// Denials surface as not-found so a caller cannot probe for the
// existence of another user's task, matching authorizeDependencyPair.
if errors.Is(err, taskrepo.ErrTaskNotFound) {
return unknownBlockerReferenceError(index, blockerID)
}
return err
}
blocker, err := s.tasks.GetTask(ctx, blockerID)
if err != nil {
if errors.Is(err, taskrepo.ErrTaskNotFound) {
return unknownBlockerReferenceError(index, blockerID)
}
return fmt.Errorf("resolve blocked_by[%d]: %w", index, err)
}
if blocker == nil {
return unknownBlockerReferenceError(index, blockerID)
}
if req.WorkspaceID != "" && blocker.WorkspaceID != "" && blocker.WorkspaceID != req.WorkspaceID {
return fmt.Errorf(
"%w: blocked_by[%d] %q belongs to a different workspace",
ErrTaskReferenceNotFound, index, blockerID,
)
}
}
return nil
}
// rollbackPartialTask deletes a task whose post-insert writes failed, and
// returns the original error to surface.
//
// Pre-insert validation closes the ordinary case; this closes the TOCTOU
// remainder, where a reference was valid when checked and gone by the time it
// was written. It mirrors the rollback the MCP create handler already performs
// for remote-contribution association and workspace-policy attach. A failed
// rollback is logged, never substituted for the original error: the caller
// needs to know why the create failed, not why the cleanup did.
// Cleanup is detached from caller cancellation but remains bounded, because a
// canceled request can be the reason finalization failed in the first place.
//
// Dependency edges are removed first. blocked_by is written one edge at a
// time, so a failure on the second entry leaves the first already persisted,
// and task_blockers predates the tasks foreign key — nothing cascades. Rolling
// back only the task row would trade an orphan task for an orphan edge
// pointing at a task that no longer exists.
func (s *Service) rollbackPartialTask(ctx context.Context, taskID string, cause error) error {
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
s.deleteDependencyEdgesForTask(rollbackCtx, taskID)
if err := s.tasks.DeleteTask(rollbackCtx, taskID); err != nil {
s.logger.Error("rollback delete failed; task left in inconsistent state",
zap.String("task_id", taskID), zap.Error(err))
}
return cause
}
// classifyRepositoryResolutionError converts a repository lookup failure into
// the caller-facing reference error, leaving anything else untouched.
func classifyRepositoryResolutionError(index int, repositoryID string, err error) error {
if errors.Is(err, repoerrors.ErrRepositoryNotFound) {
return unknownRepositoryReferenceError(index, repositoryID)
}
return err
}
@@ -202,7 +202,7 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (Creat
return result, err
}
task, err := s.prepareTaskForCreation(ctx, req, externalID)
prepared, err := s.prepareTaskForCreation(ctx, req, externalID)
if err != nil {
if found, ok := s.recoverFoundTaskAfterInsertFailure(ctx, req.WorkspaceID, externalID); ok {
return found, nil
@@ -210,7 +210,7 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (Creat
return CreateTaskResult{}, err
}
if err := s.createTaskWithCapacity(ctx, task); err != nil {
if err := s.createTaskWithCapacity(ctx, prepared.task); err != nil {
if found, ok := s.recoverFoundTaskAfterInsertFailure(ctx, req.WorkspaceID, externalID); ok {
return found, nil
}
@@ -218,13 +218,25 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (Creat
return CreateTaskResult{}, err
}
return s.finalizeCreatedTask(ctx, task, req)
return s.finalizeCreatedTask(ctx, prepared, req)
}
// preparedTask carries everything CreateTask resolved BEFORE inserting the
// task row: the row itself, and the fully-resolved task-repository rows that
// only need a TaskID stamped once the insert succeeds.
//
// Resolving the repositories here rather than in finalizeCreatedTask is the
// whole point: a reference that does not resolve must fail while there is
// still no task row to strand.
type preparedTask struct {
task *models.Task
repositories []*models.TaskRepository
}
// prepareTaskForCreation runs create-sequence steps 4-5's non-write half:
// required validation, workflow/step resolution, and office identifier
// assignment, producing the in-memory task CreateTask is about to insert.
func (s *Service) prepareTaskForCreation(ctx context.Context, req *CreateTaskRequest, externalID string) (*models.Task, error) {
func (s *Service) prepareTaskForCreation(ctx context.Context, req *CreateTaskRequest, externalID string) (*preparedTask, error) {
// Subtasks created without an explicit project inherit the parent's, so
// office cost events (which copy tasks.project_id verbatim) attribute to
// the same project as the rest of the tree instead of leaking. Runs first
@@ -283,7 +295,38 @@ func (s *Service) prepareTaskForCreation(ctx context.Context, req *CreateTaskReq
return nil, err
}
}
return task, nil
return s.resolveTaskCreationReferences(ctx, req, task)
}
// resolveTaskCreationReferences resolves every remaining caller-supplied
// reference, and runs LAST in prepareTaskForCreation: the earlier steps
// rewrite req.Repositories in place (preflightRepositorySelections and
// prepareContributionDestination both do), so resolution has to see the final
// list. It still runs before the insert, which is the point — anything that
// can reject the request on bad input has now run with nothing yet written.
//
// One consequence is deliberate. A github_url or local_path input resolves
// through find-or-create, so a repository row may now be created before the
// task insert rather than after it, and a create that then fails admission
// (WIP limit, capacity) leaves that row behind. It is NOT rolled back, and the
// asymmetry is the reason: a stranded task row duplicates a card and carries
// deferred_launch, while a repository row is an idempotent workspace-level
// entity that the caller's retry resolves straight back onto. Deleting it
// would be the more dangerous choice — find-or-create means a concurrent task
// can bind to the same row in between, and rolling back would then delete a
// repository somebody else is using.
func (s *Service) resolveTaskCreationReferences(
ctx context.Context, req *CreateTaskRequest, task *models.Task,
) (*preparedTask, error) {
if err := s.validateBlockerReferences(ctx, req); err != nil {
return nil, err
}
repositories, err := s.resolveTaskRepositoryRows(ctx, req.WorkspaceID, req.Repositories)
if err != nil {
return nil, err
}
return &preparedTask{task: task, repositories: repositories}, nil
}
func (s *Service) prepareContributionDestination(ctx context.Context, req *CreateTaskRequest) error {
@@ -318,10 +361,10 @@ func (s *Service) loadContributionDestinationRepositories(
}
repository, err := s.repoEntities.GetRepository(ctx, input.RepositoryID)
if err != nil {
return nil, fmt.Errorf("load repository %s for contribution destination: %w", input.RepositoryID, err)
return nil, classifyRepositoryResolutionError(index, input.RepositoryID, err)
}
if repository == nil || repository.WorkspaceID != req.WorkspaceID {
return nil, repoerrors.ErrRepositoryNotFound
return nil, unknownRepositoryReferenceError(index, input.RepositoryID)
}
repositories[index] = repository
}
@@ -331,16 +374,19 @@ func (s *Service) loadContributionDestinationRepositories(
// finalizeCreatedTask runs create-sequence step 6, the required synchronous
// post-create work, after task has been inserted: blocker relationships,
// task repositories, the created-event publish, and the feeder-pull refresh.
func (s *Service) finalizeCreatedTask(ctx context.Context, task *models.Task, req *CreateTaskRequest) (CreateTaskResult, error) {
// Create blocker relationships if specified.
func (s *Service) finalizeCreatedTask(ctx context.Context, prepared *preparedTask, req *CreateTaskRequest) (CreateTaskResult, error) {
task := prepared.task
// Create blocker relationships if specified. Every id here was validated
// pre-insert, so a failure now means the blocker was deleted underneath
// us — rare, but it must not leave the half-built task behind.
for _, blockerID := range req.BlockedBy {
if err := s.AddBlocker(ctx, task.ID, blockerID); err != nil {
return CreateTaskResult{}, fmt.Errorf("add blocker %s: %w", blockerID, err)
return CreateTaskResult{}, s.rollbackPartialTask(ctx, task.ID, fmt.Errorf("add blocker %s: %w", blockerID, err))
}
}
if err := s.createTaskRepositories(ctx, task.ID, req.WorkspaceID, req.Repositories); err != nil {
return CreateTaskResult{}, err
if err := s.persistTaskRepositoryRows(ctx, task.ID, prepared.repositories); err != nil {
return CreateTaskResult{}, s.rollbackPartialTask(ctx, task.ID, err)
}
// Load repositories into task for response
@@ -810,117 +856,21 @@ func (s *Service) assignIdentifier(ctx context.Context, task *models.Task) error
// createTaskRepositories creates task-repository associations, resolving local paths to repository IDs.
func (s *Service) createTaskRepositories(ctx context.Context, taskID, workspaceID string, repositories []TaskRepositoryInput) error {
var repoByPath map[string]*models.Repository
for _, repoInput := range repositories {
if repoInput.RepositoryID == "" && repoInput.LocalPath != "" {
repos, err := s.repoEntities.ListRepositories(ctx, workspaceID)
if err != nil {
s.logger.Error("failed to list repositories", zap.Error(err))
return err
}
repoByPath = make(map[string]*models.Repository, len(repos))
for _, repo := range repos {
if repo.LocalPath == "" {
continue
}
repoByPath[repo.LocalPath] = repo
}
break
}
rows, err := s.resolveTaskRepositoryRows(ctx, workspaceID, repositories)
if err != nil {
return err
}
return s.persistTaskRepositoryRows(ctx, taskID, rows)
}
seen := make(map[string]bool, len(repositories))
for i, repoInput := range repositories {
if repoInput.RemoteContribution != nil {
if err := repoInput.RemoteContribution.Validate(); err != nil {
return fmt.Errorf("invalid remote contribution: %w", err)
}
if repoInput.CheckoutBranch == "" {
repoInput.CheckoutBranch = repoInput.RemoteContribution.HeadBranch
}
if repoInput.BaseBranch == "" {
repoInput.BaseBranch = repoInput.RemoteContribution.BaseBranch
}
if repoInput.CheckoutBranch != repoInput.RemoteContribution.HeadBranch ||
repoInput.BaseBranch != repoInput.RemoteContribution.BaseBranch {
return fmt.Errorf("remote contribution branches do not match the resolved binding")
}
}
if repoInput.ContributionDestination != nil {
if err := repoInput.ContributionDestination.Validate(); err != nil {
return fmt.Errorf("invalid contribution destination: %w", err)
}
}
repositoryID, baseBranch, _, err := s.resolveRepoInput(ctx, workspaceID, repoInput, repoByPath)
if err != nil {
return err
}
if repositoryID == "" {
return fmt.Errorf("repository_id is required")
}
policy, err := s.resolveTaskRepositoryPolicy(ctx, repositoryID, repoInput)
if err != nil {
return err
}
if policy != nil {
if repoInput.RemoteContribution != nil {
if policy.BaseBranch != repoInput.RemoteContribution.BaseBranch {
return fmt.Errorf("remote contribution base branch %q does not match branch policy base branch %q", repoInput.RemoteContribution.BaseBranch, policy.BaseBranch)
}
} else if !repoInput.PreserveBaseBranch {
baseBranch = policy.BaseBranch
}
}
// Multi-branch validation: the same repository may appear multiple
// times in a task on different branches. Identity is
// (repository_id, base_branch, checkout_branch) — base_branch matters
// because the worktree executor anchors the branch there while
// checkout_branch stays empty, and the local-executor flow puts the
// branch in checkout_branch with base_branch anchored to default_branch.
// Both shapes must dedup; matching DB key is UNIQUE(task_id,
// repository_id, base_branch, checkout_branch).
dedupKey := repositoryID + "\x00" + baseBranch + "\x00" + repoInput.CheckoutBranch
if seen[dedupKey] {
label := s.repoDisplayLabel(ctx, repoInput, repositoryID)
branchLabel := repoInput.CheckoutBranch
if branchLabel == "" {
branchLabel = baseBranch
}
if branchLabel == "" {
return fmt.Errorf("repository %q is listed more than once for this task", label)
}
return fmt.Errorf("repository %q on branch %q is listed more than once for this task", label, branchLabel)
}
seen[dedupKey] = true
metadata := make(map[string]interface{})
if prNum := resolvePRNumber(repoInput); prNum > 0 {
metadata["pr_number"] = prNum
}
if repoInput.RemoteContribution != nil {
if err := models.PutRemoteContribution(metadata, repoInput.RemoteContribution); err != nil {
return fmt.Errorf("persist remote contribution: %w", err)
}
}
if repoInput.ContributionDestination != nil {
if err := models.PutContributionDestination(metadata, repoInput.ContributionDestination); err != nil {
return fmt.Errorf("persist contribution destination: %w", err)
}
}
taskRepo := &models.TaskRepository{
TaskID: taskID,
RepositoryID: repositoryID,
BaseBranch: baseBranch,
CheckoutBranch: repoInput.CheckoutBranch,
Position: i,
Metadata: metadata,
}
if policy != nil {
taskRepo.BranchPolicyID = policy.ID
taskRepo.BranchPolicyName = policy.Name
taskRepo.BranchPolicyBaseBranch = policy.BaseBranch
taskRepo.BranchPolicyBranchTemplate = policy.BranchTemplate
taskRepo.BranchPolicyPullRequestTarget = policy.PullRequestTarget
}
// persistTaskRepositoryRows writes already-resolved task-repository rows,
// stamping them with the owning task. Split from resolution so CreateTask can
// resolve BEFORE inserting the task row: every reference failure that used to
// surface here — after the insert, leaving an orphan task — is now raised by
// resolveTaskRepositoryRows while there is still nothing to orphan.
func (s *Service) persistTaskRepositoryRows(ctx context.Context, taskID string, rows []*models.TaskRepository) error {
for _, taskRepo := range rows {
taskRepo.TaskID = taskID
if err := s.taskRepos.CreateTaskRepository(ctx, taskRepo); err != nil {
s.logger.Error("failed to create task repository", zap.Error(err))
return err
@@ -929,6 +879,207 @@ func (s *Service) createTaskRepositories(ctx context.Context, taskID, workspaceI
return nil
}
// resolveTaskRepositoryRows resolves every repository input to a complete
// TaskRepository row WITHOUT persisting anything and without needing a task to
// exist yet: TaskID is left empty for persistTaskRepositoryRows to stamp.
//
// This half performs all reference resolution, policy lookup, and duplicate
// detection, so it is the half that can fail on caller-supplied data. Keeping
// it free of writes is what lets CreateTask run it pre-insert.
func (s *Service) resolveTaskRepositoryRows(
ctx context.Context, workspaceID string, repositories []TaskRepositoryInput,
) ([]*models.TaskRepository, error) {
repoByPath, err := s.repositoriesByLocalPath(ctx, workspaceID, repositories)
if err != nil {
return nil, err
}
seen := make(map[string]bool, len(repositories))
rows := make([]*models.TaskRepository, 0, len(repositories))
for i, repoInput := range repositories {
row, err := s.resolveTaskRepositoryRow(ctx, workspaceID, i, repoInput, repoByPath)
if err != nil {
return nil, err
}
if err := s.claimRepositoryBranchSlot(ctx, seen, row, repoInput); err != nil {
return nil, err
}
rows = append(rows, row)
}
return rows, nil
}
// repositoriesByLocalPath indexes the workspace's repositories by local path,
// and only when some input actually needs the lookup.
func (s *Service) repositoriesByLocalPath(
ctx context.Context, workspaceID string, repositories []TaskRepositoryInput,
) (map[string]*models.Repository, error) {
needed := false
for _, repoInput := range repositories {
if repoInput.RepositoryID == "" && repoInput.LocalPath != "" {
needed = true
break
}
}
if !needed {
return nil, nil
}
repos, err := s.repoEntities.ListRepositories(ctx, workspaceID)
if err != nil {
s.logger.Error("failed to list repositories", zap.Error(err))
return nil, err
}
repoByPath := make(map[string]*models.Repository, len(repos))
for _, repo := range repos {
if repo.LocalPath == "" {
continue
}
repoByPath[repo.LocalPath] = repo
}
return repoByPath, nil
}
// resolveTaskRepositoryRow resolves a single input into a complete, unsaved
// TaskRepository row.
func (s *Service) resolveTaskRepositoryRow(
ctx context.Context, workspaceID string, index int,
repoInput TaskRepositoryInput, repoByPath map[string]*models.Repository,
) (*models.TaskRepository, error) {
repoInput, err := normalizeContributionBindings(repoInput)
if err != nil {
return nil, err
}
repositoryID, baseBranch, _, err := s.resolveRepoInput(ctx, workspaceID, repoInput, repoByPath)
if err != nil {
if repoInput.RepositoryID != "" {
return nil, classifyRepositoryResolutionError(index, repoInput.RepositoryID, err)
}
return nil, err
}
if repositoryID == "" {
return nil, fmt.Errorf("repository_id is required")
}
policy, err := s.resolveTaskRepositoryPolicy(ctx, repositoryID, repoInput)
if err != nil {
return nil, err
}
baseBranch, err = applyBranchPolicyBaseBranch(baseBranch, repoInput, policy)
if err != nil {
return nil, err
}
metadata, err := buildTaskRepositoryMetadata(repoInput)
if err != nil {
return nil, err
}
row := &models.TaskRepository{
RepositoryID: repositoryID,
BaseBranch: baseBranch,
CheckoutBranch: repoInput.CheckoutBranch,
Position: index,
Metadata: metadata,
}
if policy != nil {
row.BranchPolicyID = policy.ID
row.BranchPolicyName = policy.Name
row.BranchPolicyBaseBranch = policy.BaseBranch
row.BranchPolicyBranchTemplate = policy.BranchTemplate
row.BranchPolicyPullRequestTarget = policy.PullRequestTarget
}
return row, nil
}
// normalizeContributionBindings validates the remote-contribution and
// contribution-destination bindings and fills in the branches they imply.
func normalizeContributionBindings(repoInput TaskRepositoryInput) (TaskRepositoryInput, error) {
if repoInput.RemoteContribution != nil {
if err := repoInput.RemoteContribution.Validate(); err != nil {
return repoInput, fmt.Errorf("invalid remote contribution: %w", err)
}
if repoInput.CheckoutBranch == "" {
repoInput.CheckoutBranch = repoInput.RemoteContribution.HeadBranch
}
if repoInput.BaseBranch == "" {
repoInput.BaseBranch = repoInput.RemoteContribution.BaseBranch
}
if repoInput.CheckoutBranch != repoInput.RemoteContribution.HeadBranch ||
repoInput.BaseBranch != repoInput.RemoteContribution.BaseBranch {
return repoInput, fmt.Errorf("remote contribution branches do not match the resolved binding")
}
}
if repoInput.ContributionDestination != nil {
if err := repoInput.ContributionDestination.Validate(); err != nil {
return repoInput, fmt.Errorf("invalid contribution destination: %w", err)
}
}
return repoInput, nil
}
// applyBranchPolicyBaseBranch reconciles the resolved base branch with the
// repository's branch policy.
func applyBranchPolicyBaseBranch(
baseBranch string, repoInput TaskRepositoryInput, policy *models.RepositoryBranchPolicy,
) (string, error) {
if policy == nil {
return baseBranch, nil
}
if repoInput.RemoteContribution != nil {
if policy.BaseBranch != repoInput.RemoteContribution.BaseBranch {
return "", fmt.Errorf("remote contribution base branch %q does not match branch policy base branch %q",
repoInput.RemoteContribution.BaseBranch, policy.BaseBranch)
}
return baseBranch, nil
}
if repoInput.PreserveBaseBranch {
return baseBranch, nil
}
return policy.BaseBranch, nil
}
// buildTaskRepositoryMetadata assembles the row's metadata blob.
func buildTaskRepositoryMetadata(repoInput TaskRepositoryInput) (map[string]interface{}, error) {
metadata := make(map[string]interface{})
if prNum := resolvePRNumber(repoInput); prNum > 0 {
metadata["pr_number"] = prNum
}
if repoInput.RemoteContribution != nil {
if err := models.PutRemoteContribution(metadata, repoInput.RemoteContribution); err != nil {
return nil, fmt.Errorf("persist remote contribution: %w", err)
}
}
if repoInput.ContributionDestination != nil {
if err := models.PutContributionDestination(metadata, repoInput.ContributionDestination); err != nil {
return nil, fmt.Errorf("persist contribution destination: %w", err)
}
}
return metadata, nil
}
// claimRepositoryBranchSlot enforces multi-branch uniqueness. The same
// repository may appear multiple times in a task on different branches.
// Identity is (repository_id, base_branch, checkout_branch) — base_branch
// matters because the worktree executor anchors the branch there while
// checkout_branch stays empty, and the local-executor flow puts the branch in
// checkout_branch with base_branch anchored to default_branch. Both shapes
// must dedup; matching DB key is UNIQUE(task_id, repository_id, base_branch,
// checkout_branch).
func (s *Service) claimRepositoryBranchSlot(
ctx context.Context, seen map[string]bool, row *models.TaskRepository, repoInput TaskRepositoryInput,
) error {
dedupKey := row.RepositoryID + "\x00" + row.BaseBranch + "\x00" + row.CheckoutBranch
if !seen[dedupKey] {
seen[dedupKey] = true
return nil
}
label := s.repoDisplayLabel(ctx, repoInput, row.RepositoryID)
branchLabel := row.CheckoutBranch
if branchLabel == "" {
branchLabel = row.BaseBranch
}
if branchLabel == "" {
return fmt.Errorf("repository %q is listed more than once for this task", label)
}
return fmt.Errorf("repository %q on branch %q is listed more than once for this task", label, branchLabel)
}
// repoDisplayLabel returns a human-readable label for a repository to surface
// in the duplicate-repository error. It prefers owner/name parsed from the
// input's GitHub URL, then the resolved repo entity's owner/name (or bare
@@ -1019,7 +1170,10 @@ func (s *Service) resolveRepoInputID(ctx context.Context, workspaceID, repositor
return "", "", false, fmt.Errorf("looking up repository %q: %w", repositoryID, lookupErr)
}
if repo == nil || repo.WorkspaceID != workspaceID {
return "", "", false, fmt.Errorf("repository %q does not belong to workspace %q", repositoryID, workspaceID)
// Wrapped so transports classify this as a caller-supplied reference
// problem (VALIDATION) rather than an internal failure; the message
// itself is unchanged.
return "", "", false, fmt.Errorf("%w: repository %q does not belong to workspace %q", ErrTaskReferenceNotFound, repositoryID, workspaceID)
}
replacementID, replacementCreated, replacementErr := s.safeRepositoryIDForTaskWorktree(ctx, workspaceID, repo)
if replacementErr != nil {