Files
orchestra/internal/domain/decision_request.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

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