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>
170 lines
5.2 KiB
Go
170 lines
5.2 KiB
Go
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) }
|