feat: mock atomizer and work-performer services with contract tests (phase 11)

- 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>
This commit is contained in:
etalon
2026-06-12 20:31:39 +02:00
parent b1c9a42810
commit d698f70c4f
11 changed files with 1129 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# --- build ---
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY *.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/atomizer .
# --- runtime ---
FROM alpine:3.21
RUN apk add --no-cache ca-certificates && adduser -S -G nogroup app
USER app
COPY --from=build /out/atomizer /usr/local/bin/atomizer
EXPOSE 8090
ENTRYPOINT ["/usr/local/bin/atomizer"]
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
module atomizer-mock
go 1.26
+252
View File
@@ -0,0 +1,252 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
// llmClient calls Anthropic via the OpenAI-compatible chat-completions
// endpoint (default) or the native Messages API (LLM_API_STYLE=anthropic).
// Plain net/http, no SDK (§9.1).
type llmClient struct {
cfg config
log *slog.Logger
http *http.Client
}
func newLLMClient(cfg config, log *slog.Logger) *llmClient {
return &llmClient{cfg: cfg, log: log, http: &http.Client{Timeout: 110 * time.Second}}
}
const atomizeSystem = `You are an expert software project planner. You split one ticket into small, well-scoped, independently deliverable developer tasks.
Respond with ONLY a JSON object, no prose and no markdown fences, exactly matching:
{"tasks":[{"title":"...","description":"...","acceptanceCriteria":["..."],"effortCoefficient":0.25}],"notes":"short advice for the consultant"}
Rules: between MIN and MAX tasks; every effortCoefficient is a number in (0,1]; all effortCoefficients MUST sum to exactly 1.0; titles are concise; descriptions are self-contained instructions; each task has 1-4 testable acceptance criteria.`
const extendSystem = `You are an expert software project planner. You design exactly ONE additional sibling task that extends the given task's functionality per the extension note.
Respond with ONLY a JSON object, no prose and no markdown fences, exactly matching:
{"task":{"title":"...","description":"...","acceptanceCriteria":["..."],"effortCoefficient":0.4},"notes":"short advice"}
Rules: effortCoefficient is the effort RELATIVE to the source task, a number in (0,2].`
func ticketPrompt(req atomizeRequest) string {
var sb strings.Builder
fmt.Fprintf(&sb, "TICKET: %s\n\nDESCRIPTION:\n%s\n", req.Title, req.Description)
if len(req.AcceptanceCriteria) > 0 {
sb.WriteString("\nACCEPTANCE CRITERIA:\n")
for _, c := range req.AcceptanceCriteria {
fmt.Fprintf(&sb, "- %s\n", c)
}
}
if len(req.Links) > 0 {
fmt.Fprintf(&sb, "\nLINKS: %s\n", strings.Join(req.Links, ", "))
}
for _, a := range req.Attachments {
fmt.Fprintf(&sb, "ATTACHMENT: %s (%s) %s\n", a.Name, a.MimeType, a.URL)
}
return sb.String()
}
func (l *llmClient) atomize(ctx context.Context, req atomizeRequest, minT, maxT int) ([]subTask, string, string, error) {
if l.cfg.apiKey == "" {
return fallbackAtomize(req, minT, maxT), "offline-fallback", "Deterministic split (no ANTHROPIC_API_KEY configured).", nil
}
system := strings.NewReplacer("MIN", fmt.Sprint(minT), "MAX", fmt.Sprint(maxT)).Replace(atomizeSystem)
user := ticketPrompt(req)
if req.SubdivisionNote != "" {
user += "\nCONSULTANT NOTE: " + req.SubdivisionNote
}
var parsed struct {
Tasks []subTask `json:"tasks"`
Notes string `json:"notes"`
}
raw, err := l.completeWithRetry(ctx, system, user, func(text string) error {
if err := json.Unmarshal([]byte(stripFences(text)), &parsed); err != nil {
return err
}
if len(parsed.Tasks) == 0 {
return fmt.Errorf("no tasks in response")
}
for _, t := range parsed.Tasks {
if t.Title == "" || t.EffortCoefficient <= 0 {
return fmt.Errorf("invalid task entry")
}
}
return nil
})
if err != nil {
l.log.Warn("llm atomize failed, using fallback", "err", err)
return fallbackAtomize(req, minT, maxT), "offline-fallback",
"LLM unavailable (" + err.Error() + "); deterministic split.", nil
}
_ = raw
return parsed.Tasks, l.cfg.model, parsed.Notes, nil
}
func (l *llmClient) extend(ctx context.Context, req atomizeRequest) (subTask, string, string, error) {
if l.cfg.apiKey == "" {
return fallbackExtend(req), "offline-fallback", "Deterministic extension (no ANTHROPIC_API_KEY configured).", nil
}
user := ticketPrompt(req) + "\nEXTENSION NOTE (required scope): " + req.ExtensionNote
var parsed struct {
Task subTask `json:"task"`
Notes string `json:"notes"`
}
_, err := l.completeWithRetry(ctx, extendSystem, user, func(text string) error {
if err := json.Unmarshal([]byte(stripFences(text)), &parsed); err != nil {
return err
}
if parsed.Task.Title == "" {
return fmt.Errorf("no task in response")
}
return nil
})
if err != nil {
l.log.Warn("llm extend failed, using fallback", "err", err)
return fallbackExtend(req), "offline-fallback",
"LLM unavailable (" + err.Error() + "); deterministic extension.", nil
}
return parsed.Task, l.cfg.model, parsed.Notes, nil
}
// completeWithRetry runs the chat completion and retries ONCE on parse
// failure with a corrective hint (§9.1).
func (l *llmClient) completeWithRetry(ctx context.Context, system, user string,
validate func(string) error) (string, error) {
text, err := l.complete(ctx, system, user)
if err != nil {
return "", err
}
if vErr := validate(text); vErr == nil {
return text, nil
} else {
l.log.Warn("llm response failed validation, retrying once", "err", vErr)
}
text, err = l.complete(ctx, system,
user+"\n\nIMPORTANT: your previous reply was not valid JSON matching the schema. Reply with ONLY the JSON object.")
if err != nil {
return "", err
}
if vErr := validate(text); vErr != nil {
return "", fmt.Errorf("invalid JSON after retry: %w", vErr)
}
return text, nil
}
func (l *llmClient) complete(ctx context.Context, system, user string) (string, error) {
if l.cfg.apiStyle == "anthropic" {
return l.completeNative(ctx, system, user)
}
return l.completeOpenAI(ctx, system, user)
}
// completeOpenAI uses Anthropic's OpenAI-compatible endpoint.
func (l *llmClient) completeOpenAI(ctx context.Context, system, user string) (string, error) {
body, _ := json.Marshal(map[string]any{
"model": l.cfg.model,
"max_tokens": 4096,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
l.cfg.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+l.cfg.apiKey)
data, err := l.do(req)
if err != nil {
return "", err
}
var out struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("decode chat completion: %w", err)
}
if len(out.Choices) == 0 {
return "", fmt.Errorf("no choices in response")
}
return out.Choices[0].Message.Content, nil
}
// completeNative uses the native Anthropic Messages API (flip via
// LLM_API_STYLE=anthropic if the compatibility endpoint misbehaves, §9.1).
func (l *llmClient) completeNative(ctx context.Context, system, user string) (string, error) {
body, _ := json.Marshal(map[string]any{
"model": l.cfg.model,
"max_tokens": 4096,
"system": system,
"messages": []map[string]string{{"role": "user", "content": user}},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
l.cfg.baseURL+"/messages", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", l.cfg.apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
data, err := l.do(req)
if err != nil {
return "", err
}
var out struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("decode messages response: %w", err)
}
for _, c := range out.Content {
if c.Type == "text" {
return c.Text, nil
}
}
return "", fmt.Errorf("no text content in response")
}
func (l *llmClient) do(req *http.Request) ([]byte, error) {
resp, err := l.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("llm api status %d: %.300s", resp.StatusCode, data)
}
return data, nil
}
// stripFences defensively removes markdown code fences and surrounding prose
// (§9.1) by slicing from the first '{' to the last '}'.
func stripFences(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '{'); i >= 0 {
if j := strings.LastIndexByte(s, '}'); j > i {
return s[i : j+1]
}
}
return s
}
+243
View File
@@ -0,0 +1,243 @@
// 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
}