0ead6d2d02
F7, security. An unset surface token makes the middleware skip its check, so a full-control surface with no credential is an open control plane rather than a closed one. With ORCHESTRA_TUI_TOKEN unset, any LAN caller could lease, release, complete or block any task by declaring one header, which is how this session's manual leases were issued. authz.RequireCredentials now refuses startup instead of logging. Web is exempt: Sessions makes its login mandatory. F5, lifecycle. router.go's silent `continue` was the first bug, not the predicate behind it. Every eligibility gate now records a router.Rejection with task, herdr and reason, exposed at GET /v1/router/health, reset per pass. No gate was weakened: a direct Store.Lease succeeding proves the lease path, not that eligibility should have selected that worker. F8, correctness. Reconcile iterated every configured source for every task, so a task's external id was looked up in whatever repository each source pointed at. Once two repositories share an issue number, an unrelated human comment becomes an authoritative decision for the wrong task. Reconciliation is now bound to task.Source, the provider:project identity the ingest stamped, and a source that cannot prove it owns the task is skipped. A task with no matching source reconciles to nothing and still launches, because nothing to import is not a failure to read. The integration fixture ingested from "jsonl" while reconciling from "gitea", which is exactly the shape F8 makes impossible; it now ingests from the source it reconciles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
5.6 KiB
Go
165 lines
5.6 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"
|
|
"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()
|
|
}
|
|
// Only the source this task came from may reconcile it. A source is
|
|
// identified by the same string the ingest stamped on the task
|
|
// (provider:project, e.g. "gitea:test-e2e"), which binds provider,
|
|
// instance and repository together.
|
|
//
|
|
// Iterating every configured source was wrong and not merely noisy: a
|
|
// task's external id was looked up in whatever repository each source
|
|
// happened to point at, so once two repositories used the same issue
|
|
// number, an unrelated human comment became an authoritative decision for
|
|
// the wrong task. Found during burn-in with three correx tasks being
|
|
// reconciled against kami/test-e2e.
|
|
//
|
|
// A source that cannot prove it owns the task is skipped, not guessed at.
|
|
// Nothing to import is not the same as a failure to read, so a task with no
|
|
// matching source reconciles to nothing and the launch proceeds.
|
|
src, ok := r.Sources[task.Source]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if err := r.reconcileSource(ctx, task, task.Source, src); err != nil {
|
|
return fmt.Errorf("%s: %w", task.Source, 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()
|
|
}
|