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