Files
kandev/apps/backend/internal/task/service/handoff_workspace_test.go
T
José Almeida 4601dbc2d2 feat(tasks): search tasks by PR number in command panel (#1268)
* feat(tasks): search tasks by PR number in command panel

Typing a PR number (e.g. 1243 or #1243) in the Cmd+K panel now surfaces
the task that PR is associated with, alongside the existing
title/description/repo search.

The github store gains a workspace-scoped ListTaskIDsByPRNumber lookup
over github_task_prs; the task service resolves it through an injected
PRTaskResolver interface (github service wired in cmd/kandev) and merges
PR matches into search results, deduped and respecting the
archived/ephemeral/config filters. Keeping the lookup at the service
layer avoids coupling the task repository to the github schema, so
existing search queries and tests are untouched.

* fix(tasks): scope PR-number search to page 1 and unfiltered queries

Address review feedback on PR-number search:

- Only augment results on the first page of an unscoped search. Running
  on every page re-prepended the PR match and truncated a real result
  off each subsequent page; running under a workflow/repository filter
  leaked tasks that don't satisfy the filter.
- Add idx_github_task_prs_pr_number so the lookup is index-backed
  instead of a full scan (pr_number is the 3rd column of the existing
  UNIQUE constraint, unusable as a leading key).
- Log instead of silently dropping a task when GetTask fails in the
  PR-match loop.

* test(github): dedup PR-lookup test setup into shared newTestStore

Add workspace_id to newTestStore's tasks table and drop the near-identical
newTestStoreWithWorkspaces helper, per review feedback.

* perf(tasks): batch-fetch PR-matched tasks instead of N+1 GetTask

Add a repository GetTasksByIDs and use it in augmentWithPRMatches so PR-number
search loads all matched tasks in one query instead of one GetTask per id,
addressing the N+1 noted in review. Fetch+filter is extracted into a
fetchPRMatchedTasks helper that preserves resolver order and dedup.
2026-06-05 16:20:30 +01:00

631 lines
20 KiB
Go

package service
import (
"context"
"sync"
"testing"
"time"
orchmodels "github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// fakeWSGroupRepo is an in-memory implementation of WorkspaceGroupRepo
// covering the slim surface AttachWorkspacePolicy needs.
type fakeWSGroupRepo struct {
groups map[string]*orchmodels.WorkspaceGroup
members map[string]map[string]string // groupID -> taskID -> role
mu sync.Mutex
createID int
}
func newFakeWSGroupRepo() *fakeWSGroupRepo {
return &fakeWSGroupRepo{
groups: map[string]*orchmodels.WorkspaceGroup{},
members: map[string]map[string]string{},
}
}
func (f *fakeWSGroupRepo) CreateWorkspaceGroup(_ context.Context, g *orchmodels.WorkspaceGroup) error {
f.mu.Lock()
defer f.mu.Unlock()
if g.ID == "" {
f.createID++
g.ID = "g-" + itoa(f.createID)
}
if g.CleanupStatus == "" {
g.CleanupStatus = orchmodels.WorkspaceCleanupStatusActive
}
f.groups[g.ID] = g
f.members[g.ID] = map[string]string{}
return nil
}
func (f *fakeWSGroupRepo) GetWorkspaceGroup(_ context.Context, id string) (*orchmodels.WorkspaceGroup, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.groups[id], nil
}
func (f *fakeWSGroupRepo) GetWorkspaceGroupForTask(_ context.Context, taskID string) (*orchmodels.WorkspaceGroup, error) {
f.mu.Lock()
defer f.mu.Unlock()
for gid, mem := range f.members {
if _, ok := mem[taskID]; ok {
return f.groups[gid], nil
}
}
return nil, nil
}
func (f *fakeWSGroupRepo) AddWorkspaceGroupMember(_ context.Context, groupID, taskID, role string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.members[groupID] == nil {
f.members[groupID] = map[string]string{}
}
if _, ok := f.members[groupID][taskID]; !ok {
f.members[groupID][taskID] = role
}
return nil
}
// Phase 6 surface — base implementation. The cascade-specific
// fakeWSGroupRepoCascade overrides Release/List/UpdateStatus.
func (f *fakeWSGroupRepo) ReleaseWorkspaceGroupMember(_ context.Context, groupID, taskID, _, _ string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.members[groupID] != nil {
delete(f.members[groupID], taskID)
}
return nil
}
func (f *fakeWSGroupRepo) RestoreWorkspaceGroupMemberByCascade(_ context.Context, _, _ string) error {
return nil
}
func (f *fakeWSGroupRepo) ListActiveWorkspaceGroupMembers(_ context.Context, groupID string) ([]orchmodels.WorkspaceGroupMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := []orchmodels.WorkspaceGroupMember{}
for tid, role := range f.members[groupID] {
out = append(out, orchmodels.WorkspaceGroupMember{
WorkspaceGroupID: groupID,
TaskID: tid,
Role: role,
})
}
return out, nil
}
// ListWorkspaceGroupMembers returns the same set as the Active variant
// in the fake — tests that need to distinguish released vs active
// override this on fakeWSGroupRepoCascade.
func (f *fakeWSGroupRepo) ListWorkspaceGroupMembers(_ context.Context, groupID string) ([]orchmodels.WorkspaceGroupMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := []orchmodels.WorkspaceGroupMember{}
for tid, role := range f.members[groupID] {
out = append(out, orchmodels.WorkspaceGroupMember{
WorkspaceGroupID: groupID,
TaskID: tid,
Role: role,
})
}
return out, nil
}
func (f *fakeWSGroupRepo) UpdateWorkspaceGroupCleanupStatus(_ context.Context, _, _, _ string, _ *time.Time) error {
return nil
}
func (f *fakeWSGroupRepo) MarkWorkspaceMaterialized(_ context.Context, id string, m orchmodels.MaterializedWorkspace) error {
f.mu.Lock()
defer f.mu.Unlock()
g := f.groups[id]
if g == nil {
return nil
}
g.MaterializedPath = m.Path
g.MaterializedEnvironmentID = m.EnvironmentID
g.MaterializedKind = m.Kind
g.OwnedByKandev = m.OwnedByKandev
g.RestoreConfigJSON = m.RestoreConfig
if m.OwnedByKandev {
g.CleanupPolicy = orchmodels.WorkspaceCleanupPolicyDeleteWhenLastMemberArchivedOrDel
} else {
g.CleanupPolicy = orchmodels.WorkspaceCleanupPolicyNeverDelete
}
return nil
}
func (f *fakeWSGroupRepo) UpdateWorkspaceGroupRestoreStatus(_ context.Context, _, _, _ string) error {
return nil
}
// fakeBlockerRepo is a tiny in-memory implementation of BlockerRepository
// so AttachWorkspacePolicy's sequential-chain logic can be exercised.
type fakeBlockerRepo struct {
mu sync.Mutex
blockers []*orchmodels.TaskBlocker
}
func (f *fakeBlockerRepo) CreateTaskBlocker(_ context.Context, b *orchmodels.TaskBlocker) error {
f.mu.Lock()
defer f.mu.Unlock()
f.blockers = append(f.blockers, b)
return nil
}
func (f *fakeBlockerRepo) ListTaskBlockers(_ context.Context, taskID string) ([]*orchmodels.TaskBlocker, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := []*orchmodels.TaskBlocker{}
for _, b := range f.blockers {
if b.TaskID == taskID {
out = append(out, b)
}
}
return out, nil
}
func (f *fakeBlockerRepo) DeleteTaskBlocker(_ context.Context, taskID, blockerTaskID string) error {
f.mu.Lock()
defer f.mu.Unlock()
for i, b := range f.blockers {
if b.TaskID == taskID && b.BlockerTaskID == blockerTaskID {
f.blockers = append(f.blockers[:i], f.blockers[i+1:]...)
return nil
}
}
return nil
}
func (f *fakeBlockerRepo) ListTasksBlockedBy(_ context.Context, blockerTaskID string) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
var ids []string
for _, b := range f.blockers {
if b.BlockerTaskID == blockerTaskID {
ids = append(ids, b.TaskID)
}
}
return ids, nil
}
// fakeTaskRepo provides the minimal TaskRepository surface AttachWorkspacePolicy
// uses. It supports GetTask + ListChildren so sibling lookup works.
type fakeTaskRepo struct {
mu sync.Mutex
tasks map[string]*models.Task
children map[string][]string // parentID -> ordered child IDs
}
func newFakeTaskRepo() *fakeTaskRepo {
return &fakeTaskRepo{
tasks: map[string]*models.Task{},
children: map[string][]string{},
}
}
func (f *fakeTaskRepo) addTask(id, parentID, ws string) {
f.mu.Lock()
defer f.mu.Unlock()
f.tasks[id] = &models.Task{ID: id, ParentID: parentID, WorkspaceID: ws}
if parentID != "" {
f.children[parentID] = append(f.children[parentID], id)
}
}
func (f *fakeTaskRepo) GetTask(_ context.Context, id string) (*models.Task, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.tasks[id], nil
}
func (f *fakeTaskRepo) GetTasksByIDs(_ context.Context, ids []string) ([]*models.Task, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []*models.Task
for _, id := range ids {
if t, ok := f.tasks[id]; ok {
out = append(out, t)
}
}
return out, nil
}
func (f *fakeTaskRepo) ListChildren(_ context.Context, parentID string) ([]*models.Task, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := []*models.Task{}
for _, id := range f.children[parentID] {
if t, ok := f.tasks[id]; ok && t.ArchivedAt == nil {
out = append(out, t)
}
}
return out, nil
}
// ReparentDirectChildren mirrors the sqlite impl: every child of
// oldParentID is updated to point at newParentID. Used by the
// no-cascade delete tests to verify children are orphaned to root
// rather than left dangling.
func (f *fakeTaskRepo) ReparentDirectChildren(_ context.Context, oldParentID, newParentID string) error {
f.mu.Lock()
defer f.mu.Unlock()
ids := f.children[oldParentID]
delete(f.children, oldParentID)
for _, id := range ids {
if t, ok := f.tasks[id]; ok {
t.ParentID = newParentID
}
if newParentID != "" {
f.children[newParentID] = append(f.children[newParentID], id)
}
}
return nil
}
func (f *fakeTaskRepo) ListChildrenIncludingArchived(_ context.Context, parentID string) ([]*models.Task, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := []*models.Task{}
for _, id := range f.children[parentID] {
if t, ok := f.tasks[id]; ok {
out = append(out, t)
}
}
return out, nil
}
// Stubs to satisfy repository.TaskRepository — only the methods actually
// used by AttachWorkspacePolicy do real work.
func (f *fakeTaskRepo) ListSiblings(context.Context, string) ([]*models.Task, error) {
return nil, nil
}
func newPhase4Service(t *testing.T, fakeTasks *fakeTaskRepo, blockers BlockerRepository, ws WorkspaceGroupRepo) *HandoffService {
t.Helper()
tr := &phase4TaskRepo{base: fakeTasks}
return NewHandoffService(tr, nil, nil, blockers, ws, nil)
}
// phase4TaskRepo satisfies repository.TaskRepository by delegating the
// methods AttachWorkspacePolicy actually calls and panicking on the rest
// (which would catch accidental new dependencies in tests).
type phase4TaskRepo struct {
base *fakeTaskRepo
}
func (r *phase4TaskRepo) GetTask(ctx context.Context, id string) (*models.Task, error) {
return r.base.GetTask(ctx, id)
}
func (r *phase4TaskRepo) GetTasksByIDs(ctx context.Context, ids []string) ([]*models.Task, error) {
return r.base.GetTasksByIDs(ctx, ids)
}
func (r *phase4TaskRepo) ListChildren(ctx context.Context, parentID string) ([]*models.Task, error) {
return r.base.ListChildren(ctx, parentID)
}
func (r *phase4TaskRepo) ListChildrenIncludingArchived(ctx context.Context, parentID string) ([]*models.Task, error) {
return r.base.ListChildrenIncludingArchived(ctx, parentID)
}
func (r *phase4TaskRepo) ReparentDirectChildren(ctx context.Context, oldParentID, newParentID string) error {
return r.base.ReparentDirectChildren(ctx, oldParentID, newParentID)
}
// All other TaskRepository methods panic — the AttachWorkspacePolicy
// path should only call GetTask + ListChildren. If a future change
// reaches into another method the panic forces a deliberate update.
func (r *phase4TaskRepo) panicNotUsed(name string) {
panic("phase4TaskRepo: unexpected call to " + name)
}
// The full TaskRepository interface has many methods; we panic-stub them so
// the compiler is happy. In tests we only ever drive AttachWorkspacePolicy.
func (r *phase4TaskRepo) CreateTask(context.Context, *models.Task) error {
r.panicNotUsed("CreateTask")
return nil
}
func (r *phase4TaskRepo) UpdateTask(context.Context, *models.Task) error {
r.panicNotUsed("UpdateTask")
return nil
}
func (r *phase4TaskRepo) DeleteTask(context.Context, string) error {
r.panicNotUsed("DeleteTask")
return nil
}
func (r *phase4TaskRepo) ListTasks(context.Context, string) ([]*models.Task, error) {
r.panicNotUsed("ListTasks")
return nil, nil
}
func (r *phase4TaskRepo) ListTasksByWorkspace(context.Context, string, string, string, string, int, int, bool, bool, bool, bool) ([]*models.Task, int, error) {
r.panicNotUsed("ListTasksByWorkspace")
return nil, 0, nil
}
func (r *phase4TaskRepo) ListTasksByWorkflowStep(context.Context, string) ([]*models.Task, error) {
r.panicNotUsed("ListTasksByWorkflowStep")
return nil, nil
}
func (r *phase4TaskRepo) ArchiveTask(context.Context, string) error {
r.panicNotUsed("ArchiveTask")
return nil
}
func (r *phase4TaskRepo) ArchiveTaskIfActive(context.Context, string, string) (bool, error) {
return false, nil
}
func (r *phase4TaskRepo) UnarchiveTaskByCascade(context.Context, string, string) (bool, error) {
return false, nil
}
func (r *phase4TaskRepo) ListTasksForAutoArchive(context.Context) ([]*models.Task, error) {
r.panicNotUsed("ListTasksForAutoArchive")
return nil, nil
}
func (r *phase4TaskRepo) CountOpenWatcherCreatedTasks(context.Context, string, string) (int, error) {
r.panicNotUsed("CountOpenWatcherCreatedTasks")
return 0, nil
}
func (r *phase4TaskRepo) UpdateTaskState(context.Context, string, v1.TaskState) error {
r.panicNotUsed("UpdateTaskState")
return nil
}
func (r *phase4TaskRepo) UpdateTaskStateIfCurrentIn(context.Context, string, v1.TaskState, []v1.TaskState) (v1.TaskState, bool, error) {
r.panicNotUsed("UpdateTaskStateIfCurrentIn")
return "", false, nil
}
func (r *phase4TaskRepo) CountTasksByWorkflow(context.Context, string) (int, error) {
r.panicNotUsed("CountTasksByWorkflow")
return 0, nil
}
func (r *phase4TaskRepo) CountTasksByWorkflowStep(context.Context, string) (int, error) {
r.panicNotUsed("CountTasksByWorkflowStep")
return 0, nil
}
func (r *phase4TaskRepo) AddTaskToWorkflow(context.Context, string, string, string, int) error {
r.panicNotUsed("AddTaskToWorkflow")
return nil
}
func (r *phase4TaskRepo) RemoveTaskFromWorkflow(context.Context, string, string) error {
r.panicNotUsed("RemoveTaskFromWorkflow")
return nil
}
func (r *phase4TaskRepo) ListTasksByProject(context.Context, string) ([]*models.Task, error) {
r.panicNotUsed("ListTasksByProject")
return nil, nil
}
func (r *phase4TaskRepo) ListTasksByAssignee(context.Context, string) ([]*models.Task, error) {
r.panicNotUsed("ListTasksByAssignee")
return nil, nil
}
func (r *phase4TaskRepo) ListTaskTree(context.Context, string, models.TaskTreeFilters) ([]*models.Task, error) {
r.panicNotUsed("ListTaskTree")
return nil, nil
}
func (r *phase4TaskRepo) ListSiblings(ctx context.Context, taskID string) ([]*models.Task, error) {
// Implemented via the fake's data so phase 7 GetTaskContext tests
// can exercise the sibling rule without relying on the real repo.
r.base.mu.Lock()
self := r.base.tasks[taskID]
r.base.mu.Unlock()
if self == nil || self.ParentID == "" {
return []*models.Task{}, nil
}
siblings, err := r.base.ListChildren(ctx, self.ParentID)
if err != nil {
return nil, err
}
out := make([]*models.Task, 0, len(siblings))
for _, s := range siblings {
if s.ID != taskID {
out = append(out, s)
}
}
return out, nil
}
func (r *phase4TaskRepo) IncrementTaskSequence(context.Context, string) (int, error) {
r.panicNotUsed("IncrementTaskSequence")
return 0, nil
}
func (r *phase4TaskRepo) GetWorkspaceTaskPrefix(context.Context, string) (string, string, error) {
r.panicNotUsed("GetWorkspaceTaskPrefix")
return "", "", nil
}
func TestWorkspacePolicy_MetadataBlock(t *testing.T) {
pol := WorkspacePolicy{
Mode: "inherit_parent",
DefaultChildWorkspace: "new_workspace",
DefaultChildOrdering: "sequential",
}
meta := pol.MetadataBlock()
ws, ok := meta["workspace"].(map[string]interface{})
if !ok {
t.Fatalf("workspace map missing: %#v", meta)
}
if ws["mode"] != "inherit_parent" {
t.Errorf("mode = %v", ws["mode"])
}
if ws["default_child_workspace"] != "new_workspace" {
t.Errorf("default_child_workspace = %v", ws["default_child_workspace"])
}
if ws["default_child_ordering"] != "sequential" {
t.Errorf("default_child_ordering = %v", ws["default_child_ordering"])
}
empty := WorkspacePolicy{Mode: "new_workspace"}
got := empty.MetadataBlock()
if got == nil {
t.Fatal("non-empty policy must return a metadata block")
}
none := WorkspacePolicy{}
if got := none.MetadataBlock(); got != nil {
t.Fatalf("empty policy should return nil, got %v", got)
}
}
func TestWorkspacePolicy_NeedsAttachment(t *testing.T) {
cases := []struct {
name string
pol WorkspacePolicy
want bool
}{
{"new_workspace + parallel parent", WorkspacePolicy{Mode: "new_workspace"}, false},
{"inherit_parent", WorkspacePolicy{Mode: "inherit_parent"}, true},
{"shared_group", WorkspacePolicy{Mode: "shared_group", GroupID: "g1"}, true},
{"new_workspace + sequential parent", WorkspacePolicy{Mode: "new_workspace", ParentOrdering: "sequential"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.pol.NeedsAttachment(); got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
func TestAttachWorkspacePolicy_InheritParentCreatesGroupAndJoinsChild(t *testing.T) {
tasks := newFakeTaskRepo()
tasks.addTask("parent", "", "ws-1")
tasks.addTask("child", "parent", "ws-1")
groups := newFakeWSGroupRepo()
svc := newPhase4Service(t, tasks, &fakeBlockerRepo{}, groups)
pol := WorkspacePolicy{Mode: "inherit_parent"}
if err := svc.AttachWorkspacePolicy(context.Background(), "child", "parent", pol); err != nil {
t.Fatalf("attach: %v", err)
}
if len(groups.groups) != 1 {
t.Fatalf("expected 1 group, got %d", len(groups.groups))
}
var gid string
for id := range groups.groups {
gid = id
}
if groups.members[gid]["parent"] != orchmodels.WorkspaceMemberRoleOwner {
t.Errorf("parent should be owner, got %q", groups.members[gid]["parent"])
}
if groups.members[gid]["child"] != orchmodels.WorkspaceMemberRoleMember {
t.Errorf("child should be member, got %q", groups.members[gid]["child"])
}
// A second inherit_parent child reuses the existing group.
tasks.addTask("child2", "parent", "ws-1")
if err := svc.AttachWorkspacePolicy(context.Background(), "child2", "parent", pol); err != nil {
t.Fatalf("attach 2: %v", err)
}
if len(groups.groups) != 1 {
t.Errorf("group must be reused; got %d groups", len(groups.groups))
}
if _, ok := groups.members[gid]["child2"]; !ok {
t.Error("child2 should be added to the same group")
}
}
func TestAttachWorkspacePolicy_SharedGroupRefusesInactive(t *testing.T) {
tasks := newFakeTaskRepo()
tasks.addTask("child", "parent", "ws-1")
groups := newFakeWSGroupRepo()
g := &orchmodels.WorkspaceGroup{
ID: "g-cleaned", WorkspaceID: "ws-1", OwnerTaskID: "x",
MaterializedKind: orchmodels.WorkspaceGroupKindSingleRepo,
CleanupStatus: orchmodels.WorkspaceCleanupStatusCleaned,
}
groups.groups[g.ID] = g
groups.members[g.ID] = map[string]string{}
svc := newPhase4Service(t, tasks, &fakeBlockerRepo{}, groups)
err := svc.AttachWorkspacePolicy(context.Background(), "child", "parent",
WorkspacePolicy{Mode: "shared_group", GroupID: "g-cleaned"})
if err == nil {
t.Fatal("expected error for cleaned group")
}
}
func TestAttachWorkspacePolicy_SequentialChainsSiblings(t *testing.T) {
tasks := newFakeTaskRepo()
tasks.addTask("parent", "", "ws-1")
blockers := &fakeBlockerRepo{}
svc := newPhase4Service(t, tasks, blockers, newFakeWSGroupRepo())
pol := WorkspacePolicy{Mode: "new_workspace", ParentOrdering: "sequential"}
// Attach in creation order — the production caller (handleCreateTask)
// always invokes attach immediately after creating the task, so the
// child is the most recently-added sibling at attach time.
tasks.addTask("a", "parent", "ws-1")
if err := svc.AttachWorkspacePolicy(context.Background(), "a", "parent", pol); err != nil {
t.Fatalf("attach a: %v", err)
}
tasks.addTask("b", "parent", "ws-1")
// Attach for b: b should be blocked-by a.
if err := svc.AttachWorkspacePolicy(context.Background(), "b", "parent", pol); err != nil {
t.Fatalf("attach b: %v", err)
}
tasks.addTask("c", "parent", "ws-1")
// Attach for c: c should be blocked-by b.
if err := svc.AttachWorkspacePolicy(context.Background(), "c", "parent", pol); err != nil {
t.Fatalf("attach c: %v", err)
}
if len(blockers.blockers) != 2 {
t.Fatalf("expected 2 blocker edges, got %d", len(blockers.blockers))
}
bb, _ := blockers.ListTaskBlockers(context.Background(), "b")
if len(bb) != 1 || bb[0].BlockerTaskID != "a" {
t.Errorf("b blockers = %+v", bb)
}
cb, _ := blockers.ListTaskBlockers(context.Background(), "c")
if len(cb) != 1 || cb[0].BlockerTaskID != "b" {
t.Errorf("c blockers = %+v", cb)
}
}
// TestAttachWorkspacePolicy_SequentialIsRaceFree exercises the per-parent
// mutex by attaching multiple siblings concurrently. The blocker chain
// should be deterministic (no duplicate or self-blocking edges).
//
// Real callers (handleCreateTask) call addTask + attach atomically per
// sibling; we model that here by adding+attaching each goroutine in one
// critical section so the contention is over the per-parent attach lock.
func TestAttachWorkspacePolicy_SequentialIsRaceFree(t *testing.T) {
tasks := newFakeTaskRepo()
tasks.addTask("parent", "", "ws-1")
blockers := &fakeBlockerRepo{}
svc := newPhase4Service(t, tasks, blockers, newFakeWSGroupRepo())
pol := WorkspacePolicy{Mode: "new_workspace", ParentOrdering: "sequential"}
const n = 8
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(id string) {
defer wg.Done()
tasks.addTask(id, "parent", "ws-1")
_ = svc.AttachWorkspacePolicy(context.Background(), id, "parent", pol)
}("k" + itoa(i))
}
wg.Wait()
// Under raw concurrency the first task to claim the per-parent lock
// may already see one or more siblings (their addTask raced ahead),
// so the count of blockers depends on interleaving — anywhere from
// n-1 (deterministic chain) up to n (every attach saw a previous
// sibling). The contract we DO enforce is: no self-edges, and each
// sibling has at most one blocker (the lock prevents double-attach).
if len(blockers.blockers) > n {
t.Errorf("blocker edges = %d, want <= %d", len(blockers.blockers), n)
}
for _, b := range blockers.blockers {
if b.TaskID == b.BlockerTaskID {
t.Errorf("self-blocker detected: %s", b.TaskID)
}
}
seen := map[string]bool{}
for _, b := range blockers.blockers {
if seen[b.TaskID] {
t.Errorf("task %s has more than one blocker (lock leaked)", b.TaskID)
}
seen[b.TaskID] = true
}
}