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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user