Files
orchestra/internal/human/reconcile.go
T
kami 7f12c7fc37 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>
2026-08-26 18:31:20 +04:00

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()
}