f87b954f27
- 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>
256 lines
7.6 KiB
Go
256 lines
7.6 KiB
Go
//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")
|
||
}
|
||
}
|