822f086451
The brief at agentctx.go:167 advertised findings[].id and findings[].confidence to every research session. The struct carried neither, so encoding/json dropped both on every seal, silently, for as long as the schema has existed. A plan phase had nothing stable to cite and no way to tell an observation from an assumption. Finding gains ID and Confidence. Ids are unique within an artifact and shaped so "research:<id>" is unambiguous in plan prose. Confidence is fact, inference, or assumption, matching the labels the output style already uses. DecodeStoredResearch reads what is already in the CAS and backfills both. Refusing an artifact sealed before this change would block every task whose research predates it, including at rotation, where the agent that could fix it is already gone. A backfilled finding is labelled inference rather than fact: the old schema required evidence and made no verification claim, so upgrading it on the way in would be the same class of lie this commit removes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
166 lines
5.4 KiB
Go
166 lines
5.4 KiB
Go
package operations
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"orchestra/internal/authz"
|
|
"orchestra/internal/domain"
|
|
"orchestra/internal/registry"
|
|
"orchestra/internal/store"
|
|
"orchestra/internal/workphase"
|
|
)
|
|
|
|
func phaseStore(t *testing.T) (*store.Store, string) {
|
|
t.Helper()
|
|
s, err := store.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := json.Marshal(map[string]any{"source": "gitea", "external_id": "381", "project": "p"})
|
|
id := domain.NewID()
|
|
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: id, Version: 1, Payload: b, Surface: string(authz.System)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s, id
|
|
}
|
|
|
|
// lease gives the task an owning session. A question or a phase change comes
|
|
// from a live session, so a test that skips the lease is exercising a state no
|
|
// agent can be in.
|
|
func lease(t *testing.T, s *store.Store, id string) {
|
|
t.Helper()
|
|
if _, err := s.Lease(id, "h1", time.Hour); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func sealed(t *testing.T, v interface{ Validate() error }) []byte {
|
|
t.Helper()
|
|
b, err := workphase.Encode(v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
var research = workphase.Research{Findings: []workphase.Finding{{ID: "r1", Confidence: workphase.Fact, Claim: "runs per figure", Evidence: "attr.go:88"}}}
|
|
var plan = workphase.Plan{Changes: []workphase.Change{{Target: "attr.go", Intent: "aggregate per person"}}}
|
|
|
|
func TestFullPhasePathSealsEachArtifact(t *testing.T) {
|
|
s, id := phaseStore(t)
|
|
project := registry.Project{ID: "p"}
|
|
|
|
// frame -> research needs no artifact: framing produces none.
|
|
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := s.Task(id); got.WorkPhase != domain.WorkPhaseResearch {
|
|
t.Fatalf("phase = %q", got.WorkPhase)
|
|
}
|
|
|
|
// research -> plan must seal the research.
|
|
if _, err := AdvanceWorkPhase(s, project, id, nil); !errors.Is(err, domain.ErrInvalid) {
|
|
t.Fatalf("leaving research without an artifact must fail, got %v", err)
|
|
}
|
|
if _, err := AdvanceWorkPhase(s, project, id, []byte(`{"findings":[]}`)); err == nil {
|
|
t.Fatal("an invalid research artifact must be rejected")
|
|
}
|
|
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := s.Task(id)
|
|
if got.WorkPhase != domain.WorkPhasePlan || got.ResearchRef == "" {
|
|
t.Fatalf("task = %+v", got)
|
|
}
|
|
|
|
// plan -> implement must seal the plan, and must not overwrite the
|
|
// research ref.
|
|
researchRef := got.ResearchRef
|
|
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, plan)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ = s.Task(id)
|
|
if got.WorkPhase != domain.WorkPhaseImplement || got.PlanRef == "" {
|
|
t.Fatalf("task = %+v", got)
|
|
}
|
|
if got.ResearchRef != researchRef {
|
|
t.Fatal("research ref was overwritten by the plan")
|
|
}
|
|
if got.PlanRef == got.ResearchRef {
|
|
t.Fatal("plan and research sealed to the same ref")
|
|
}
|
|
|
|
// implement -> review, then review sends work back to implement.
|
|
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := s.Task(id); got.WorkPhase != domain.WorkPhaseReview {
|
|
t.Fatalf("phase = %q", got.WorkPhase)
|
|
}
|
|
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := s.Task(id); got.WorkPhase != domain.WorkPhaseImplement {
|
|
t.Fatalf("phase = %q, review must be able to return work", got.WorkPhase)
|
|
}
|
|
}
|
|
|
|
// A project that declares a short path skips the phases it omits.
|
|
func TestProjectPathSkipsUndeclaredPhases(t *testing.T) {
|
|
s, id := phaseStore(t)
|
|
project := registry.Project{ID: "p", WorkPhases: []domain.WorkPhase{domain.WorkPhaseFrame, domain.WorkPhaseImplement, domain.WorkPhaseReview}}
|
|
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := s.Task(id)
|
|
if got.WorkPhase != domain.WorkPhaseImplement {
|
|
t.Fatalf("phase = %q, want implement", got.WorkPhase)
|
|
}
|
|
if got.ResearchRef != "" || got.PlanRef != "" {
|
|
t.Fatal("a skipped phase must not seal an artifact")
|
|
}
|
|
}
|
|
|
|
// The store refuses a phase move that is not legal, whatever a caller asks.
|
|
func TestIllegalTransitionRejectedAtTheAppendBoundary(t *testing.T) {
|
|
s, id := phaseStore(t)
|
|
task, _ := s.Task(id)
|
|
b, _ := json.Marshal(map[string]any{"phase": string(domain.WorkPhaseReview)})
|
|
err := s.Append(domain.Event{ID: domain.NewID(), Type: domain.EventWorkPhaseChanged, TaskID: id, Version: task.Version + 1, Payload: b, Surface: string(authz.System)})
|
|
if !errors.Is(err, domain.ErrInvalid) {
|
|
t.Fatalf("frame to review must be rejected, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEndOfPathIsRefused(t *testing.T) {
|
|
s, id := phaseStore(t)
|
|
project := registry.Project{ID: "p", WorkPhases: []domain.WorkPhase{domain.WorkPhaseFrame}}
|
|
if _, err := AdvanceWorkPhase(s, project, id, nil); !errors.Is(err, domain.ErrInvalid) {
|
|
t.Fatalf("want ErrInvalid at the end of the path, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPhaseChangeDoesNotTouchLifecycle(t *testing.T) {
|
|
s, id := phaseStore(t)
|
|
if _, err := s.Lease(id, "h1", 60_000_000_000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, _ := s.Task(id)
|
|
if _, err := AdvanceWorkPhase(s, registry.Project{ID: "p"}, id, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, _ := s.Task(id)
|
|
if after.State != before.State {
|
|
t.Fatalf("state changed %s -> %s", before.State, after.State)
|
|
}
|
|
if after.Lease == nil || *after.Lease != *before.Lease {
|
|
t.Fatal("lease changed")
|
|
}
|
|
if after.WorkPhase != domain.WorkPhaseResearch {
|
|
t.Fatalf("phase = %q", after.WorkPhase)
|
|
}
|
|
}
|