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:
@@ -0,0 +1,231 @@
|
||||
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 ""
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DecisionRequest is a bounded question to the human. It exists because some
|
||||
// ambiguity cannot be resolved by reading the repository, and guessing would
|
||||
// waste a session or ship the wrong behaviour.
|
||||
//
|
||||
// Grilling is not a mode here. It is one blocker, one question, one answer,
|
||||
// and the answer arrives through the ordinary human-decision mechanism. The
|
||||
// bounds are what keep it from becoming an interview.
|
||||
type DecisionRequest struct {
|
||||
Question string `json:"question"`
|
||||
Why string `json:"why"`
|
||||
Options []DecisionOption `json:"options,omitempty"`
|
||||
Evidence []string `json:"evidence,omitempty"`
|
||||
}
|
||||
|
||||
// DecisionOption is one way forward, with the cost of taking it. A request
|
||||
// without options is legal: sometimes the honest question is open.
|
||||
type DecisionOption struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
Tradeoff string `json:"tradeoff,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
maxRequestField = 500
|
||||
maxRequestOption = 4
|
||||
maxRequestFacts = 8
|
||||
)
|
||||
|
||||
func (r DecisionRequest) Validate() error {
|
||||
if err := requestLine("question", r.Question, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requestLine("why", r.Why, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(r.Options) > maxRequestOption {
|
||||
return fmt.Errorf("%w: at most %d options", ErrInvalid, maxRequestOption)
|
||||
}
|
||||
if len(r.Evidence) > maxRequestFacts {
|
||||
return fmt.Errorf("%w: at most %d evidence lines", ErrInvalid, maxRequestFacts)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, o := range r.Options {
|
||||
if err := requestLine(fmt.Sprintf("options[%d].id", i), o.ID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if seen[o.ID] {
|
||||
return fmt.Errorf("%w: duplicate option id %q", ErrInvalid, o.ID)
|
||||
}
|
||||
seen[o.ID] = true
|
||||
if err := requestLine(fmt.Sprintf("options[%d].description", i), o.Description, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requestLine(fmt.Sprintf("options[%d].tradeoff", i), o.Tradeoff, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, e := range r.Evidence {
|
||||
if err := requestLine(fmt.Sprintf("evidence[%d]", i), e, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requestLine enforces the single-line, bounded shape. A multi-line field
|
||||
// would let a request carry the transcript this type exists to exclude.
|
||||
func requestLine(field, v string, required bool) error {
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
if required {
|
||||
return fmt.Errorf("%w: %s is required", ErrInvalid, field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(s) > maxRequestField {
|
||||
return fmt.Errorf("%w: %s exceeds %d characters", ErrInvalid, field, maxRequestField)
|
||||
}
|
||||
if strings.ContainsAny(s, "\n\r") {
|
||||
return fmt.Errorf("%w: %s must be a single line", ErrInvalid, field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render is the human-facing form, delivered in the blocker field that
|
||||
// notification surfaces already read.
|
||||
func (r DecisionRequest) Render() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Human decision required.\n")
|
||||
fmt.Fprintf(&b, "\nQuestion: %s\n", r.Question)
|
||||
fmt.Fprintf(&b, "Why it blocks: %s\n", r.Why)
|
||||
if len(r.Options) > 0 {
|
||||
b.WriteString("\nOptions:\n")
|
||||
for _, o := range r.Options {
|
||||
if o.Tradeoff != "" {
|
||||
fmt.Fprintf(&b, "- %s: %s (tradeoff: %s)\n", o.ID, o.Description, o.Tradeoff)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "- %s: %s\n", o.ID, o.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(r.Evidence) > 0 {
|
||||
b.WriteString("\nEvidence:\n")
|
||||
for _, e := range r.Evidence {
|
||||
fmt.Fprintf(&b, "- %s\n", e)
|
||||
}
|
||||
}
|
||||
b.WriteString("\nReply with your decision. Any reply is recorded as a decision and resumes the task.\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// DeferredFinding is a real observation that is not this task's business. It
|
||||
// is recorded outside agent context so a discovery neither derails the task
|
||||
// nor evaporates into a promise the next session cannot see.
|
||||
type DeferredFinding struct {
|
||||
Summary string `json:"summary"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// EventDeferredFindingRecorded keeps a deferred finding in the log without
|
||||
// putting it in front of an agent.
|
||||
const EventDeferredFindingRecorded = "DeferredFindingRecorded"
|
||||
|
||||
func (f DeferredFinding) Validate() error {
|
||||
if err := requestLine("summary", f.Summary, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return requestLine("why", f.Why, true)
|
||||
}
|
||||
|
||||
// decodeDecisionRequest reads the request out of a generic event payload.
|
||||
// Validation lives on the type, so the wire form and the projection agree.
|
||||
func decodeDecisionRequest(m map[string]any) DecisionRequest {
|
||||
var r DecisionRequest
|
||||
r.Question, _ = m["question"].(string)
|
||||
r.Why, _ = m["why"].(string)
|
||||
if list, ok := m["options"].([]any); ok {
|
||||
for _, item := range list {
|
||||
o, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var opt DecisionOption
|
||||
opt.ID, _ = o["id"].(string)
|
||||
opt.Description, _ = o["description"].(string)
|
||||
opt.Tradeoff, _ = o["tradeoff"].(string)
|
||||
r.Options = append(r.Options, opt)
|
||||
}
|
||||
}
|
||||
if list, ok := m["evidence"].([]any); ok {
|
||||
for _, item := range list {
|
||||
if s, ok := item.(string); ok {
|
||||
r.Evidence = append(r.Evidence, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// DecodeDecisionRequest is decodeDecisionRequest for callers outside this
|
||||
// package (the store's projection).
|
||||
func DecodeDecisionRequest(m map[string]any) DecisionRequest { return decodeDecisionRequest(m) }
|
||||
@@ -0,0 +1,291 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func decisionEvent(t *testing.T, id, taskID string, at time.Time, p map[string]any) Event {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := Event{ID: id, Type: EventHumanDecisionRecorded, TaskID: taskID, At: at, Payload: b, Surface: "web", SchemaVersion: CurrentEventSchema}
|
||||
if err := ValidateEvent(e); err != nil {
|
||||
t.Fatalf("event %s should validate: %v", id, err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func decision(t *testing.T, id, taskID string, at time.Time, kind HumanDecisionKind, subject, value string, supersedes ...string) Event {
|
||||
t.Helper()
|
||||
p := map[string]any{
|
||||
"decision_id": id,
|
||||
"kind": string(kind),
|
||||
"subject": subject,
|
||||
"value": value,
|
||||
"source": map[string]any{"provider": "web", "external_id": "c1"},
|
||||
}
|
||||
if len(supersedes) > 0 {
|
||||
p["supersedes"] = supersedes
|
||||
}
|
||||
return decisionEvent(t, "e-"+id, taskID, at, p)
|
||||
}
|
||||
|
||||
var t0 = time.Date(2026, 8, 26, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// The one that matters: a correction outranks both the original contract and
|
||||
// whatever the handoff says the next step is.
|
||||
func TestCorrectionOverridesContractAndHandoff(t *testing.T) {
|
||||
task := Task{
|
||||
ID: "task-1",
|
||||
State: StateLeased,
|
||||
Description: "implement a",
|
||||
Acceptance: []string{"a works"},
|
||||
// Handoff prose says "next: implement a". It must not reach authority.
|
||||
HandoffRef: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
}
|
||||
events := []Event{decision(t, "d1", "task-1", t0, HumanDecisionCorrection, "strategy", "use b")}
|
||||
|
||||
got, err := ReduceIntent(task, events)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Task.Description != "implement a" || got.Task.Acceptance[0] != "a works" {
|
||||
t.Fatalf("contract must survive unmodified, got %+v", got.Task)
|
||||
}
|
||||
if len(got.Decisions) != 1 {
|
||||
t.Fatalf("want 1 standing decision, got %d", len(got.Decisions))
|
||||
}
|
||||
if d := got.Decisions[0]; d.Value != "use b" || d.Subject != "strategy" || d.Kind != HumanDecisionCorrection {
|
||||
t.Fatalf("standing decision = %+v", d)
|
||||
}
|
||||
if got.Task.HandoffRef != task.HandoffRef {
|
||||
t.Fatal("reducer must not rewrite handoff fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitSupersessionRetiresPredecessor(t *testing.T) {
|
||||
events := []Event{
|
||||
decision(t, "d1", "task-1", t0, HumanDecisionChoice, "strategy", "use a"),
|
||||
decision(t, "d2", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "strategy", "use b", "d1"),
|
||||
}
|
||||
got, err := ReduceIntent(Task{ID: "task-1"}, events)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Decisions) != 1 || got.Decisions[0].ID != "d2" {
|
||||
t.Fatalf("want only d2 standing, got %+v", got.Decisions)
|
||||
}
|
||||
}
|
||||
|
||||
// Same subject, no supersedes: both stand. Inferring replacement from subject
|
||||
// is exactly the ambiguity the explicit edge exists to avoid.
|
||||
func TestSameSubjectWithoutSupersedesKeepsBoth(t *testing.T) {
|
||||
events := []Event{
|
||||
decision(t, "d1", "task-1", t0, HumanDecisionConstraint, "strategy", "no new deps"),
|
||||
decision(t, "d2", "task-1", t0.Add(time.Hour), HumanDecisionConstraint, "strategy", "stdlib only"),
|
||||
}
|
||||
got, err := ReduceIntent(Task{ID: "task-1"}, events)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Decisions) != 2 {
|
||||
t.Fatalf("want both standing, got %+v", got.Decisions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandaloneSupersededEventRetracts(t *testing.T) {
|
||||
b, _ := json.Marshal(map[string]any{"decision_id": "d1"})
|
||||
retract := Event{ID: "e-retract", Type: EventHumanDecisionSuperseded, TaskID: "task-1", At: t0.Add(time.Hour), Payload: b, Surface: "web", SchemaVersion: CurrentEventSchema}
|
||||
if err := ValidateEvent(retract); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events := []Event{decision(t, "d1", "task-1", t0, HumanDecisionAnswer, "q", "yes"), retract}
|
||||
got, err := ReduceIntent(Task{ID: "task-1"}, events)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Decisions) != 0 {
|
||||
t.Fatalf("want nothing standing, got %+v", got.Decisions)
|
||||
}
|
||||
}
|
||||
|
||||
// Log order must not change the answer. Every permutation of a supersession
|
||||
// chain reduces to the same standing set, including the one where the
|
||||
// superseding decision is appended before its target.
|
||||
func TestReductionIsOrderIndependent(t *testing.T) {
|
||||
d1 := decision(t, "d1", "task-1", t0, HumanDecisionChoice, "strategy", "use a")
|
||||
d2 := decision(t, "d2", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "strategy", "use b", "d1")
|
||||
d3 := decision(t, "d3", "task-1", t0.Add(2*time.Hour), HumanDecisionConstraint, "deps", "stdlib only")
|
||||
for _, order := range [][]Event{
|
||||
{d1, d2, d3}, {d3, d2, d1}, {d2, d1, d3}, {d2, d3, d1}, {d3, d1, d2}, {d1, d3, d2},
|
||||
} {
|
||||
got, err := ReduceIntent(Task{ID: "task-1"}, order)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Decisions) != 2 || got.Decisions[0].ID != "d2" || got.Decisions[1].ID != "d3" {
|
||||
t.Fatalf("order %v reduced to %+v", ids(order), got.Decisions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ids(events []Event) []string {
|
||||
out := make([]string, 0, len(events))
|
||||
for _, e := range events {
|
||||
out = append(out, e.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestDuplicateReplayIsIdempotent(t *testing.T) {
|
||||
d1 := decision(t, "d1", "task-1", t0, HumanDecisionAnswer, "q", "yes")
|
||||
got, err := ReduceIntent(Task{ID: "task-1"}, []Event{d1, d1, d1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Decisions) != 1 {
|
||||
t.Fatalf("want 1 decision, got %d", len(got.Decisions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConflictingDuplicateDecisionIDRejected(t *testing.T) {
|
||||
a := decision(t, "d17", "task-1", t0, HumanDecisionChoice, "strategy", "use a")
|
||||
b := decision(t, "d17", "task-1", t0, HumanDecisionChoice, "strategy", "use b")
|
||||
if _, err := ReduceIntent(Task{ID: "task-1"}, []Event{a, b}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("want ErrInvalid, got %v", err)
|
||||
}
|
||||
// Reversing the two must fail the same way. Order must never decide.
|
||||
if _, err := ReduceIntent(Task{ID: "task-1"}, []Event{b, a}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("reversed: want ErrInvalid, got %v", err)
|
||||
}
|
||||
// A differing timestamp is also a conflict, because At orders the output.
|
||||
c := decision(t, "d17", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "strategy", "use a")
|
||||
if _, err := ReduceIntent(Task{ID: "task-1"}, []Event{a, c}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("timestamp conflict: want ErrInvalid, got %v", err)
|
||||
}
|
||||
// An identical replay, including supersedes, still reduces cleanly.
|
||||
base := decision(t, "d1", "task-1", t0, HumanDecisionChoice, "s", "a")
|
||||
sup := decision(t, "d2", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "s", "b", "d1")
|
||||
got, err := ReduceIntent(Task{ID: "task-1"}, []Event{base, sup, sup, base})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Decisions) != 1 || got.Decisions[0].ID != "d2" {
|
||||
t.Fatalf("standing set = %+v", got.Decisions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSupersedesTargetRejected(t *testing.T) {
|
||||
events := []Event{decision(t, "d1", "task-1", t0, HumanDecisionChoice, "strategy", "use b", "ghost")}
|
||||
if _, err := ReduceIntent(Task{ID: "task-1"}, events); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("want ErrInvalid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A cross-task reference is an unknown target, not a silent no-op: the other
|
||||
// task's decision is invisible to this reduction and cannot be retired here.
|
||||
func TestCannotSupersedeAnotherTasksDecision(t *testing.T) {
|
||||
events := []Event{
|
||||
decision(t, "other", "task-2", t0, HumanDecisionChoice, "strategy", "use a"),
|
||||
decision(t, "d1", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "strategy", "use b", "other"),
|
||||
}
|
||||
if _, err := ReduceIntent(Task{ID: "task-1"}, events); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("want ErrInvalid, got %v", err)
|
||||
}
|
||||
// And the other task's own reduction is unaffected by task-1's events.
|
||||
got, err := ReduceIntent(Task{ID: "task-2"}, events)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Decisions) != 1 || got.Decisions[0].ID != "other" {
|
||||
t.Fatalf("task-2 standing set = %+v", got.Decisions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupersessionCyclesRejected(t *testing.T) {
|
||||
for name, events := range map[string][]Event{
|
||||
"self": {decision(t, "d1", "task-1", t0, HumanDecisionChoice, "s", "v", "d1")},
|
||||
"pair": {
|
||||
decision(t, "d1", "task-1", t0, HumanDecisionChoice, "s", "a", "d2"),
|
||||
decision(t, "d2", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "s", "b", "d1"),
|
||||
},
|
||||
"three": {
|
||||
decision(t, "d1", "task-1", t0, HumanDecisionChoice, "s", "a", "d3"),
|
||||
decision(t, "d2", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "s", "b", "d1"),
|
||||
decision(t, "d3", "task-1", t0.Add(2*time.Hour), HumanDecisionChoice, "s", "c", "d2"),
|
||||
},
|
||||
} {
|
||||
if _, err := ReduceIntent(Task{ID: "task-1"}, events); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("%s cycle: want ErrInvalid, got %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A transitive chain leaves only the head standing.
|
||||
func TestTransitiveChainKeepsOnlyHead(t *testing.T) {
|
||||
events := []Event{
|
||||
decision(t, "d1", "task-1", t0, HumanDecisionChoice, "s", "a"),
|
||||
decision(t, "d2", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "s", "b", "d1"),
|
||||
decision(t, "d3", "task-1", t0.Add(2*time.Hour), HumanDecisionChoice, "s", "c", "d2"),
|
||||
}
|
||||
got, err := ReduceIntent(Task{ID: "task-1"}, events)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Decisions) != 1 || got.Decisions[0].ID != "d3" {
|
||||
t.Fatalf("standing set = %+v", got.Decisions)
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle events are inert to the reducer, so authority cannot be smuggled
|
||||
// in through a release, a pickup, or an amendment.
|
||||
func TestLifecycleEventsCarryNoAuthority(t *testing.T) {
|
||||
amend, _ := json.Marshal(map[string]any{"description": "implement a instead"})
|
||||
events := []Event{
|
||||
{ID: "e1", Type: "TaskAmended", TaskID: "task-1", At: t0, Payload: amend, Surface: "system"},
|
||||
decision(t, "d1", "task-1", t0.Add(time.Hour), HumanDecisionCorrection, "strategy", "use b"),
|
||||
}
|
||||
got, err := ReduceIntent(Task{ID: "task-1", Description: "implement a"}, events)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Decisions) != 1 || got.Decisions[0].Value != "use b" {
|
||||
t.Fatalf("standing set = %+v", got.Decisions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecisionEventValidation(t *testing.T) {
|
||||
base := func() map[string]any {
|
||||
return map[string]any{
|
||||
"decision_id": "d1", "kind": "correction", "subject": "strategy", "value": "use b",
|
||||
"source": map[string]any{"provider": "web"},
|
||||
}
|
||||
}
|
||||
if err := ValidatePayload(EventHumanDecisionRecorded, base()); err != nil {
|
||||
t.Fatalf("valid payload rejected: %v", err)
|
||||
}
|
||||
for name, mutate := range map[string]func(map[string]any){
|
||||
"no decision_id": func(p map[string]any) { delete(p, "decision_id") },
|
||||
"no subject": func(p map[string]any) { delete(p, "subject") },
|
||||
"no value": func(p map[string]any) { delete(p, "value") },
|
||||
"bad kind": func(p map[string]any) { p["kind"] = "vibes" },
|
||||
"no source": func(p map[string]any) { delete(p, "source") },
|
||||
"no provider": func(p map[string]any) { p["source"] = map[string]any{} },
|
||||
"supersedes str": func(p map[string]any) { p["supersedes"] = "d0" },
|
||||
"supersedes nil": func(p map[string]any) { p["supersedes"] = []any{""} },
|
||||
} {
|
||||
p := base()
|
||||
mutate(p)
|
||||
if err := ValidatePayload(EventHumanDecisionRecorded, p); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("%s: want ErrInvalid, got %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := ValidatePayload(EventHumanDecisionSuperseded, map[string]any{}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("empty supersede payload: want ErrInvalid, got %v", err)
|
||||
}
|
||||
}
|
||||
+133
-5
@@ -41,6 +41,9 @@ const (
|
||||
// completion, explicitly release it, or renew it while an operator
|
||||
// investigates; expiry remains the only automatic reclaim.
|
||||
StateNeedsAttention TaskState = "needs_attention"
|
||||
// StateInReview is a submitted change waiting on the human. It is not
|
||||
// completion: an agent never decides that a change shipped.
|
||||
StateInReview TaskState = "in_review"
|
||||
)
|
||||
|
||||
// BlockReason is the machine-readable diagnosis for a TaskBlocked event.
|
||||
@@ -56,14 +59,24 @@ const (
|
||||
BlockReasonHandoffValidation BlockReason = "handoff_validation"
|
||||
BlockReasonOperator BlockReason = "operator_block"
|
||||
BlockReasonSystem BlockReason = "system_error"
|
||||
BlockReasonUnknown BlockReason = "unknown"
|
||||
// BlockReasonTrajectoryGate is a deliberate stop, not a fault: the plan is
|
||||
// sealed and Orchestra is waiting for the human to confirm the direction.
|
||||
BlockReasonTrajectoryGate BlockReason = "trajectory_gate"
|
||||
// BlockReasonHumanDecision is a bounded question the repository could not
|
||||
// answer. BlockReasonOperatorRequired is what a task becomes when it has
|
||||
// spent its question budget: an operator looks at it rather than the
|
||||
// agent asking again.
|
||||
BlockReasonHumanDecision BlockReason = "human_decision"
|
||||
BlockReasonOperatorRequired BlockReason = "operator_required"
|
||||
BlockReasonUnknown BlockReason = "unknown"
|
||||
)
|
||||
|
||||
func (r BlockReason) Valid() bool {
|
||||
switch r {
|
||||
case BlockReasonLeaseFailure, BlockReasonWorkerOffline, BlockReasonLeaseExpired,
|
||||
BlockReasonApproval, BlockReasonHandoffValidation, BlockReasonOperator,
|
||||
BlockReasonSystem, BlockReasonUnknown:
|
||||
BlockReasonSystem, BlockReasonUnknown, BlockReasonTrajectoryGate,
|
||||
BlockReasonHumanDecision, BlockReasonOperatorRequired:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -158,7 +171,47 @@ type Task struct {
|
||||
NextRetryAt time.Time `json:"next_retry_at,omitempty"`
|
||||
FailureClass string `json:"failure_class,omitempty"`
|
||||
LifecyclePhase string `json:"lifecycle_phase,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
// WorkPhase is the cognitive phase (frame/research/plan/implement/review),
|
||||
// orthogonal to State and LifecyclePhase. Empty means frame.
|
||||
WorkPhase WorkPhase `json:"work_phase,omitempty"`
|
||||
// DecisionRequest is the question this task is currently blocked on. It is
|
||||
// cleared when the task leaves the blocked state, because the answer then
|
||||
// stands on its own as a decision and the log still holds the question.
|
||||
DecisionRequest *DecisionRequest `json:"decision_request,omitempty"`
|
||||
// ReviewTargetSHA is the commit the current review phase was entered
|
||||
// against. A review of any other commit is not a review of this work.
|
||||
ReviewTargetSHA string `json:"review_target_sha,omitempty"`
|
||||
// Review is the last independent review, bound to the commit it was
|
||||
// performed against. A review is never a free-floating pass: when the code
|
||||
// moves, ResultSHA no longer matches and the review describes a tree that
|
||||
// does not exist any more.
|
||||
Review *ReviewRef `json:"review,omitempty"`
|
||||
// Submission is the durable record of the change handed to the human.
|
||||
Submission *SubmissionRef `json:"submission,omitempty"`
|
||||
// ResearchRef and PlanRef are the sealed artifacts of the phases already
|
||||
// finished. The next phase reads these, never the session that wrote them.
|
||||
ResearchRef string `json:"research_ref,omitempty"`
|
||||
PlanRef string `json:"plan_ref,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
// ReviewRef binds a sealed review artifact to one commit.
|
||||
type ReviewRef struct {
|
||||
ArtifactRef string `json:"artifact_ref"`
|
||||
ResultSHA string `json:"result_sha"`
|
||||
// Blocking is the count of blocker and important findings, projected so a
|
||||
// completion check does not have to read the artifact to know the answer.
|
||||
Blocking int `json:"blocking"`
|
||||
}
|
||||
|
||||
// EventReviewRecorded seals one independent review. Orchestra emits it; the
|
||||
// reviewing session only supplies the findings.
|
||||
const EventReviewRecorded = "ReviewRecorded"
|
||||
|
||||
// ReviewSatisfied reports whether this task holds an accepted review of the
|
||||
// exact commit named. It is the mechanical half of completion eligibility.
|
||||
func (t Task) ReviewSatisfied(resultSHA string) bool {
|
||||
return t.Review != nil && t.Review.ResultSHA == resultSHA && t.Review.Blocking == 0
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
@@ -198,7 +251,7 @@ func ValidateEvent(e Event) error {
|
||||
if e.SchemaVersion >= 2 && strings.TrimSpace(e.Surface) == "" {
|
||||
return fmt.Errorf("%w: surface required", ErrInvalid)
|
||||
}
|
||||
allowed := map[string]bool{"TaskCreated": true, "TaskLeased": true, "TaskLeaseRenewed": true, "TaskReleased": true, "TaskLaunchAcknowledged": true, "TaskPickupValidated": true, "TaskCompleted": true, "TaskFailed": true, "TaskBlocked": true, "TaskNeedsAttention": true, "ApprovalRequested": true, "ApprovalGranted": true, "ApprovalDenied": true, "TaskAmended": true, "TaskCorrected": true, "QuotaReported": true, "StandupAdvisory": true}
|
||||
allowed := map[string]bool{"TaskCreated": true, "TaskLeased": true, "TaskLeaseRenewed": true, "TaskReleased": true, "TaskLaunchAcknowledged": true, "TaskPickupValidated": true, "TaskCompleted": true, "TaskFailed": true, "TaskBlocked": true, "TaskNeedsAttention": true, "ApprovalRequested": true, "ApprovalGranted": true, "ApprovalDenied": true, "TaskAmended": true, "TaskCorrected": true, "QuotaReported": true, "StandupAdvisory": true, EventHumanDecisionRecorded: true, EventHumanDecisionSuperseded: true, EventWorkPhaseChanged: true, EventDeferredFindingRecorded: true, EventReviewRecorded: true, EventTaskSubmitted: true, EventTaskChangesRequested: true}
|
||||
if !allowed[e.Type] {
|
||||
return fmt.Errorf("%w: unknown type %q", ErrInvalid, e.Type)
|
||||
}
|
||||
@@ -343,6 +396,15 @@ func ValidatePayload(typ string, p map[string]any) error {
|
||||
if v, ok := p["pane_state"]; ok && v != "open" && v != "closed" && v != "unreachable" && v != "unknown" {
|
||||
return fmt.Errorf("%w: pane_state invalid", ErrInvalid)
|
||||
}
|
||||
if v, ok := p["decision_request"]; ok {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: decision_request must be an object", ErrInvalid)
|
||||
}
|
||||
if err := decodeDecisionRequest(m).Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "TaskAmended":
|
||||
if len(p) == 0 {
|
||||
return fmt.Errorf("%w: amendment cannot be empty", ErrInvalid)
|
||||
@@ -362,7 +424,7 @@ func ValidatePayload(typ string, p map[string]any) error {
|
||||
return fmt.Errorf("%w: state must be a string", ErrInvalid)
|
||||
}
|
||||
switch TaskState(s) {
|
||||
case StateQueued, StateLeased, StateCompleted, StateFailed, StateBlocked, StateNeedsAttention:
|
||||
case StateQueued, StateLeased, StateCompleted, StateFailed, StateBlocked, StateNeedsAttention, StateInReview:
|
||||
default:
|
||||
return fmt.Errorf("%w: state invalid", ErrInvalid)
|
||||
}
|
||||
@@ -391,6 +453,72 @@ func ValidatePayload(typ string, p map[string]any) error {
|
||||
if _, ok := p["items"]; !ok {
|
||||
return fmt.Errorf("%w: items required", ErrInvalid)
|
||||
}
|
||||
case EventHumanDecisionRecorded:
|
||||
for _, k := range []string{"decision_id", "kind", "subject", "value"} {
|
||||
if err := requiredString(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if kind, _ := p["kind"].(string); !HumanDecisionKind(kind).Valid() {
|
||||
return fmt.Errorf("%w: kind invalid", ErrInvalid)
|
||||
}
|
||||
src, ok := p["source"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: source required", ErrInvalid)
|
||||
}
|
||||
if v, ok := src["provider"].(string); !ok || strings.TrimSpace(v) == "" {
|
||||
return fmt.Errorf("%w: source.provider required", ErrInvalid)
|
||||
}
|
||||
if v, ok := p["supersedes"]; ok {
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: supersedes must be an array", ErrInvalid)
|
||||
}
|
||||
for _, item := range list {
|
||||
if s, ok := item.(string); !ok || strings.TrimSpace(s) == "" {
|
||||
return fmt.Errorf("%w: supersedes entries must be decision ids", ErrInvalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
case EventHumanDecisionSuperseded:
|
||||
if err := requiredString("decision_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
case EventWorkPhaseChanged:
|
||||
return ValidateWorkPhaseChanged(p)
|
||||
case EventTaskSubmitted:
|
||||
return ValidateTaskSubmitted(p)
|
||||
case EventTaskChangesRequested:
|
||||
if v, ok := p["submitted_sha"].(string); !ok || len(v) != 40 {
|
||||
return fmt.Errorf("%w: submitted_sha invalid", ErrInvalid)
|
||||
}
|
||||
if v, ok := p["submission_event"].(string); !ok || strings.TrimSpace(v) == "" {
|
||||
return fmt.Errorf("%w: submission_event required", ErrInvalid)
|
||||
}
|
||||
ids, ok := p["decision_ids"].([]any)
|
||||
if !ok || len(ids) == 0 {
|
||||
return fmt.Errorf("%w: decision_ids required", ErrInvalid)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if s, ok := id.(string); !ok || strings.TrimSpace(s) == "" {
|
||||
return fmt.Errorf("%w: decision_ids entries must be ids", ErrInvalid)
|
||||
}
|
||||
}
|
||||
case EventReviewRecorded:
|
||||
if err := requiredHash(p, "artifact_ref"); err != nil {
|
||||
return err
|
||||
}
|
||||
if v, ok := p["result_sha"].(string); !ok || len(v) != 40 {
|
||||
return fmt.Errorf("%w: result_sha invalid", ErrInvalid)
|
||||
}
|
||||
if v, ok := p["blocking"].(float64); !ok || v < 0 || v != float64(int(v)) {
|
||||
return fmt.Errorf("%w: blocking invalid", ErrInvalid)
|
||||
}
|
||||
case EventDeferredFindingRecorded:
|
||||
f := DeferredFinding{}
|
||||
f.Summary, _ = p["summary"].(string)
|
||||
f.Why, _ = p["why"].(string)
|
||||
return f.Validate()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventTaskSubmitted records that a reviewed change reached the human. It is
|
||||
// deliberately not a completion: submission means the work is in the human's
|
||||
// hands, and completion means the change shipped.
|
||||
const EventTaskSubmitted = "TaskSubmitted"
|
||||
|
||||
// EventTaskChangesRequested records that the human sent a submitted change
|
||||
// back. The submission it names is not removed: sha A was reviewed, submitted,
|
||||
// and rejected, and that history is what explains sha B.
|
||||
const EventTaskChangesRequested = "TaskChangesRequested"
|
||||
|
||||
// CompletionReceipt is the evidence that a submission shipped. Merge strategy
|
||||
// varies, so a squash or merge commit means MergeSHA rarely equals
|
||||
// SubmittedSHA. What establishes completion is that the bound pull request
|
||||
// merged while carrying the submitted commit, not sha equality.
|
||||
type CompletionReceipt struct {
|
||||
SubmissionRef string `json:"submission_ref"`
|
||||
PR ExternalRef `json:"pr"`
|
||||
SubmittedSHA string `json:"submitted_sha"`
|
||||
MergeSHA string `json:"merge_sha,omitempty"`
|
||||
MergedAt time.Time `json:"merged_at"`
|
||||
}
|
||||
|
||||
// GateResult is one quality-gate run, bound to the commit it ran against. A
|
||||
// gate result with no commit is a claim, not evidence.
|
||||
type GateResult struct {
|
||||
Command string `json:"command"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
SHA string `json:"sha"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
func (g GateResult) Passed() bool { return g.ExitCode == 0 && len(g.SHA) == 40 }
|
||||
|
||||
// ExternalRef identifies a pull request in the forge that holds it.
|
||||
type ExternalRef struct {
|
||||
Provider string `json:"provider"`
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// SubmissionRef is the durable record of what was submitted. Every field binds
|
||||
// the submission to one commit, so a later change cannot inherit it.
|
||||
type SubmissionRef struct {
|
||||
ResultSHA string `json:"result_sha"`
|
||||
RemoteRef string `json:"remote_ref"`
|
||||
PR ExternalRef `json:"pr"`
|
||||
GateRef string `json:"gate_ref,omitempty"`
|
||||
ReviewRef string `json:"review_ref,omitempty"`
|
||||
PacketRef string `json:"packet_ref,omitempty"`
|
||||
}
|
||||
|
||||
// SubmissionCheck is why a task may or may not be submitted. Reasons are
|
||||
// listed rather than summarised: "not eligible" alone sends an operator
|
||||
// reading code.
|
||||
type SubmissionCheck struct {
|
||||
Eligible bool `json:"eligible"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
}
|
||||
|
||||
// CheckSubmission is the whole eligibility rule, as one pure function of the
|
||||
// task, the current commit, and the gate run.
|
||||
//
|
||||
// The invariant that matters most: gate sha, review sha, and head sha must be
|
||||
// the same commit. Anything changing after review makes submission ineligible
|
||||
// immediately, with no state to clear and no flag to go stale.
|
||||
func CheckSubmission(task Task, headSHA string, gate GateResult) SubmissionCheck {
|
||||
var reasons []string
|
||||
add := func(format string, args ...any) { reasons = append(reasons, fmt.Sprintf(format, args...)) }
|
||||
|
||||
phase := task.WorkPhase
|
||||
if phase == "" {
|
||||
phase = WorkPhaseFrame
|
||||
}
|
||||
if phase != WorkPhaseReview {
|
||||
add("work phase is %s, not review", phase)
|
||||
}
|
||||
if task.State == StateBlocked || task.State == StateNeedsAttention {
|
||||
add("task is %s (%s)", task.State, task.BlockReason)
|
||||
}
|
||||
if task.State == StateCompleted || task.State == StateFailed {
|
||||
add("task is already %s", task.State)
|
||||
}
|
||||
if task.DecisionRequest != nil {
|
||||
add("a human decision is still outstanding")
|
||||
}
|
||||
if len(headSHA) != 40 {
|
||||
add("head commit is not anchored")
|
||||
}
|
||||
if !gate.Passed() {
|
||||
add("quality gate %q exited %d", gate.Command, gate.ExitCode)
|
||||
} else if gate.SHA != headSHA {
|
||||
add("quality gate ran against %s, not the current head", short(gate.SHA))
|
||||
}
|
||||
switch {
|
||||
case task.Review == nil:
|
||||
add("no independent review has been recorded")
|
||||
case task.Review.ResultSHA != headSHA:
|
||||
add("the review is for %s, not the current head", short(task.Review.ResultSHA))
|
||||
case task.Review.Blocking > 0:
|
||||
add("%d unresolved blocker or important review findings", task.Review.Blocking)
|
||||
}
|
||||
return SubmissionCheck{Eligible: len(reasons) == 0, Reasons: reasons}
|
||||
}
|
||||
|
||||
// RequirePhaseArtifacts reports the project-policy half of eligibility: a
|
||||
// project whose path includes research or plan must have sealed them.
|
||||
func (t Task) RequirePhaseArtifacts(path []WorkPhase) []string {
|
||||
var missing []string
|
||||
for _, phase := range path {
|
||||
switch phase {
|
||||
case WorkPhaseResearch:
|
||||
if t.ResearchRef == "" {
|
||||
missing = append(missing, "the project's path includes research but none was sealed")
|
||||
}
|
||||
case WorkPhasePlan:
|
||||
if t.PlanRef == "" {
|
||||
missing = append(missing, "the project's path includes plan but none was sealed")
|
||||
}
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// Submitted reports whether this task already has a submission for exactly
|
||||
// this commit, which is what makes a repeated submission idempotent.
|
||||
func (t Task) Submitted(headSHA string) bool {
|
||||
return t.Submission != nil && t.Submission.ResultSHA == headSHA
|
||||
}
|
||||
|
||||
func ValidateTaskSubmitted(p map[string]any) error {
|
||||
if v, ok := p["result_sha"].(string); !ok || len(v) != 40 {
|
||||
return fmt.Errorf("%w: result_sha invalid", ErrInvalid)
|
||||
}
|
||||
if v, ok := p["remote_ref"].(string); !ok || strings.TrimSpace(v) == "" {
|
||||
return fmt.Errorf("%w: remote_ref required", ErrInvalid)
|
||||
}
|
||||
pr, ok := p["pr"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: pr required", ErrInvalid)
|
||||
}
|
||||
for _, k := range []string{"provider", "id"} {
|
||||
if v, ok := pr[k].(string); !ok || strings.TrimSpace(v) == "" {
|
||||
return fmt.Errorf("%w: pr.%s required", ErrInvalid, k)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"gate_ref", "review_ref", "packet_ref"} {
|
||||
if v, ok := p[k]; ok {
|
||||
if s, _ := v.(string); s != "" {
|
||||
if err := requiredHash(map[string]any{k: s}, k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func short(sha string) string {
|
||||
if len(sha) > 12 {
|
||||
return sha[:12]
|
||||
}
|
||||
if sha == "" {
|
||||
return "an unknown commit"
|
||||
}
|
||||
return sha
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package domain
|
||||
|
||||
import "fmt"
|
||||
|
||||
// WorkPhase is the cognitive phase of a task. It is orthogonal to TaskState:
|
||||
// a task can be leased in any phase, and a phase change is not a lifecycle
|
||||
// transition. Keeping them separate is what stops a rotation from looking
|
||||
// like progress and a failed experiment from looking like a failed task.
|
||||
type WorkPhase string
|
||||
|
||||
const (
|
||||
WorkPhaseFrame WorkPhase = "frame"
|
||||
WorkPhaseResearch WorkPhase = "research"
|
||||
WorkPhasePlan WorkPhase = "plan"
|
||||
WorkPhaseImplement WorkPhase = "implement"
|
||||
WorkPhaseReview WorkPhase = "review"
|
||||
)
|
||||
|
||||
// EventWorkPhaseChanged is emitted by Orchestra, never by an agent. An agent
|
||||
// asks for a phase change through the approval surface and Orchestra decides.
|
||||
const EventWorkPhaseChanged = "WorkPhaseChanged"
|
||||
|
||||
func (p WorkPhase) Valid() bool {
|
||||
switch p {
|
||||
case WorkPhaseFrame, WorkPhaseResearch, WorkPhasePlan, WorkPhaseImplement, WorkPhaseReview:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// legalPhaseTransitions is the full set of moves Orchestra may make. A
|
||||
// project's declared path is a subset of this, checked where the registry is
|
||||
// visible. Skipping ahead is allowed, going backwards is not, except for
|
||||
// review sending work back to implement.
|
||||
var legalPhaseTransitions = map[WorkPhase][]WorkPhase{
|
||||
WorkPhaseFrame: {WorkPhaseResearch, WorkPhaseImplement},
|
||||
WorkPhaseResearch: {WorkPhasePlan, WorkPhaseImplement},
|
||||
WorkPhasePlan: {WorkPhaseImplement},
|
||||
WorkPhaseImplement: {WorkPhaseReview},
|
||||
WorkPhaseReview: {WorkPhaseImplement},
|
||||
}
|
||||
|
||||
// CanTransitionPhase reports whether Orchestra may move from one phase to
|
||||
// another. An empty from is treated as frame, the phase every task starts in.
|
||||
func CanTransitionPhase(from, to WorkPhase) bool {
|
||||
if from == "" {
|
||||
from = WorkPhaseFrame
|
||||
}
|
||||
if !from.Valid() || !to.Valid() {
|
||||
return false
|
||||
}
|
||||
for _, allowed := range legalPhaseTransitions[from] {
|
||||
if allowed == to {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateWorkPhaseChanged checks the payload shape. Whether the transition
|
||||
// is legal from the task's current phase is checked at the append boundary,
|
||||
// where the current phase is visible.
|
||||
func ValidateWorkPhaseChanged(p map[string]any) error {
|
||||
phase, _ := p["phase"].(string)
|
||||
if !WorkPhase(phase).Valid() {
|
||||
return fmt.Errorf("%w: phase invalid", ErrInvalid)
|
||||
}
|
||||
if v, ok := p["from"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok || !WorkPhase(s).Valid() {
|
||||
return fmt.Errorf("%w: from invalid", ErrInvalid)
|
||||
}
|
||||
}
|
||||
// A sealed artifact is what makes the next phase's context cheap. It is
|
||||
// required when leaving research or plan, because those phases exist to
|
||||
// produce one.
|
||||
if v, ok := p["result_sha"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok || len(s) != 40 {
|
||||
return fmt.Errorf("%w: result_sha invalid", ErrInvalid)
|
||||
}
|
||||
}
|
||||
if v, ok := p["artifact_ref"]; ok {
|
||||
s, _ := v.(string)
|
||||
if err := requiredHash(map[string]any{"artifact_ref": s}, "artifact_ref"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user