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 }