d698f70c4f
- services/atomizer: standalone Go module implementing §5.1 — Anthropic
OpenAI-compatible chat completions by default, native /v1/messages when
LLM_API_STYLE=anthropic, strict-JSON prompting, defensive fence-stripping
parse with one retry, deterministic equal-split fallback without an API
key, exact coefficient-sum normalization, bearer auth
- services/work-performer: Node 20 http server implementing §5.2 — single
concurrency, /work/{jobId}/TASK.md preparation, attachment downloads,
optional shallow git clone, claude CLI execution with JSON output,
simulated success when the CLI is unavailable (offline demo), artifact
endpoint, idempotent HMAC-signed callbacks with retry, best-effort cancel
- compose profile 'mocks': separate builds/ports/tokens, healthchecks,
${HOME}/.claude(.json) mounted read-only into the performer
- contract tests (go test -tags=contract) for both services; Makefile
test-contract target; verified live incl. a real Claude Code job run
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
244 lines
7.1 KiB
Go
244 lines
7.1 KiB
Go
// atomizer-mock is the standalone placeholder Atomization Service (§5.1,
|
|
// §9.1). It calls Anthropic through the OpenAI-compatible chat-completions
|
|
// endpoint by default, or the native Messages API when
|
|
// LLM_API_STYLE=anthropic, and falls back to a deterministic equal split
|
|
// when no ANTHROPIC_API_KEY is configured so the whole system works offline.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
type config struct {
|
|
port string
|
|
token string
|
|
apiKey string
|
|
baseURL string
|
|
model string
|
|
apiStyle string // openai | anthropic
|
|
}
|
|
|
|
func loadConfig() config {
|
|
get := func(k, def string) string {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
return config{
|
|
port: get("PORT", "8090"),
|
|
token: os.Getenv("ATOMIZER_TOKEN"),
|
|
apiKey: os.Getenv("ANTHROPIC_API_KEY"),
|
|
baseURL: strings.TrimRight(get("ANTHROPIC_OPENAI_BASE_URL", "https://api.anthropic.com/v1"), "/"),
|
|
model: get("ANTHROPIC_MODEL", "claude-sonnet-4-6"),
|
|
apiStyle: get("LLM_API_STYLE", "openai"),
|
|
}
|
|
}
|
|
|
|
type subTask struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
AcceptanceCriteria []string `json:"acceptanceCriteria"`
|
|
EffortCoefficient float64 `json:"effortCoefficient"`
|
|
}
|
|
|
|
type atomizeRequest struct {
|
|
TaskID string `json:"taskId"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
AcceptanceCriteria []string `json:"acceptanceCriteria"`
|
|
Attachments []struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
MimeType string `json:"mimeType"`
|
|
} `json:"attachments"`
|
|
Links []string `json:"links"`
|
|
SubdivisionNote string `json:"subdivisionNote"`
|
|
ExtensionNote string `json:"extensionNote"`
|
|
Constraints *struct {
|
|
MinTasks int `json:"minTasks"`
|
|
MaxTasks int `json:"maxTasks"`
|
|
} `json:"constraints"`
|
|
}
|
|
|
|
type server struct {
|
|
cfg config
|
|
log *slog.Logger
|
|
llm *llmClient
|
|
}
|
|
|
|
func main() {
|
|
cfg := loadConfig()
|
|
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
s := &server{cfg: cfg, log: log, llm: newLLMClient(cfg, log)}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
})
|
|
mux.HandleFunc("POST /v1/atomize", s.auth(s.handleAtomize))
|
|
mux.HandleFunc("POST /v1/extend", s.auth(s.handleExtend))
|
|
|
|
srv := &http.Server{Addr: ":" + cfg.port, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
go func() {
|
|
log.Info("atomizer-mock listening", "port", cfg.port,
|
|
"llm", map[bool]string{true: "anthropic:" + cfg.apiStyle, false: "fallback (no api key)"}[cfg.apiKey != ""])
|
|
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
|
log.Error("serve", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}()
|
|
<-ctx.Done()
|
|
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
srv.Shutdown(shutCtx)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, status int, code, msg string) {
|
|
writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": msg}})
|
|
}
|
|
|
|
func (s *server) auth(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if s.cfg.token != "" && r.Header.Get("Authorization") != "Bearer "+s.cfg.token {
|
|
writeErr(w, http.StatusUnauthorized, "unauthorized", "missing or invalid bearer token")
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func (s *server) handleAtomize(w http.ResponseWriter, r *http.Request) {
|
|
var req atomizeRequest
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
minT, maxT := 2, 8
|
|
if req.Constraints != nil {
|
|
if req.Constraints.MinTasks > 0 {
|
|
minT = req.Constraints.MinTasks
|
|
}
|
|
if req.Constraints.MaxTasks > 0 {
|
|
maxT = req.Constraints.MaxTasks
|
|
}
|
|
}
|
|
if minT > maxT {
|
|
minT = maxT
|
|
}
|
|
|
|
tasks, model, notes, err := s.llm.atomize(r.Context(), req, minT, maxT)
|
|
if err != nil {
|
|
s.log.Error("atomize failed", "taskId", req.TaskID, "err", err)
|
|
writeErr(w, http.StatusBadGateway, "llm_failed", err.Error())
|
|
return
|
|
}
|
|
normalizeSum(tasks)
|
|
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "model": model, "notes": notes})
|
|
}
|
|
|
|
func (s *server) handleExtend(w http.ResponseWriter, r *http.Request) {
|
|
var req atomizeRequest
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.ExtensionNote) == "" {
|
|
writeErr(w, http.StatusBadRequest, "note_required", "extensionNote is required")
|
|
return
|
|
}
|
|
task, model, notes, err := s.llm.extend(r.Context(), req)
|
|
if err != nil {
|
|
s.log.Error("extend failed", "taskId", req.TaskID, "err", err)
|
|
writeErr(w, http.StatusBadGateway, "llm_failed", err.Error())
|
|
return
|
|
}
|
|
if task.EffortCoefficient <= 0 || task.EffortCoefficient > 2 {
|
|
task.EffortCoefficient = 0.3
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"task": task, "model": model, "notes": notes})
|
|
}
|
|
|
|
// normalizeSum forces coefficients to sum to exactly 1.0.
|
|
func normalizeSum(tasks []subTask) {
|
|
sum := 0.0
|
|
for _, t := range tasks {
|
|
sum += t.EffortCoefficient
|
|
}
|
|
if sum <= 0 {
|
|
eq := 1.0 / float64(len(tasks))
|
|
for i := range tasks {
|
|
tasks[i].EffortCoefficient = eq
|
|
}
|
|
sum = 1.0
|
|
}
|
|
total := 0.0
|
|
largest := 0
|
|
for i := range tasks {
|
|
tasks[i].EffortCoefficient = round4(tasks[i].EffortCoefficient / sum)
|
|
total += tasks[i].EffortCoefficient
|
|
if tasks[i].EffortCoefficient > tasks[largest].EffortCoefficient {
|
|
largest = i
|
|
}
|
|
}
|
|
tasks[largest].EffortCoefficient = round4(tasks[largest].EffortCoefficient + 1 - total)
|
|
}
|
|
|
|
func round4(v float64) float64 {
|
|
return float64(int64(v*10000+0.5)) / 10000
|
|
}
|
|
|
|
func fallbackAtomize(req atomizeRequest, minT, maxT int) []subTask {
|
|
n := (minT + maxT) / 2
|
|
if n < 1 {
|
|
n = 3
|
|
}
|
|
out := make([]subTask, n)
|
|
eq := round4(1.0 / float64(n))
|
|
for i := range out {
|
|
out[i] = subTask{
|
|
Title: fmt.Sprintf("%s — part %d of %d", req.Title, i+1, n),
|
|
Description: fmt.Sprintf("Deterministic offline split %d/%d of:\n\n%s", i+1, n, req.Description),
|
|
AcceptanceCriteria: append([]string{}, req.AcceptanceCriteria...),
|
|
EffortCoefficient: eq,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func fallbackExtend(req atomizeRequest) subTask {
|
|
return subTask{
|
|
Title: req.Title + " — extension: " + firstLine(req.ExtensionNote, 60),
|
|
Description: "Deterministic offline extension of the source task.\n\nRequested scope: " + req.ExtensionNote,
|
|
AcceptanceCriteria: []string{"extension scope implemented: " + firstLine(req.ExtensionNote, 120)},
|
|
EffortCoefficient: 0.3,
|
|
}
|
|
}
|
|
|
|
func firstLine(s string, max int) string {
|
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
if len(s) > max {
|
|
s = s[:max]
|
|
}
|
|
return s
|
|
}
|