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>
157 lines
5.0 KiB
Go
157 lines
5.0 KiB
Go
// 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()
|
|
}
|