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>
185 lines
6.7 KiB
Go
185 lines
6.7 KiB
Go
package operations
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"orchestra/internal/domain"
|
|
"orchestra/internal/registry"
|
|
)
|
|
|
|
func request(q string) domain.DecisionRequest {
|
|
return domain.DecisionRequest{
|
|
Question: q,
|
|
Why: "both behaviours are valid and two external callers depend on the answer",
|
|
Options: []domain.DecisionOption{
|
|
{ID: "preserve", Description: "keep the old contract", Tradeoff: "larger implementation"},
|
|
{ID: "break", Description: "change the contract", Tradeoff: "consumers must migrate"},
|
|
},
|
|
Evidence: []string{"internal/cache/cache.go:44 documents neither behaviour"},
|
|
}
|
|
}
|
|
|
|
// Real ambiguity blocks with one bounded question, the human answers through
|
|
// the ordinary decision path, and the task resumes.
|
|
func TestDecisionRequestBlocksAndResumes(t *testing.T) {
|
|
s, id := phaseStore(t)
|
|
lease(t, s, id)
|
|
project := registry.Project{ID: "p"}
|
|
|
|
if _, err := RequestHumanDecision(s, project, id, request("should the old cache contract stay compatible?")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
blocked, _ := s.Task(id)
|
|
if blocked.State != domain.StateBlocked || blocked.BlockReason != domain.BlockReasonHumanDecision {
|
|
t.Fatalf("task = %+v", blocked)
|
|
}
|
|
if blocked.DecisionRequest == nil || blocked.DecisionRequest.Question == "" {
|
|
t.Fatal("the question is not projected onto the task")
|
|
}
|
|
for _, want := range []string{"Human decision required", "stay compatible", "preserve", "consumers must migrate"} {
|
|
if !strings.Contains(blocked.Blocker, want) {
|
|
t.Fatalf("blocker missing %q:\n%s", want, blocked.Blocker)
|
|
}
|
|
}
|
|
|
|
// Asking again while waiting is refused.
|
|
if _, err := RequestHumanDecision(s, project, id, request("another question?")); !errors.Is(err, domain.ErrConflict) {
|
|
t.Fatalf("want ErrConflict, got %v", err)
|
|
}
|
|
|
|
// Nothing resumes before the human replies.
|
|
if events, err := ResumeAnsweredBlockers(s); err != nil || len(events) != 0 {
|
|
t.Fatalf("resumed early: %v %v", events, err)
|
|
}
|
|
|
|
humanReply(t, s, id, "d1", "break compatibility and update the two callers")
|
|
events, err := ResumeAnsweredBlockers(s)
|
|
if err != nil || len(events) != 1 {
|
|
t.Fatalf("events=%v err=%v", events, err)
|
|
}
|
|
resumed, _ := s.Task(id)
|
|
if resumed.State != domain.StateQueued {
|
|
t.Fatalf("state = %s", resumed.State)
|
|
}
|
|
// The question is gone from the projection. The log still has it, so a
|
|
// resolved question never reappears in a later context.
|
|
if resumed.DecisionRequest != nil {
|
|
t.Fatalf("resolved question still on the task: %+v", resumed.DecisionRequest)
|
|
}
|
|
// And a second sweep is idempotent.
|
|
if events, err := ResumeAnsweredBlockers(s); err != nil || len(events) != 0 {
|
|
t.Fatalf("resumed twice: %v %v", events, err)
|
|
}
|
|
}
|
|
|
|
// A rotation between the question and the answer must not re-ask it.
|
|
func TestResolvedQuestionIsNotRepeatedAfterRotation(t *testing.T) {
|
|
s, id := phaseStore(t)
|
|
lease(t, s, id)
|
|
project := registry.Project{ID: "p"}
|
|
if _, err := RequestHumanDecision(s, project, id, request("preserve compatibility?")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
humanReply(t, s, id, "d1", "break it")
|
|
if _, err := ResumeAnsweredBlockers(s); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A later session sees no pending question, and the budget records that
|
|
// one was spent.
|
|
got, _ := s.Task(id)
|
|
if got.DecisionRequest != nil {
|
|
t.Fatal("question repeated after resume")
|
|
}
|
|
if n := countDecisionRequests(s, id); n != 1 {
|
|
t.Fatalf("requests counted = %d", n)
|
|
}
|
|
}
|
|
|
|
// The budget stops a task turning into an interview.
|
|
func TestDecisionBudgetBecomesOperatorRequired(t *testing.T) {
|
|
s, id := phaseStore(t)
|
|
project := registry.Project{ID: "p"}
|
|
project.HumanDecisions.MaxRequestsPerTask = 2
|
|
|
|
for i, q := range []string{"first?", "second?"} {
|
|
// An answered question returns the task to the queue, so the next
|
|
// session leases it again before it can ask.
|
|
lease(t, s, id)
|
|
if _, err := RequestHumanDecision(s, project, id, request(q)); err != nil {
|
|
t.Fatalf("request %d: %v", i, err)
|
|
}
|
|
humanReply(t, s, id, "d"+q, "answered")
|
|
if _, err := ResumeAnsweredBlockers(s); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
lease(t, s, id)
|
|
_, err := RequestHumanDecision(s, project, id, request("third?"))
|
|
if !errors.Is(err, ErrDecisionBudgetSpent) {
|
|
t.Fatalf("want ErrDecisionBudgetSpent, got %v", err)
|
|
}
|
|
got, _ := s.Task(id)
|
|
if got.BlockReason != domain.BlockReasonOperatorRequired {
|
|
t.Fatalf("block reason = %q", got.BlockReason)
|
|
}
|
|
// A reply must not resume a task that spent its budget. An operator does.
|
|
humanReply(t, s, id, "d-late", "just keep going")
|
|
if events, err := ResumeAnsweredBlockers(s); err != nil || len(events) != 0 {
|
|
t.Fatalf("operator_required resumed on a reply: %v %v", events, err)
|
|
}
|
|
}
|
|
|
|
// Bounds are the whole defence against an interview arriving as one request.
|
|
func TestDecisionRequestBoundsRejectInterviews(t *testing.T) {
|
|
s, id := phaseStore(t)
|
|
project := registry.Project{ID: "p"}
|
|
cases := map[string]domain.DecisionRequest{
|
|
"no question": {Why: "w"},
|
|
"no why": {Question: "q"},
|
|
"multiline": {Question: "line one\nline two", Why: "w"},
|
|
"long": {Question: strings.Repeat("x", 501), Why: "w"},
|
|
"five options": {Question: "q", Why: "w", Options: []domain.DecisionOption{
|
|
{ID: "a", Description: "d"}, {ID: "b", Description: "d"}, {ID: "c", Description: "d"},
|
|
{ID: "d", Description: "d"}, {ID: "e", Description: "d"},
|
|
}},
|
|
"duplicate options": {Question: "q", Why: "w", Options: []domain.DecisionOption{
|
|
{ID: "a", Description: "d"}, {ID: "a", Description: "d"},
|
|
}},
|
|
"nine evidence lines": {Question: "q", Why: "w", Evidence: []string{"1", "2", "3", "4", "5", "6", "7", "8", "9"}},
|
|
}
|
|
for name, req := range cases {
|
|
if _, err := RequestHumanDecision(s, project, id, req); !errors.Is(err, domain.ErrInvalid) {
|
|
t.Fatalf("%s: want ErrInvalid, got %v", name, err)
|
|
}
|
|
}
|
|
if got, _ := s.Task(id); got.State == domain.StateBlocked {
|
|
t.Fatal("a rejected request must not block the task")
|
|
}
|
|
}
|
|
|
|
// An out-of-scope discovery is recorded and does not block anything, and never
|
|
// reaches agent context.
|
|
func TestDeferredFindingDoesNotBlock(t *testing.T) {
|
|
s, id := phaseStore(t)
|
|
before, _ := s.Task(id)
|
|
if _, err := RecordDeferredFinding(s, id, domain.DeferredFinding{
|
|
Summary: "identity clustering could be redesigned",
|
|
Why: "unrelated to speaker attribution and out of this task's scope",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, _ := s.Task(id)
|
|
if after.State != before.State || after.DecisionRequest != nil {
|
|
t.Fatalf("deferred finding changed task state: %+v", after)
|
|
}
|
|
found := DeferredFindings(s, id)
|
|
if len(found) != 1 || found[0].Summary != "identity clustering could be redesigned" {
|
|
t.Fatalf("findings = %+v", found)
|
|
}
|
|
if _, err := RecordDeferredFinding(s, id, domain.DeferredFinding{Summary: " "}); !errors.Is(err, domain.ErrInvalid) {
|
|
t.Fatal("an empty finding must be rejected")
|
|
}
|
|
}
|