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:
2026-08-26 18:31:20 +04:00
parent 97a9c65302
commit 7f12c7fc37
78 changed files with 16417 additions and 352 deletions
+107
View File
@@ -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
}
+156
View File
@@ -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()
}
+242
View File
@@ -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)
}
}