7f12c7fc37
The v3 stack, previously an uncommitted working tree, plus this session's two units and the burn-in instrument. This commit is the burn-in build identity: coordinator and worker must both report this revision before a task is created. Workflow (earlier sessions, uncommitted until now): human decision events and reduction, source cursors and reconcile-before-launch, turn-boundary reconciliation, internal/agentctx as the single renderer, ace-fca phases with sealed artifacts, the trajectory gate, bounded grilling, independent review, task pr enforcement, and human review reflection. Capability restrictions at the agent boundary: an authz.Agent surface at GatedWrite may ask and may not act. It also fixes two bugs the unit exposed -- gated surfaces could not reach the two endpoints written for them, and RequestHumanDecision would block an unowned task while rejecting a question from the session that did own it. Turn-boundary reconcile-failure escalation: a streak of consecutive failures asks the session to hand off, fenced on the lease epoch, with reconcile_failure as a real handoff reason. The worker was dropping the coordinator's verdict on the floor; it now acts on it. Burn-in: herdr.WriteLaunchContext dumps the exact agentctx.Build result to <worktree>/.orchestra/launch.md at every launch, local and federated. BURNIN.md is the runbook. deploy/build.sh stamps both binaries from one commit. go build, go vet and go test ./... pass, 20 packages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
243 lines
7.8 KiB
Go
243 lines
7.8 KiB
Go
package human
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"orchestra/internal/authz"
|
|
"orchestra/internal/domain"
|
|
"orchestra/internal/store"
|
|
)
|
|
|
|
type fakeSource struct {
|
|
inputs []Input
|
|
next string
|
|
err error
|
|
calls int
|
|
seen []store.SourceCursor
|
|
}
|
|
|
|
func (f *fakeSource) FetchAfter(_ context.Context, task domain.Task, cursor store.SourceCursor) ([]Input, store.SourceCursor, error) {
|
|
f.calls++
|
|
f.seen = append(f.seen, cursor)
|
|
if f.err != nil {
|
|
return nil, store.SourceCursor{}, f.err
|
|
}
|
|
return f.inputs, store.SourceCursor{TaskID: task.ID, Provider: "gitea", Cursor: f.next}, nil
|
|
}
|
|
|
|
func input(id, body string) Input {
|
|
return Input{Provider: "gitea", ExternalID: id, Author: "kami", At: time.Unix(1700000000, 0).UTC(), Body: body}
|
|
}
|
|
|
|
func setup(t *testing.T) (string, *store.Store, domain.Task) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
s, err := store.Open(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Ingested directly: provider imports this package, so the test cannot.
|
|
created := []byte(`{"source":"gitea","external_id":"381","project":"p"}`)
|
|
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: domain.NewID(), Version: 1, Payload: created, Surface: string(authz.System)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tasks := s.Tasks()
|
|
if len(tasks) != 1 {
|
|
t.Fatalf("tasks=%d", len(tasks))
|
|
}
|
|
return dir, s, tasks[0]
|
|
}
|
|
|
|
func reconciler(s *store.Store, src Source) *Reconciler {
|
|
return &Reconciler{Store: s, Sources: map[string]Source{"gitea": src}}
|
|
}
|
|
|
|
func TestNewCommentBecomesStandingDecision(t *testing.T) {
|
|
_, s, task := setup(t)
|
|
src := &fakeSource{inputs: []Input{input("918", "no, use b")}, next: "918"}
|
|
if err := reconciler(s, src).Reconcile(context.Background(), task.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
intent, err := s.EffectiveIntent(task.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(intent.Decisions) != 1 {
|
|
t.Fatalf("standing set = %+v", intent.Decisions)
|
|
}
|
|
d := intent.Decisions[0]
|
|
if d.Value != "no, use b" || d.Kind != domain.HumanDecisionCorrection || d.Subject != "operator_instruction" {
|
|
t.Fatalf("decision = %+v", d)
|
|
}
|
|
if d.Source.Provider != "gitea" || d.Source.ExternalID != "918" {
|
|
t.Fatalf("provenance = %+v", d.Source)
|
|
}
|
|
c, ok := s.SourceCursor(task.ID, "gitea")
|
|
if !ok || c.Cursor != "918" {
|
|
t.Fatalf("cursor = %+v ok=%v", c, ok)
|
|
}
|
|
}
|
|
|
|
func TestNoNewInputIsANoOp(t *testing.T) {
|
|
_, s, task := setup(t)
|
|
before, _ := s.Task(task.ID)
|
|
src := &fakeSource{}
|
|
if err := reconciler(s, src).Reconcile(context.Background(), task.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, _ := s.Task(task.ID)
|
|
if after.Version != before.Version {
|
|
t.Fatalf("version moved %d -> %d", before.Version, after.Version)
|
|
}
|
|
if _, ok := s.SourceCursor(task.ID, "gitea"); ok {
|
|
t.Fatal("cursor advanced with no input")
|
|
}
|
|
}
|
|
|
|
func TestNoConfiguredSourcesProceeds(t *testing.T) {
|
|
_, s, task := setup(t)
|
|
r := &Reconciler{Store: s}
|
|
if err := r.Reconcile(context.Background(), task.ID); err != nil {
|
|
t.Fatalf("a deployment with no source configured must not be blocked: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSameCommentTwiceYieldsOneDecision(t *testing.T) {
|
|
_, s, task := setup(t)
|
|
src := &fakeSource{inputs: []Input{input("918", "no, use b")}, next: "918"}
|
|
r := reconciler(s, src)
|
|
for i := 0; i < 3; i++ {
|
|
if err := r.Reconcile(context.Background(), task.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
intent, err := s.EffectiveIntent(task.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(intent.Decisions) != 1 {
|
|
t.Fatalf("want 1 decision after 3 reconciles, got %d", len(intent.Decisions))
|
|
}
|
|
if src.seen[1].Cursor != "918" {
|
|
t.Fatalf("second fetch did not resume from the cursor: %+v", src.seen[1])
|
|
}
|
|
}
|
|
|
|
func TestProviderFailureFailsClosed(t *testing.T) {
|
|
_, s, task := setup(t)
|
|
src := &fakeSource{err: errors.New("gitea unreachable")}
|
|
err := reconciler(s, src).Reconcile(context.Background(), task.ID)
|
|
if err == nil {
|
|
t.Fatal("provider failure must not be swallowed")
|
|
}
|
|
if !strings.Contains(err.Error(), "gitea unreachable") {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
if _, ok := s.SourceCursor(task.ID, "gitea"); ok {
|
|
t.Fatal("cursor advanced despite fetch failure")
|
|
}
|
|
}
|
|
|
|
// A durable append is the precondition for advancing the cursor. If the log
|
|
// write fails, the input must be refetched on the next attempt.
|
|
func TestAppendFailureLeavesCursorInPlace(t *testing.T) {
|
|
dir, s, task := setup(t)
|
|
log := filepath.Join(dir, "events.jsonl")
|
|
if err := os.Chmod(log, 0400); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
src := &fakeSource{inputs: []Input{input("918", "no, use b")}, next: "918"}
|
|
if err := reconciler(s, src).Reconcile(context.Background(), task.ID); err == nil {
|
|
t.Fatal("append failure must fail reconciliation")
|
|
}
|
|
if _, ok := s.SourceCursor(task.ID, "gitea"); ok {
|
|
t.Fatal("cursor advanced despite append failure")
|
|
}
|
|
if err := os.Chmod(log, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := reconciler(s, src).Reconcile(context.Background(), task.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
intent, _ := s.EffectiveIntent(task.ID)
|
|
if len(intent.Decisions) != 1 || intent.Decisions[0].Value != "no, use b" {
|
|
t.Fatalf("retry did not record the decision: %+v", intent.Decisions)
|
|
}
|
|
}
|
|
|
|
// The reverse crash window: the decision is durable but the cursor write
|
|
// fails. Provenance uniqueness, not the cursor, is what stops the refetch
|
|
// from becoming a second copy of the same instruction.
|
|
func TestCursorWriteFailureDoesNotDuplicateDecision(t *testing.T) {
|
|
dir, s, task := setup(t)
|
|
// Occupying the cursor path with a directory makes the atomic rename fail.
|
|
if err := os.Mkdir(filepath.Join(dir, "source-cursors.json"), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
src := &fakeSource{inputs: []Input{input("918", "no, use b")}, next: "918"}
|
|
if err := reconciler(s, src).Reconcile(context.Background(), task.ID); err == nil {
|
|
t.Fatal("cursor write failure must be reported")
|
|
}
|
|
if _, ok := s.DecisionForSource("gitea", "918"); !ok {
|
|
t.Fatal("decision should already be durable")
|
|
}
|
|
if err := os.Remove(filepath.Join(dir, "source-cursors.json")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Same range refetched, because the cursor never advanced.
|
|
if err := reconciler(s, src).Reconcile(context.Background(), task.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
intent, _ := s.EffectiveIntent(task.ID)
|
|
if len(intent.Decisions) != 1 {
|
|
t.Fatalf("want 1 decision, got %d", len(intent.Decisions))
|
|
}
|
|
if c, ok := s.SourceCursor(task.ID, "gitea"); !ok || c.Cursor != "918" {
|
|
t.Fatalf("cursor = %+v ok=%v", c, ok)
|
|
}
|
|
}
|
|
|
|
func TestBatchRecordsEveryInputInOrder(t *testing.T) {
|
|
_, s, task := setup(t)
|
|
src := &fakeSource{next: "920", inputs: []Input{
|
|
{Provider: "gitea", ExternalID: "918", At: time.Unix(1700000000, 0).UTC(), Body: "use b"},
|
|
{Provider: "gitea", ExternalID: "919", At: time.Unix(1700000060, 0).UTC(), Body: " "},
|
|
{Provider: "gitea", ExternalID: "920", At: time.Unix(1700000120, 0).UTC(), Body: "and keep the old flag"},
|
|
}}
|
|
if err := reconciler(s, src).Reconcile(context.Background(), task.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
intent, _ := s.EffectiveIntent(task.ID)
|
|
if len(intent.Decisions) != 2 {
|
|
t.Fatalf("want 2 decisions, blank comment skipped: %+v", intent.Decisions)
|
|
}
|
|
if intent.Decisions[0].Value != "use b" || intent.Decisions[1].Value != "and keep the old flag" {
|
|
t.Fatalf("order = %+v", intent.Decisions)
|
|
}
|
|
}
|
|
|
|
func TestReconcileUnknownTask(t *testing.T) {
|
|
_, s, _ := setup(t)
|
|
src := &fakeSource{}
|
|
if err := reconciler(s, src).Reconcile(context.Background(), "nope"); !errors.Is(err, domain.ErrNotFound) {
|
|
t.Fatalf("want ErrNotFound, got %v", err)
|
|
}
|
|
if src.calls != 0 {
|
|
t.Fatal("must not fetch for an unknown task")
|
|
}
|
|
}
|
|
|
|
func TestInputWithoutExternalIDRejected(t *testing.T) {
|
|
_, s, task := setup(t)
|
|
src := &fakeSource{inputs: []Input{{Provider: "gitea", Body: "no id"}}}
|
|
if err := reconciler(s, src).Reconcile(context.Background(), task.ID); !errors.Is(err, domain.ErrInvalid) {
|
|
t.Fatalf("want ErrInvalid, got %v", err)
|
|
}
|
|
}
|