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
+153
View File
@@ -0,0 +1,153 @@
// Package atomize is the app-side client of the Atomization Service (§5.1)
// plus the coefficient validation/normalization rules.
package atomize
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"time"
"bountyboard/internal/extsvc"
)
type Attachment struct {
Name string `json:"name"`
URL string `json:"url"`
MimeType string `json:"mimeType"`
}
type Constraints struct {
MinTasks int `json:"minTasks,omitempty"`
MaxTasks int `json:"maxTasks,omitempty"`
}
type AtomizeRequest struct {
TaskID string `json:"taskId"`
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria []string `json:"acceptanceCriteria"`
Attachments []Attachment `json:"attachments"`
Links []string `json:"links"`
SubdivisionNote string `json:"subdivisionNote,omitempty"`
Constraints *Constraints `json:"constraints,omitempty"`
}
type ExtendRequest struct {
TaskID string `json:"taskId"`
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria []string `json:"acceptanceCriteria"`
Attachments []Attachment `json:"attachments"`
Links []string `json:"links"`
ExtensionNote string `json:"extensionNote"`
}
type SubTask struct {
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria []string `json:"acceptanceCriteria"`
EffortCoefficient float64 `json:"effortCoefficient"`
}
type AtomizeResponse struct {
Tasks []SubTask `json:"tasks"`
Model string `json:"model,omitempty"`
Notes string `json:"notes,omitempty"`
}
type ExtendResponse struct {
Task SubTask `json:"task"`
Model string `json:"model,omitempty"`
Notes string `json:"notes,omitempty"`
}
// ErrBadCoefficients is the 502-class rejection when the service returns
// coefficients we are not willing to repair (§5.1).
var ErrBadCoefficients = errors.New("atomizer returned invalid effort coefficients")
type Client struct {
c *extsvc.Client
}
// New builds the client. baseURL is resolved per call so the admin settings
// override applies without restart.
func New(baseURL func() string, token string, timeout time.Duration) *Client {
return &Client{c: extsvc.NewClient("atomizer", baseURL, token, timeout)}
}
func (a *Client) BreakerState() string { return a.c.BreakerState() }
func (a *Client) Healthy(ctx context.Context) error { return a.c.Healthy(ctx) }
// Atomize calls POST /v1/atomize and applies the §5.1 contract: coefficients
// must sum to 1 ± 0.001; a deviation ≤ 0.05 is defensively renormalized,
// anything worse is rejected as a service error.
func (a *Client) Atomize(ctx context.Context, req AtomizeRequest) (*AtomizeResponse, error) {
var resp AtomizeResponse
if err := a.c.Call(ctx, http.MethodPost, "/v1/atomize", req, &resp); err != nil {
return nil, err
}
if len(resp.Tasks) == 0 {
return nil, fmt.Errorf("%w: empty task list", ErrBadCoefficients)
}
coeffs := make([]float64, len(resp.Tasks))
for i, t := range resp.Tasks {
coeffs[i] = t.EffortCoefficient
}
normalized, err := NormalizeCoefficients(coeffs)
if err != nil {
return nil, err
}
for i := range resp.Tasks {
resp.Tasks[i].EffortCoefficient = normalized[i]
}
return &resp, nil
}
// Extend calls POST /v1/extend. The sibling coefficient is relative to the
// source task and may exceed 1 up to 2.0 (§5.1).
func (a *Client) Extend(ctx context.Context, req ExtendRequest) (*ExtendResponse, error) {
var resp ExtendResponse
if err := a.c.Call(ctx, http.MethodPost, "/v1/extend", req, &resp); err != nil {
return nil, err
}
c := resp.Task.EffortCoefficient
if c <= 0 || c > 2.0 || math.IsNaN(c) {
return nil, fmt.Errorf("%w: extension coefficient %v outside (0, 2]", ErrBadCoefficients, c)
}
return &resp, nil
}
// NormalizeCoefficients enforces the §5.1 sum contract. Each coefficient
// must be in (0, 1]; the sum must be 1 ± 0.001 (accepted as-is) or within
// ± 0.05 (renormalized proportionally, then rounded to 4 decimals with the
// remainder folded into the largest entry so the sum is exactly 1).
func NormalizeCoefficients(coeffs []float64) ([]float64, error) {
if len(coeffs) == 0 {
return nil, fmt.Errorf("%w: empty", ErrBadCoefficients)
}
sum := 0.0
for _, c := range coeffs {
if math.IsNaN(c) || c <= 0 || c > 1 {
return nil, fmt.Errorf("%w: coefficient %v outside (0, 1]", ErrBadCoefficients, c)
}
sum += c
}
if math.Abs(sum-1) > 0.05 {
return nil, fmt.Errorf("%w: sum %.4f deviates more than 0.05 from 1", ErrBadCoefficients, sum)
}
out := make([]float64, len(coeffs))
largest := 0
total := 0.0
for i, c := range coeffs {
out[i] = math.Round(c/sum*10000) / 10000
total += out[i]
if out[i] > out[largest] {
largest = i
}
}
out[largest] = math.Round((out[largest]+1-total)*10000) / 10000
return out, nil
}
+143
View File
@@ -0,0 +1,143 @@
package atomize
import (
"context"
"encoding/json"
"errors"
"math"
"net/http"
"net/http/httptest"
"testing"
"time"
"bountyboard/internal/extsvc"
)
func TestNormalizeCoefficients(t *testing.T) {
tests := []struct {
name string
in []float64
wantErr bool
wantSum float64
}{
{"exact sum kept", []float64{0.2, 0.5, 0.3}, false, 1},
{"tiny deviation accepted", []float64{0.2, 0.5, 0.3001}, false, 1},
{"small deviation renormalized", []float64{0.21, 0.52, 0.31}, false, 1}, // sum 1.04
{"under-sum renormalized", []float64{0.3, 0.3, 0.36}, false, 1}, // sum 0.96
{"large deviation rejected", []float64{0.5, 0.5, 0.5}, true, 0},
{"zero coefficient rejected", []float64{0, 0.5, 0.5}, true, 0},
{"negative rejected", []float64{-0.1, 0.6, 0.5}, true, 0},
{"above one rejected", []float64{1.2}, true, 0},
{"empty rejected", nil, true, 0},
{"single exact", []float64{1.0}, false, 1},
{"NaN rejected", []float64{math.NaN(), 0.5}, true, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, err := NormalizeCoefficients(tt.in)
if tt.wantErr {
if !errors.Is(err, ErrBadCoefficients) {
t.Fatalf("want ErrBadCoefficients, got %v", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
sum := 0.0
for _, c := range out {
if c <= 0 || c > 1 {
t.Errorf("normalized coefficient %v out of range", c)
}
sum += c
}
if math.Abs(sum-tt.wantSum) > 1e-9 {
t.Errorf("sum = %.6f, want exactly %v (coeffs %v)", sum, tt.wantSum, out)
}
})
}
}
func fakeAtomizer(t *testing.T, handler http.HandlerFunc) *Client {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return New(func() string { return srv.URL }, "test-token", 5*time.Second)
}
func TestAtomizeAppliesNormalization(t *testing.T) {
client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
t.Errorf("missing bearer token")
}
var req AtomizeRequest
json.NewDecoder(r.Body).Decode(&req)
if req.TaskID != "01JTASK" {
t.Errorf("taskId = %q", req.TaskID)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AtomizeResponse{Tasks: []SubTask{
{Title: "A", EffortCoefficient: 0.31}, // sum 1.03 → renormalize
{Title: "B", EffortCoefficient: 0.41},
{Title: "C", EffortCoefficient: 0.31},
}})
})
resp, err := client.Atomize(context.Background(), AtomizeRequest{TaskID: "01JTASK"})
if err != nil {
t.Fatal(err)
}
sum := 0.0
for _, st := range resp.Tasks {
sum += st.EffortCoefficient
}
if math.Abs(sum-1) > 1e-9 {
t.Fatalf("normalized sum = %v", sum)
}
}
func TestAtomizeRejectsBadSum(t *testing.T) {
client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AtomizeResponse{Tasks: []SubTask{
{Title: "A", EffortCoefficient: 0.9},
{Title: "B", EffortCoefficient: 0.9},
}})
})
_, err := client.Atomize(context.Background(), AtomizeRequest{})
if !errors.Is(err, ErrBadCoefficients) {
t.Fatalf("want ErrBadCoefficients, got %v", err)
}
}
func TestExtendValidatesCoefficient(t *testing.T) {
coeff := 2.5
client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ExtendResponse{Task: SubTask{Title: "X", EffortCoefficient: coeff}})
})
if _, err := client.Extend(context.Background(), ExtendRequest{}); !errors.Is(err, ErrBadCoefficients) {
t.Fatalf("coefficient 2.5: want ErrBadCoefficients, got %v", err)
}
coeff = 1.4 // legal for extensions
if _, err := client.Extend(context.Background(), ExtendRequest{}); err != nil {
t.Fatalf("coefficient 1.4 should pass: %v", err)
}
}
func TestBreakerOpensAfterRepeatedFailures(t *testing.T) {
client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
})
for i := 0; i < 3; i++ {
if _, err := client.Atomize(context.Background(), AtomizeRequest{}); err == nil {
t.Fatal("expected failure")
}
}
if client.BreakerState() != "open" {
t.Fatalf("breaker = %s, want open", client.BreakerState())
}
_, err := client.Atomize(context.Background(), AtomizeRequest{})
if !errors.Is(err, extsvc.ErrBreakerOpen) {
t.Fatalf("want ErrBreakerOpen, got %v", err)
}
}
+263
View File
@@ -0,0 +1,263 @@
package atomize
import (
"context"
"fmt"
"log/slog"
"time"
"bountyboard/internal/domain"
"bountyboard/internal/files"
"bountyboard/internal/jobs"
"bountyboard/internal/store"
)
// JobKindSubdivide / JobKindExtend are the queue kinds this service handles.
const (
JobKindSubdivide = "atomize.subdivide"
JobKindExtend = "atomize.extend"
)
type SubdividePayload struct {
TaskID string `bson:"taskId"`
Note string `bson:"note"`
MinTasks int `bson:"minTasks"`
MaxTasks int `bson:"maxTasks"`
ActorID string `bson:"actorId"`
FromStatus string `bson:"fromStatus"` // revert target on terminal failure
}
type ExtendPayload struct {
TaskID string `bson:"taskId"`
Note string `bson:"note"`
ActorID string `bson:"actorId"`
}
// Service executes atomization jobs: it owns the §5.1 request building,
// child/sibling task creation, status transitions, notifications, and live
// board updates.
type Service struct {
st *store.Store
client *Client
log *slog.Logger
appBaseURL string
secret []byte
publish func(channel, event string, payload any)
}
func NewService(st *store.Store, client *Client, log *slog.Logger,
appBaseURL string, secret []byte, publish func(string, string, any)) *Service {
if publish == nil {
publish = func(string, string, any) {}
}
return &Service{st: st, client: client, log: log,
appBaseURL: appBaseURL, secret: secret, publish: publish}
}
// requestAttachments builds §5.1 attachment refs: cached files get signed
// app URLs (fetchable without a session); uncached ones keep the upstream
// URL.
func (s *Service) requestAttachments(t *domain.Task) []Attachment {
out := make([]Attachment, 0, len(t.Attachments))
for _, a := range t.Attachments {
url := a.URL
if a.FileID != "" {
url = files.SignedURL(s.appBaseURL, s.secret, a.FileID, time.Now().Add(files.DefaultTokenTTL))
}
out = append(out, Attachment{Name: a.Name, URL: url, MimeType: a.MimeType})
}
return out
}
func taskLinks(t *domain.Task) []string {
links := append([]string{}, t.Links...)
if t.External != nil && t.External.URL != "" {
links = append(links, t.External.URL)
}
return links
}
func (s *Service) HandleSubdivide(ctx context.Context, job *jobs.Job) error {
var p SubdividePayload
if err := jobs.DecodePayload(job, &p); err != nil {
return err
}
t, err := s.st.TaskByID(ctx, p.TaskID)
if err != nil {
return fmt.Errorf("load task: %w", err)
}
if t.Status != domain.StatusAtomizing {
s.log.Warn("subdivide job for non-atomizing task, skipping", "task", t.ID, "status", t.Status)
return nil
}
req := AtomizeRequest{
TaskID: t.ID,
Title: t.Title,
Description: t.Description,
AcceptanceCriteria: t.AcceptanceCriteria,
Attachments: s.requestAttachments(t),
Links: taskLinks(t),
SubdivisionNote: p.Note,
}
if p.MinTasks > 0 || p.MaxTasks > 0 {
req.Constraints = &Constraints{MinTasks: p.MinTasks, MaxTasks: p.MaxTasks}
}
resp, err := s.client.Atomize(ctx, req)
if err != nil {
if jobs.IsFinalAttempt(job) {
s.revertSubdivide(ctx, t, &p, err)
}
return fmt.Errorf("atomize call: %w", err)
}
// Create children, all atomized (§4.4).
childIDs := make([]string, 0, len(resp.Tasks))
for _, sub := range resp.Tasks {
child := &domain.Task{
CustomerID: t.CustomerID,
ConsultantID: t.ConsultantID,
Origin: domain.OriginSubdivided,
ParentID: t.ID,
RootID: t.RootID,
Title: sub.Title,
Description: sub.Description,
AcceptanceCriteria: sub.AcceptanceCriteria,
EffortCoefficient: sub.EffortCoefficient,
Budget: t.Budget,
Bounty: domain.ComputeBounty(sub.EffortCoefficient, t.Budget),
Status: domain.StatusAtomized,
Timeline: []domain.TimelineEntry{{
At: time.Now().UTC(), ActorID: "system", Event: "created_by_subdivision",
Data: map[string]any{"parentId": t.ID, "model": resp.Model},
}},
}
if err := s.st.InsertTask(ctx, child); err != nil {
return fmt.Errorf("insert child: %w", err)
}
childIDs = append(childIDs, child.ID)
}
err = s.st.TransitionTask(ctx, t, domain.StatusAtomized, p.ActorID, "subdivided",
map[string]any{"children": len(childIDs), "model": resp.Model, "notes": resp.Notes},
map[string]any{"atomizationNote": p.Note})
if err != nil {
// children exist; a concurrent edit raced us — reload once and retry
if fresh, ferr := s.st.TaskByID(ctx, t.ID); ferr == nil && fresh.Status == domain.StatusAtomizing {
err = s.st.TransitionTask(ctx, fresh, domain.StatusAtomized, p.ActorID, "subdivided",
map[string]any{"children": len(childIDs)}, map[string]any{"atomizationNote": p.Note})
}
if err != nil {
return fmt.Errorf("finish subdivision: %w", err)
}
}
s.notify(ctx, t.ConsultantID, "subdivision_ready",
fmt.Sprintf("Subdivision ready: %d subtasks", len(childIDs)),
t.Title, "/consultant/board?customerId="+t.CustomerID)
s.publish("board", "task.subdivided", map[string]any{
"taskId": t.ID, "customerId": t.CustomerID, "childIds": childIDs,
})
return nil
}
func (s *Service) revertSubdivide(ctx context.Context, t *domain.Task, p *SubdividePayload, cause error) {
target := domain.TaskStatus(p.FromStatus)
if !target.Valid() {
target = domain.StatusImported
}
fresh, err := s.st.TaskByID(ctx, t.ID)
if err != nil || fresh.Status != domain.StatusAtomizing {
return
}
if err := s.st.TransitionTask(ctx, fresh, target, "system", "atomization_failed",
map[string]any{"error": cause.Error()}, nil); err != nil {
s.log.Error("revert failed subdivision", "task", t.ID, "err", err)
return
}
s.notify(ctx, t.ConsultantID, "atomization_failed",
"Subdivision failed: "+t.Title, cause.Error(),
"/consultant/board?customerId="+t.CustomerID)
s.publish("board", "task.atomization_failed", map[string]any{
"taskId": t.ID, "customerId": t.CustomerID,
})
}
func (s *Service) HandleExtend(ctx context.Context, job *jobs.Job) error {
var p ExtendPayload
if err := jobs.DecodePayload(job, &p); err != nil {
return err
}
t, err := s.st.TaskByID(ctx, p.TaskID)
if err != nil {
return fmt.Errorf("load task: %w", err)
}
if t.Status != domain.StatusImported && t.Status != domain.StatusAtomized {
s.log.Warn("extend job for wrong-status task, skipping", "task", t.ID, "status", t.Status)
return nil
}
resp, err := s.client.Extend(ctx, ExtendRequest{
TaskID: t.ID,
Title: t.Title,
Description: t.Description,
AcceptanceCriteria: t.AcceptanceCriteria,
Attachments: s.requestAttachments(t),
Links: taskLinks(t),
ExtensionNote: p.Note,
})
if err != nil {
if jobs.IsFinalAttempt(job) {
s.notify(ctx, t.ConsultantID, "atomization_failed",
"Extension failed: "+t.Title, err.Error(),
"/consultant/board?customerId="+t.CustomerID)
}
return fmt.Errorf("extend call: %w", err)
}
sibling := &domain.Task{
CustomerID: t.CustomerID,
ConsultantID: t.ConsultantID,
Origin: domain.OriginExtended,
ParentID: t.ID, // §4.4: parentId records the sibling source
RootID: t.RootID,
Title: resp.Task.Title,
Description: resp.Task.Description,
AcceptanceCriteria: resp.Task.AcceptanceCriteria,
EffortCoefficient: resp.Task.EffortCoefficient,
Budget: t.Budget, // §5.1: same budget as the source
Bounty: domain.ComputeBounty(resp.Task.EffortCoefficient, t.Budget),
Status: domain.StatusAtomized,
Timeline: []domain.TimelineEntry{{
At: time.Now().UTC(), ActorID: "system", Event: "created_by_extension",
Data: map[string]any{"sourceId": t.ID, "model": resp.Model, "note": p.Note},
}},
}
if err := s.st.InsertTask(ctx, sibling); err != nil {
return fmt.Errorf("insert sibling: %w", err)
}
if err := s.st.AppendTimeline(ctx, t.ID, domain.TimelineEntry{
ActorID: p.ActorID, Event: "extended",
Data: map[string]any{"siblingId": sibling.ID, "note": p.Note},
}); err != nil {
s.log.Warn("append extend timeline", "err", err)
}
s.notify(ctx, t.ConsultantID, "extension_ready",
"Extension ready: "+sibling.Title, t.Title,
"/consultant/board?customerId="+t.CustomerID)
s.publish("board", "task.extended", map[string]any{
"taskId": t.ID, "siblingId": sibling.ID, "customerId": t.CustomerID,
})
return nil
}
func (s *Service) notify(ctx context.Context, userID, kind, title, body, link string) {
n := &store.Notification{UserID: userID, Kind: kind, Title: title, Body: body, Link: link}
if err := s.st.InsertNotification(ctx, n); err != nil {
s.log.Warn("insert notification", "err", err)
return
}
s.publish("notifications", "notification", n)
}
@@ -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")
}
}