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
133 lines
5.1 KiB
Go
133 lines
5.1 KiB
Go
package integration
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"orchestra/internal/domain"
|
|
"orchestra/internal/human"
|
|
"orchestra/internal/operations"
|
|
"orchestra/internal/orchestrator"
|
|
"orchestra/internal/registry"
|
|
"orchestra/internal/router"
|
|
"orchestra/internal/workphase"
|
|
)
|
|
|
|
// F23. Human steering must work throughout the task, not only after a pull
|
|
// request exists. Pull-request feedback is a separate concept with a narrower
|
|
// window; conflating them would leave every pre-submission phase unsteerable,
|
|
// which is the state run 3 was diagnosed in.
|
|
//
|
|
// The task here never submits anything. It sits in its first phase, leased and
|
|
// running, and a comment on its own issue still becomes a standing decision
|
|
// that the live session is handed at its next verified turn boundary.
|
|
func TestPreSubmissionCommentSteersALiveSession(t *testing.T) {
|
|
s, reg, _ := setup(t)
|
|
task := ingest(t, s, "381")
|
|
src := &tracingSource{tr: &trace{}}
|
|
rec := &human.Reconciler{Store: s, Sources: map[string]human.Source{"gitea": src}}
|
|
s.PreLease = func(id string) error { return rec.Reconcile(context.Background(), id) }
|
|
|
|
c := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{}, Adapters: adapters{&harness{occupancy: .1}}, StatePath: t.TempDir() + "/sessions.json", Hard: .8}
|
|
c.ReconcileHumanInput = rec.Reconcile
|
|
rt := router.Router{Store: s, Registry: reg, Reachability: alwaysReachable{}, OnLease: func(e domain.Event) error {
|
|
return c.Start(context.Background(), e)
|
|
}}
|
|
if leased, err := rt.AssignPending(); err != nil || len(leased) != 1 {
|
|
t.Fatalf("leased=%d err=%v", len(leased), err)
|
|
}
|
|
leasedTask, _ := s.Task(task.ID)
|
|
if leasedTask.Lease == nil {
|
|
t.Fatal("task is not leased")
|
|
}
|
|
// Still in the first phase, and nothing has been submitted.
|
|
if leasedTask.WorkPhase != "" && leasedTask.WorkPhase != domain.WorkPhaseFrame {
|
|
t.Fatalf("phase = %q", leasedTask.WorkPhase)
|
|
}
|
|
epoch := leasedTask.Lease.Epoch
|
|
|
|
// No human input yet: the boundary answers with nothing to deliver.
|
|
_, decisions, err := c.RemoteTurn(context.Background(), task.ID, epoch, orchestrator.TurnContinue, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(decisions) != 0 {
|
|
t.Fatalf("decisions before any comment = %+v", decisions)
|
|
}
|
|
|
|
// The human comments on the issue while the session runs.
|
|
src.next = "918"
|
|
src.inputs = []human.Input{{Provider: "gitea", ExternalID: "918", Author: "kami", Body: "no, use b"}}
|
|
|
|
_, decisions, err = c.RemoteTurn(context.Background(), task.ID, epoch, orchestrator.TurnContinue, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(decisions) != 1 || decisions[0].Value != "no, use b" {
|
|
t.Fatalf("decisions = %+v", decisions)
|
|
}
|
|
|
|
// Reported as delivered, so the same correction is not re-sent every turn.
|
|
_, decisions, err = c.RemoteTurn(context.Background(), task.ID, epoch, orchestrator.TurnContinue, []string{decisions[0].ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(decisions) != 0 {
|
|
t.Fatalf("a delivered decision repeated: %+v", decisions)
|
|
}
|
|
}
|
|
|
|
// F21 and F22 at the coordinator boundary: an accepted request moves the phase
|
|
// and seals what the phase produced, and a stale session's request is refused.
|
|
func TestPhaseRequestPathSealsAndFences(t *testing.T) {
|
|
s, reg, _ := setup(t)
|
|
task := ingest(t, s, "381")
|
|
c := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{}, Adapters: adapters{&harness{occupancy: .1}}, StatePath: t.TempDir() + "/sessions.json", Hard: .8}
|
|
rt := router.Router{Store: s, Registry: reg, Reachability: alwaysReachable{}, OnLease: func(e domain.Event) error {
|
|
return c.Start(context.Background(), e)
|
|
}}
|
|
if _, err := rt.AssignPending(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
leased, _ := s.Task(task.ID)
|
|
epoch := leased.Lease.Epoch
|
|
project := registry.Project{ID: "p"}
|
|
|
|
if _, err := operations.RequestWorkPhase(s, project, task.ID, epoch, "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := s.Task(task.ID)
|
|
if got.WorkPhase != domain.WorkPhaseResearch {
|
|
t.Fatalf("phase = %q", got.WorkPhase)
|
|
}
|
|
|
|
// A request carrying a lease epoch that no longer owns the task is a stale
|
|
// opinion from a session that has been superseded.
|
|
if _, err := operations.RequestWorkPhase(s, project, task.ID, "stale-epoch", "op-2", domain.WorkPhaseResearch, domain.WorkPhasePlan, sealed(t, researchArtifact)); err == nil {
|
|
t.Fatal("a stale epoch advanced the phase")
|
|
}
|
|
if got, _ := s.Task(task.ID); got.WorkPhase != domain.WorkPhaseResearch {
|
|
t.Fatalf("phase moved on a stale request: %q", got.WorkPhase)
|
|
}
|
|
|
|
// The real owner's request seals the research the next phase will read.
|
|
if _, err := operations.RequestWorkPhase(s, project, task.ID, epoch, "op-3", domain.WorkPhaseResearch, domain.WorkPhasePlan, sealed(t, researchArtifact)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ = s.Task(task.ID)
|
|
if got.WorkPhase != domain.WorkPhasePlan || got.ResearchRef == "" {
|
|
t.Fatalf("task = %+v", got)
|
|
}
|
|
}
|
|
|
|
var researchArtifact = workphase.Research{Findings: []workphase.Finding{{ID: "r1", Confidence: workphase.Fact, Claim: "runs per figure", Evidence: "attr.go:88"}}}
|
|
|
|
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
|
|
}
|