feat: atomization pipeline with job queue, atomizer client, WS hub, and board UI (phase 7)
- internal/extsvc: circuit breaker (§11.16) + retrying bearer JSON client - atomizer client: §5.1 contract incl. defensive coefficient normalization and extension coefficient range (0,2] - persisted jobs collection: atomic claim, panic recovery, exponential backoff (max 3), stale-running requeue, shutdown-safe bookkeeping - subdivide (async 202, atomizing status, re-run replaces unpublished children after confirm) and extend (sibling task, source budget) - failure path reverts status and notifies the consultant on final attempt - PATCH task editing with bounty recompute, budget cascade to descendants - publish single + bulk; task detail endpoint role-scoped - coder/websocket hub: multiplexed channels, origin check, 30s heartbeats; client ws.js with reconnect + 15s polling fallback - consultant atomization board: tree by root, coefficient sliders with live per-parent sum indicator, bounty preview, modals, shimmer, atomizer-down gating via /api/v1/service-health - fix: tasks external-ref unique index is partial, not sparse (compound sparse indexed every task through customerId and broke child inserts) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
//go:build integration
|
||||
|
||||
package atomize
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/jobs"
|
||||
"bountyboard/internal/metrics"
|
||||
"bountyboard/internal/store"
|
||||
"bountyboard/internal/testutil"
|
||||
"bountyboard/internal/ulid"
|
||||
)
|
||||
|
||||
type stack struct {
|
||||
st *store.Store
|
||||
svc *Service
|
||||
runner *jobs.Runner
|
||||
task *domain.Task
|
||||
}
|
||||
|
||||
func newStack(t *testing.T, atomizerHandler http.HandlerFunc) *stack {
|
||||
t.Helper()
|
||||
db := testutil.MongoDB(t)
|
||||
if err := store.EnsureIndexes(t.Context(), db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := &store.Store{Client: db.Client(), DB: db}
|
||||
|
||||
srv := httptest.NewServer(atomizerHandler)
|
||||
t.Cleanup(srv.Close)
|
||||
client := New(func() string { return srv.URL }, "tok", 10*time.Second)
|
||||
|
||||
logOut := io.Writer(io.Discard)
|
||||
if os.Getenv("TEST_LOG") == "1" {
|
||||
logOut = os.Stderr
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(logOut, nil))
|
||||
svc := NewService(st, client, log, "http://localhost:8787",
|
||||
[]byte("0123456789abcdef0123456789abcdef"), nil)
|
||||
runner := jobs.NewRunner(st, log, metrics.NewRegistry(), 2)
|
||||
runner.Register(JobKindSubdivide, svc.HandleSubdivide)
|
||||
runner.Register(JobKindExtend, svc.HandleExtend)
|
||||
|
||||
task := &domain.Task{
|
||||
CustomerID: ulid.New(), ConsultantID: ulid.New(),
|
||||
Origin: domain.OriginImported, Status: domain.StatusImported,
|
||||
Title: "Implement user import", Description: "Full description",
|
||||
AcceptanceCriteria: []string{"works"},
|
||||
Budget: 1000, EffortCoefficient: 1, Bounty: 1000,
|
||||
External: &domain.External{System: "demo", Key: "DEMO-1", URL: "https://demo.invalid/1"},
|
||||
}
|
||||
if err := st.InsertTask(t.Context(), task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &stack{st: st, svc: svc, runner: runner, task: task}
|
||||
}
|
||||
|
||||
// runJob synchronously claims + executes pending jobs until idle.
|
||||
func runJobs(t *testing.T, s *stack, deadline time.Duration) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), deadline)
|
||||
defer cancel()
|
||||
go s.runner.Run(ctx)
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
func goodAtomizer(calls *atomic.Int64) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if calls != nil {
|
||||
calls.Add(1)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/v1/atomize":
|
||||
json.NewEncoder(w).Encode(AtomizeResponse{
|
||||
Model: "claude-sonnet-4-6",
|
||||
Tasks: []SubTask{
|
||||
{Title: "Backend", Description: "API part", AcceptanceCriteria: []string{"a"}, EffortCoefficient: 0.5},
|
||||
{Title: "Frontend", Description: "UI part", AcceptanceCriteria: []string{"b"}, EffortCoefficient: 0.3},
|
||||
{Title: "Migration", Description: "DB part", AcceptanceCriteria: []string{"c"}, EffortCoefficient: 0.2},
|
||||
},
|
||||
})
|
||||
case "/v1/extend":
|
||||
json.NewEncoder(w).Encode(ExtendResponse{
|
||||
Task: SubTask{Title: "CSV mapping presets", Description: "extension",
|
||||
AcceptanceCriteria: []string{"x"}, EffortCoefficient: 0.4},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubdivideEndToEnd(t *testing.T) {
|
||||
s := newStack(t, goodAtomizer(nil))
|
||||
ctx := context.Background()
|
||||
|
||||
// consultant moved it to atomizing (handler does this before enqueue)
|
||||
if err := s.st.TransitionTask(ctx, s.task, domain.StatusAtomizing, "cons", "subdivide_requested", nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.runner.Enqueue(ctx, JobKindSubdivide, SubdividePayload{
|
||||
TaskID: s.task.ID, Note: "split it", ActorID: "cons", FromStatus: "imported",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runJobs(t, s, 4*time.Second)
|
||||
|
||||
parent, err := s.st.TaskByID(ctx, s.task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parent.Status != domain.StatusAtomized {
|
||||
t.Fatalf("parent status = %s, want atomized", parent.Status)
|
||||
}
|
||||
if parent.AtomizationNote != "split it" {
|
||||
t.Errorf("atomizationNote = %q", parent.AtomizationNote)
|
||||
}
|
||||
|
||||
children, err := s.st.ListTasks(ctx, store.TaskFilter{ParentID: s.task.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(children) != 3 {
|
||||
t.Fatalf("children = %d, want 3", len(children))
|
||||
}
|
||||
sum := 0.0
|
||||
for _, c := range children {
|
||||
if c.Status != domain.StatusAtomized || c.Origin != domain.OriginSubdivided {
|
||||
t.Errorf("child %s: status=%s origin=%s", c.ID, c.Status, c.Origin)
|
||||
}
|
||||
if c.RootID != s.task.ID || c.Budget != 1000 {
|
||||
t.Errorf("child %s: root=%s budget=%v", c.ID, c.RootID, c.Budget)
|
||||
}
|
||||
if c.Bounty != domain.ComputeBounty(c.EffortCoefficient, c.Budget) {
|
||||
t.Errorf("child %s: bounty %v != coeff %v × budget %v", c.ID, c.Bounty, c.EffortCoefficient, c.Budget)
|
||||
}
|
||||
sum += c.EffortCoefficient
|
||||
}
|
||||
if sum < 0.999 || sum > 1.001 {
|
||||
t.Errorf("children coefficients sum %v, want 1±0.001", sum)
|
||||
}
|
||||
|
||||
// consultant got the "ready" notification
|
||||
notifs, _ := s.st.ListNotifications(ctx, s.task.ConsultantID, false, "", 10)
|
||||
found := false
|
||||
for _, n := range notifs {
|
||||
if n.Kind == "subdivision_ready" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("missing subdivision_ready notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendEndToEnd(t *testing.T) {
|
||||
s := newStack(t, goodAtomizer(nil))
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.runner.Enqueue(ctx, JobKindExtend, ExtendPayload{
|
||||
TaskID: s.task.ID, Note: "add CSV presets", ActorID: "cons",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runJobs(t, s, 4*time.Second)
|
||||
|
||||
siblings, err := s.st.ListTasks(ctx, store.TaskFilter{ParentID: s.task.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(siblings) != 1 {
|
||||
t.Fatalf("siblings = %d, want exactly 1", len(siblings))
|
||||
}
|
||||
sib := siblings[0]
|
||||
if sib.Origin != domain.OriginExtended || sib.Status != domain.StatusAtomized {
|
||||
t.Fatalf("sibling: %s/%s", sib.Origin, sib.Status)
|
||||
}
|
||||
if sib.EffortCoefficient != 0.4 || sib.Bounty != 400 {
|
||||
t.Fatalf("sibling coeff=%v bounty=%v, want 0.4/400", sib.EffortCoefficient, sib.Bounty)
|
||||
}
|
||||
// source status unchanged, timeline notes the extension
|
||||
src, _ := s.st.TaskByID(ctx, s.task.ID)
|
||||
if src.Status != domain.StatusImported {
|
||||
t.Fatalf("source status = %s, want imported", src.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubdivideFailureRevertsAfterRetries(t *testing.T) {
|
||||
var calls atomic.Int64
|
||||
s := newStack(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, `{"error":{"code":"llm_unavailable","message":"model down"}}`, http.StatusBadGateway)
|
||||
})
|
||||
ctx := context.Background()
|
||||
if err := s.st.TransitionTask(ctx, s.task, domain.StatusAtomizing, "cons", "subdivide_requested", nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.runner.Enqueue(ctx, JobKindSubdivide, SubdividePayload{
|
||||
TaskID: s.task.ID, ActorID: "cons", FromStatus: "imported",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// job retries with backoff (10s, 20s) — run one attempt per window (the
|
||||
// HTTP client's own 5xx retries take ~4s), forcing queued retries due
|
||||
// immediately between windows to keep the test fast
|
||||
runJobs(t, s, 6*time.Second)
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := s.st.DB.Collection("jobs").UpdateMany(ctx,
|
||||
map[string]any{"status": jobs.StatusQueued},
|
||||
map[string]any{"$set": map[string]any{"runAfter": time.Now().UTC().Add(-time.Second)}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runJobs(t, s, 6*time.Second)
|
||||
}
|
||||
|
||||
parent, err := s.st.TaskByID(ctx, s.task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parent.Status != domain.StatusImported {
|
||||
t.Fatalf("status after exhausted retries = %s, want imported", parent.Status)
|
||||
}
|
||||
foundFailure := false
|
||||
for _, e := range parent.Timeline {
|
||||
if e.Event == "atomization_failed" {
|
||||
foundFailure = true
|
||||
}
|
||||
}
|
||||
if !foundFailure {
|
||||
t.Error("timeline missing atomization_failed")
|
||||
}
|
||||
notifs, _ := s.st.ListNotifications(ctx, s.task.ConsultantID, false, "", 10)
|
||||
failNotif := false
|
||||
for _, n := range notifs {
|
||||
if n.Kind == "atomization_failed" {
|
||||
failNotif = true
|
||||
}
|
||||
}
|
||||
if !failNotif {
|
||||
t.Error("missing atomization_failed notification")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user