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>
232 lines
7.4 KiB
Go
232 lines
7.4 KiB
Go
package domain
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// Event types carrying human authority. A decision is a durable fact about
|
|
// what the operator has decided, never a lifecycle transition: neither type
|
|
// moves task state, and neither is readable from handoff prose.
|
|
const (
|
|
EventHumanDecisionRecorded = "HumanDecisionRecorded"
|
|
EventHumanDecisionSuperseded = "HumanDecisionSuperseded"
|
|
)
|
|
|
|
type HumanDecisionKind string
|
|
|
|
const (
|
|
HumanDecisionAnswer HumanDecisionKind = "answer"
|
|
HumanDecisionChoice HumanDecisionKind = "decision"
|
|
HumanDecisionCorrection HumanDecisionKind = "correction"
|
|
HumanDecisionConstraint HumanDecisionKind = "constraint"
|
|
)
|
|
|
|
func (k HumanDecisionKind) Valid() bool {
|
|
switch k {
|
|
case HumanDecisionAnswer, HumanDecisionChoice, HumanDecisionCorrection, HumanDecisionConstraint:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HumanDecisionSource records where the decision was observed. Provenance is
|
|
// mandatory so a decision can always be traced back to a human utterance.
|
|
type HumanDecisionSource struct {
|
|
Provider string `json:"provider"`
|
|
ExternalID string `json:"external_id,omitempty"`
|
|
}
|
|
|
|
type HumanDecision struct {
|
|
ID string `json:"id"`
|
|
TaskID string `json:"task_id"`
|
|
Kind HumanDecisionKind `json:"kind"`
|
|
// Subject names the area under decision. It deliberately does not imply
|
|
// replacement: two decisions may share a subject and both stay effective.
|
|
// Retiring a decision requires naming it in Supersedes, or a standalone
|
|
// HumanDecisionSuperseded.
|
|
Subject string `json:"subject"`
|
|
Value string `json:"value"`
|
|
Supersedes []string `json:"supersedes,omitempty"`
|
|
Source HumanDecisionSource `json:"source"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
// EffectiveIntent is the reduced authority for one task: the original
|
|
// contract, unmodified, plus the human decisions that are still standing.
|
|
// Rendering the two into a prompt is BuildContext's job, not the reducer's.
|
|
type EffectiveIntent struct {
|
|
Task Task `json:"task"`
|
|
Decisions []HumanDecision `json:"decisions"`
|
|
}
|
|
|
|
// Decision returns the standing decision with the given ID.
|
|
func (i EffectiveIntent) Decision(id string) (HumanDecision, bool) {
|
|
for _, d := range i.Decisions {
|
|
if d.ID == id {
|
|
return d, true
|
|
}
|
|
}
|
|
return HumanDecision{}, false
|
|
}
|
|
|
|
type humanDecisionPayload struct {
|
|
DecisionID string `json:"decision_id"`
|
|
Kind HumanDecisionKind `json:"kind"`
|
|
Subject string `json:"subject"`
|
|
Value string `json:"value"`
|
|
Supersedes []string `json:"supersedes"`
|
|
Source HumanDecisionSource `json:"source"`
|
|
}
|
|
|
|
// equal reports whether two records describe the same decision. At is part of
|
|
// the comparison because it participates in the canonical output order.
|
|
func (d HumanDecision) equal(o HumanDecision) bool {
|
|
if d.ID != o.ID || d.TaskID != o.TaskID || d.Kind != o.Kind || d.Subject != o.Subject ||
|
|
d.Value != o.Value || d.Source != o.Source || !d.At.Equal(o.At) || len(d.Supersedes) != len(o.Supersedes) {
|
|
return false
|
|
}
|
|
for i := range d.Supersedes {
|
|
if d.Supersedes[i] != o.Supersedes[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ReduceIntent folds a task's decision events into the standing set.
|
|
//
|
|
// The result depends only on the set of events, not on their order in the
|
|
// log: supersession is explicit, so a late-appended older decision can never
|
|
// silently override a newer correction. Events for other tasks are ignored,
|
|
// which is also what makes a cross-task supersedes reference read as an
|
|
// unknown target and be rejected.
|
|
//
|
|
// Errors are returned rather than skipped. A log that cannot be reduced is a
|
|
// log whose authority is ambiguous, and guessing is how a stale instruction
|
|
// reaches an agent.
|
|
func ReduceIntent(task Task, events []Event) (EffectiveIntent, error) {
|
|
byID := map[string]HumanDecision{}
|
|
var ids []string
|
|
adjacency := map[string][]string{}
|
|
superseded := map[string]bool{}
|
|
var targets []string
|
|
|
|
for _, e := range events {
|
|
if e.TaskID != task.ID {
|
|
continue
|
|
}
|
|
switch e.Type {
|
|
case EventHumanDecisionRecorded:
|
|
var p humanDecisionPayload
|
|
if err := json.Unmarshal(e.Payload, &p); err != nil {
|
|
return EffectiveIntent{}, fmt.Errorf("%w: decision payload in event %s: %v", ErrInvalid, e.ID, err)
|
|
}
|
|
if p.DecisionID == "" {
|
|
return EffectiveIntent{}, fmt.Errorf("%w: decision_id required in event %s", ErrInvalid, e.ID)
|
|
}
|
|
if !p.Kind.Valid() {
|
|
return EffectiveIntent{}, fmt.Errorf("%w: decision %s has kind %q", ErrInvalid, p.DecisionID, p.Kind)
|
|
}
|
|
d := HumanDecision{
|
|
ID: p.DecisionID,
|
|
TaskID: e.TaskID,
|
|
Kind: p.Kind,
|
|
Subject: p.Subject,
|
|
Value: p.Value,
|
|
Supersedes: p.Supersedes,
|
|
Source: p.Source,
|
|
At: e.At,
|
|
}
|
|
// Replaying the same decision is a no-op. Reusing one ID for two
|
|
// different decisions is not: first-wins would make the result
|
|
// depend on encounter order, which is the property this reducer
|
|
// exists to guarantee. Reject it instead.
|
|
if prior, seen := byID[p.DecisionID]; seen {
|
|
if prior.equal(d) {
|
|
continue
|
|
}
|
|
return EffectiveIntent{}, fmt.Errorf("%w: decision %q recorded twice with different content (event %s)", ErrInvalid, p.DecisionID, e.ID)
|
|
}
|
|
byID[p.DecisionID] = d
|
|
ids = append(ids, p.DecisionID)
|
|
adjacency[p.DecisionID] = append(adjacency[p.DecisionID], p.Supersedes...)
|
|
targets = append(targets, p.Supersedes...)
|
|
case EventHumanDecisionSuperseded:
|
|
var p struct {
|
|
DecisionID string `json:"decision_id"`
|
|
}
|
|
if err := json.Unmarshal(e.Payload, &p); err != nil {
|
|
return EffectiveIntent{}, fmt.Errorf("%w: supersede payload in event %s: %v", ErrInvalid, e.ID, err)
|
|
}
|
|
if p.DecisionID == "" {
|
|
return EffectiveIntent{}, fmt.Errorf("%w: decision_id required in event %s", ErrInvalid, e.ID)
|
|
}
|
|
targets = append(targets, p.DecisionID)
|
|
}
|
|
}
|
|
|
|
for _, target := range targets {
|
|
if _, ok := byID[target]; !ok {
|
|
return EffectiveIntent{}, fmt.Errorf("%w: supersedes references unknown decision %q for task %s", ErrInvalid, target, task.ID)
|
|
}
|
|
superseded[target] = true
|
|
}
|
|
if cycle := findCycle(ids, adjacency); cycle != "" {
|
|
return EffectiveIntent{}, fmt.Errorf("%w: supersession cycle through decision %q", ErrInvalid, cycle)
|
|
}
|
|
|
|
out := EffectiveIntent{Task: task}
|
|
for _, id := range ids {
|
|
if !superseded[id] {
|
|
out.Decisions = append(out.Decisions, byID[id])
|
|
}
|
|
}
|
|
// Canonical order, so two logs holding the same events render the same
|
|
// context regardless of append order.
|
|
sort.Slice(out.Decisions, func(a, b int) bool {
|
|
x, y := out.Decisions[a], out.Decisions[b]
|
|
if !x.At.Equal(y.At) {
|
|
return x.At.Before(y.At)
|
|
}
|
|
return x.ID < y.ID
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// findCycle returns a decision ID on a supersession cycle, or "" if the graph
|
|
// is acyclic. A cycle would otherwise mark every decision on it superseded
|
|
// and drop the whole chain from the effective set without a trace.
|
|
func findCycle(ids []string, adjacency map[string][]string) string {
|
|
const (
|
|
open = 1
|
|
done = 2
|
|
)
|
|
mark := map[string]int{}
|
|
var walk func(string) string
|
|
walk = func(id string) string {
|
|
switch mark[id] {
|
|
case open:
|
|
return id
|
|
case done:
|
|
return ""
|
|
}
|
|
mark[id] = open
|
|
for _, next := range adjacency[id] {
|
|
if hit := walk(next); hit != "" {
|
|
return hit
|
|
}
|
|
}
|
|
mark[id] = done
|
|
return ""
|
|
}
|
|
for _, id := range ids {
|
|
if hit := walk(id); hit != "" {
|
|
return hit
|
|
}
|
|
}
|
|
return ""
|
|
}
|