Files
orchestra/internal/operations/human_decision.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

167 lines
6.1 KiB
Go

package operations
import (
"encoding/json"
"errors"
"fmt"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/registry"
"orchestra/internal/store"
)
// DefaultMaxDecisionRequests bounds how many times one task may stop for a
// human question. The bound is per task, not per round: rounds are
// conversation machinery, and one blocker with one question needs none.
const DefaultMaxDecisionRequests = 6
// ErrDecisionBudgetSpent reports that a task has asked its last question. The
// task stays blocked, but for an operator rather than for another answer, so
// an agent cannot turn a task into an interview.
var ErrDecisionBudgetSpent = errors.New("decision request budget spent: operator required")
// RequestHumanDecision records a bounded question and blocks the task on it.
//
// Admission is the agent's judgement, stated in the phase brief: ask only when
// the answer materially changes the implementation, the repository cannot
// answer it, and no useful safe work can continue without guessing. Orchestra
// owns what happens next, which is this function.
func RequestHumanDecision(s *store.Store, project registry.Project, taskID string, req domain.DecisionRequest) (domain.Event, error) {
if err := req.Validate(); err != nil {
return domain.Event{}, err
}
t, ok := s.Task(taskID)
if !ok {
return domain.Event{}, domain.ErrNotFound
}
if t.State != domain.StateLeased && t.State != domain.StateNeedsAttention {
// Only a session that currently owns the task may stop it for a
// question. Without this, an agent credential is a way to block any
// task in the queue, including one no agent is working on.
return domain.Event{}, fmt.Errorf("%w: task %s is not owned by a session (state %s)", domain.ErrConflict, taskID, t.State)
}
if t.State == domain.StateBlocked && t.BlockReason == domain.BlockReasonHumanDecision {
// Already waiting. Re-asking would spam the human and move the
// position the answered check depends on.
return domain.Event{}, fmt.Errorf("%w: task %s is already waiting on a decision", domain.ErrConflict, taskID)
}
spent := countDecisionRequests(s, taskID)
if spent >= project.MaxDecisionRequests() {
e, err := blockTask(s, t, domain.BlockReasonOperatorRequired,
fmt.Sprintf("This task has asked %d questions, its budget. An operator should look at it rather than answer another.\n\nLast question: %s", spent, req.Question), nil)
if err != nil {
return domain.Event{}, err
}
return e, fmt.Errorf("%w (task %s, %d requests)", ErrDecisionBudgetSpent, taskID, spent)
}
return blockTask(s, t, domain.BlockReasonHumanDecision, req.Render(), &req)
}
func blockTask(s *store.Store, t domain.Task, reason domain.BlockReason, blocker string, req *domain.DecisionRequest) (domain.Event, error) {
payload := map[string]any{
"blocker": blocker, "block_reason": string(reason),
"lifecycle_phase": "awaiting_human",
}
if t.Lease != nil {
// Store.Append fences every lifecycle event on a leased task against
// the current owner and epoch. A question from a session that no
// longer owns the task is a conflict, not a block.
payload["harness_id"] = t.Lease.HarnessID
payload["lease_epoch"] = t.Lease.Epoch
}
if req != nil {
payload["decision_request"] = req
}
b, err := json.Marshal(payload)
if err != nil {
return domain.Event{}, err
}
e := domain.Event{ID: domain.NewID(), Type: "TaskBlocked", TaskID: t.ID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)}
return e, s.Append(e)
}
func countDecisionRequests(s *store.Store, taskID string) int {
n := 0
for _, e := range s.Events(0) {
if e.TaskID != taskID || e.Type != "TaskBlocked" {
continue
}
var p struct {
BlockReason string `json:"block_reason"`
}
if json.Unmarshal(e.Payload, &p) == nil && p.BlockReason == string(domain.BlockReasonHumanDecision) {
n++
}
}
return n
}
// ResumeAnsweredBlockers returns every task whose human blocker has been
// answered to the queue. Run it wherever pending assignment runs: the router
// cannot see a blocked task, so something has to unblock it, and that
// something must be Orchestra rather than the agent that asked.
func ResumeAnsweredBlockers(s *store.Store) ([]domain.Event, error) {
var out []domain.Event
for _, t := range s.Tasks() {
if t.State != domain.StateBlocked {
continue
}
switch t.BlockReason {
case domain.BlockReasonHumanDecision, domain.BlockReasonTrajectoryGate:
default:
// operator_required is deliberately not resumed by a reply. An
// operator decides when a task that spent its budget continues.
continue
}
if !blockerAnswered(s, t.ID, t.BlockReason) {
continue
}
before := t.Version
updated, err := clearBlocker(s, t, t.BlockReason, "resumed")
if err != nil {
return out, err
}
if updated.Version != before {
out = append(out, domain.Event{ID: t.ID, Type: "TaskCorrected", TaskID: t.ID, Version: updated.Version})
}
}
return out, nil
}
// RecordDeferredFinding keeps a real but out-of-scope discovery without
// derailing the task. It is appended to the log and projected onto nothing,
// so it never enters agent context. Turning these into follow-up tasks is a
// separate, deliberate step.
func RecordDeferredFinding(s *store.Store, taskID string, f domain.DeferredFinding) (domain.Event, error) {
if err := f.Validate(); err != nil {
return domain.Event{}, err
}
t, ok := s.Task(taskID)
if !ok {
return domain.Event{}, domain.ErrNotFound
}
b, err := json.Marshal(map[string]any{"summary": f.Summary, "why": f.Why})
if err != nil {
return domain.Event{}, err
}
e := domain.Event{ID: domain.NewID(), Type: domain.EventDeferredFindingRecorded, TaskID: taskID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)}
return e, s.Append(e)
}
// DeferredFindings lists what a task chose not to do, for follow-up creation
// at completion time.
func DeferredFindings(s *store.Store, taskID string) []domain.DeferredFinding {
var out []domain.DeferredFinding
for _, e := range s.Events(0) {
if e.TaskID != taskID || e.Type != domain.EventDeferredFindingRecorded {
continue
}
var f domain.DeferredFinding
if json.Unmarshal(e.Payload, &f) == nil {
out = append(out, f)
}
}
return out
}