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>
167 lines
6.1 KiB
Go
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
|
|
}
|