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:
etalon
2026-06-12 19:17:09 +02:00
parent 4e01b64bbd
commit f87b954f27
23 changed files with 2628 additions and 7 deletions
@@ -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")
}
}