6cb2f932d8
F64. A plan mismatch that asks for a human decision blocks the task, and nothing came back. Two independent gaps, either one enough to strand it: the reconciler ran only before a lease and at a turn boundary, so a blocked task's reply was never even read, and ResumeAnsweredBlockers listed two block reasons, not this one. PlanMismatchAnswered had no caller anywhere. Blocked tasks awaiting a reply are now reconciled on their own loop, the same reasoning the submitted-work loop above it already uses: a task that cannot be leased cannot be reconciled behind a pre-lease hook. One predicate, BlockReasonAwaitsReply, now names the set for both loops so they cannot drift apart again. The existing test asserted the predicate and never the resume, which is how this survived. It asserts the resume now, and fails without the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
165 lines
6.1 KiB
Go
165 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
|
|
}
|
|
// operator_required is deliberately not resumed by a reply. An
|
|
// operator decides when a task that spent its budget continues.
|
|
if !domain.BlockReasonAwaitsReply(t.BlockReason) {
|
|
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
|
|
}
|