v3 workflow: intent, phases, review, submission, enforcement, burn-in
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>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
package human
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
)
|
||||
|
||||
// ReviewObservation is one review the forge recorded on a pull request. It is
|
||||
// an observation, not a verdict Orchestra trusts: the trust boundary is the
|
||||
// actor, applied by the reconciler.
|
||||
type ReviewObservation struct {
|
||||
Actor string
|
||||
State string // approved | changes_requested | commented
|
||||
At time.Time
|
||||
Body string
|
||||
}
|
||||
|
||||
// PullRequestState is everything Orchestra needs to know about a submitted
|
||||
// pull request. HeadSHA is the commit the forge believes the pull request
|
||||
// carries, which is how a merge is tied back to a specific submission.
|
||||
type PullRequestState struct {
|
||||
ID string
|
||||
HeadSHA string
|
||||
State string // open | merged | closed
|
||||
MergeSHA string
|
||||
MergedAt time.Time
|
||||
Reviews []ReviewObservation
|
||||
Comments []Input
|
||||
}
|
||||
|
||||
// PullRequestSource reads the state of one submitted pull request. Polling is
|
||||
// enough: a webhook would add an inbound trust boundary for no new capability.
|
||||
type PullRequestSource interface {
|
||||
PullRequest(ctx context.Context, task domain.Task) (PullRequestState, error)
|
||||
}
|
||||
|
||||
// Trust decides whose words can move a task. Without it, a bot comment or
|
||||
// Orchestra's own reflection could reopen a finished implementation.
|
||||
type Trust struct {
|
||||
// Accepted, when non-empty, is the allow-list of actor identities. Empty
|
||||
// means anyone not explicitly ignored, which is only safe on a private
|
||||
// forge with no bots.
|
||||
Accepted []string
|
||||
// Ignored always loses, even when it appears in Accepted.
|
||||
Ignored []string
|
||||
}
|
||||
|
||||
// Allows reports whether this actor's words may move a task.
|
||||
func (t Trust) Allows(actor string) bool {
|
||||
actor = strings.TrimSpace(strings.ToLower(actor))
|
||||
if actor == "" {
|
||||
return false
|
||||
}
|
||||
for _, ignored := range t.Ignored {
|
||||
if strings.EqualFold(strings.TrimSpace(ignored), actor) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(t.Accepted) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, accepted := range t.Accepted {
|
||||
if strings.EqualFold(strings.TrimSpace(accepted), actor) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FeedbackAfter returns the trusted human input on a pull request that arrived
|
||||
// strictly after the submission. Anything at or before it was already visible
|
||||
// when the submission was made, so it cannot be a response to it.
|
||||
func (p PullRequestState) FeedbackAfter(provider string, submittedAt time.Time, trust Trust) []Input {
|
||||
var out []Input
|
||||
for _, c := range p.Comments {
|
||||
if !c.At.After(submittedAt) || !trust.Allows(c.Author) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(c.Body) == "" {
|
||||
continue
|
||||
}
|
||||
if c.Provider == "" {
|
||||
c.Provider = provider
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
for _, r := range p.Reviews {
|
||||
if !r.At.After(submittedAt) || !trust.Allows(r.Actor) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(r.Body) == "" && r.State != "changes_requested" {
|
||||
continue
|
||||
}
|
||||
body := strings.TrimSpace(r.Body)
|
||||
if body == "" {
|
||||
body = "changes requested with no comment"
|
||||
}
|
||||
out = append(out, Input{
|
||||
Provider: provider, ExternalID: "review:" + r.Actor + ":" + r.At.UTC().Format(time.RFC3339),
|
||||
Author: r.Actor, At: r.At, Body: body,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Package human turns external human utterances into durable Orchestra
|
||||
// decisions. It runs immediately before ownership of a task begins, so an
|
||||
// agent can never resume from an older intent while newer human input is
|
||||
// waiting in a configured source.
|
||||
package human
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/store"
|
||||
)
|
||||
|
||||
// Input is one human utterance as the provider found it. It carries no
|
||||
// Orchestra semantics on purpose: classifying it is this package's job, so
|
||||
// provider code never has to know what a decision is.
|
||||
type Input struct {
|
||||
Provider string
|
||||
ExternalID string
|
||||
Author string
|
||||
At time.Time
|
||||
Body string
|
||||
}
|
||||
|
||||
// Source fetches the inputs a task has received after a cursor. It returns
|
||||
// the inputs in the order the human wrote them, plus the cursor that covers
|
||||
// them. The returned cursor is only persisted once every derived event is
|
||||
// durable, so a Source must tolerate being asked for the same range twice.
|
||||
type Source interface {
|
||||
FetchAfter(ctx context.Context, task domain.Task, cursor store.SourceCursor) ([]Input, store.SourceCursor, error)
|
||||
}
|
||||
|
||||
// Reconciler is the pre-launch step. Wire it to Store.PreLease.
|
||||
type Reconciler struct {
|
||||
Store *store.Store
|
||||
// Sources is keyed by provider name, which is also the provider half of
|
||||
// the (provider, external_id) provenance key.
|
||||
Sources map[string]Source
|
||||
Timeout time.Duration
|
||||
// Now exists for tests. Reconciliation stamps nothing itself, but the
|
||||
// classifier records when Orchestra observed the input.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// operatorInstructionSubject is the single subject every imported comment
|
||||
// lands under until extraction exists. Crude, and mechanically correct: the
|
||||
// text is preserved verbatim and outranks handoff prose because it is a
|
||||
// decision and the handoff is not.
|
||||
const operatorInstructionSubject = "operator_instruction"
|
||||
|
||||
// Reconcile imports every input newer than the stored cursor, then advances
|
||||
// the cursor. It fails closed: any provider or append error returns an error
|
||||
// and leaves the cursor where it was, so the caller refuses the launch and a
|
||||
// later attempt refetches the same range.
|
||||
func (r *Reconciler) Reconcile(ctx context.Context, taskID string) error {
|
||||
if r == nil || r.Store == nil || len(r.Sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
task, ok := r.Store.Task(taskID)
|
||||
if !ok {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
if r.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, r.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
// Deterministic provider order, so two runs over the same pending inputs
|
||||
// produce the same log.
|
||||
providers := make([]string, 0, len(r.Sources))
|
||||
for name := range r.Sources {
|
||||
providers = append(providers, name)
|
||||
}
|
||||
sort.Strings(providers)
|
||||
|
||||
for _, name := range providers {
|
||||
if err := r.reconcileSource(ctx, task, name, r.Sources[name]); err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileSource(ctx context.Context, task domain.Task, provider string, src Source) error {
|
||||
cursor, _ := r.Store.SourceCursor(task.ID, provider)
|
||||
inputs, next, err := src.FetchAfter(ctx, task, cursor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, in := range inputs {
|
||||
if in.ExternalID == "" {
|
||||
return fmt.Errorf("%w: input without external id", domain.ErrInvalid)
|
||||
}
|
||||
// An empty utterance decides nothing. Skipping it still advances the
|
||||
// cursor past it, so it is read once and never again.
|
||||
if strings.TrimSpace(in.Body) == "" {
|
||||
continue
|
||||
}
|
||||
// A refetch after a lost cursor write must not duplicate the
|
||||
// decision. The store rejects it too; checking first keeps the
|
||||
// ordinary resume path free of expected errors.
|
||||
if _, exists := r.Store.DecisionForSource(provider, in.ExternalID); exists {
|
||||
continue
|
||||
}
|
||||
if err := r.record(task, provider, in); err != nil && !errors.Is(err, domain.ErrDuplicate) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Only now: every event derived from this range is durable.
|
||||
next.TaskID, next.Provider = task.ID, provider
|
||||
if next.Cursor == "" || next.Cursor == cursor.Cursor {
|
||||
return nil
|
||||
}
|
||||
return r.Store.SetSourceCursor(next)
|
||||
}
|
||||
|
||||
func (r *Reconciler) record(task domain.Task, provider string, in Input) error {
|
||||
at := in.At
|
||||
if at.IsZero() {
|
||||
at = r.now()
|
||||
}
|
||||
current, ok := r.Store.Task(task.ID)
|
||||
if !ok {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
payload := map[string]any{
|
||||
"decision_id": domain.NewID(),
|
||||
"kind": string(domain.HumanDecisionCorrection),
|
||||
"subject": operatorInstructionSubject,
|
||||
"value": in.Body,
|
||||
"source": map[string]any{"provider": provider, "external_id": in.ExternalID},
|
||||
"author": in.Author,
|
||||
}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.Store.Append(domain.Event{
|
||||
ID: domain.NewID(), Type: domain.EventHumanDecisionRecorded, TaskID: task.ID,
|
||||
Version: current.Version + 1, At: at, Payload: b, Surface: string(authz.System),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Reconciler) now() time.Time {
|
||||
if r.Now != nil {
|
||||
return r.Now()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user