de18f372d3
F65, found live on run 20. A plan mismatch asking for a human decision recorded its observation, then failed to block the task: Store.Append fences every lifecycle event on a leased task against the current owner and epoch, and this TaskBlocked carried neither. The task kept implementing while the contradiction sat durable in the log, and the agent was told its report was refused. The trajectory gate had the same omission. The human-decision path already did this correctly and explained why in a comment. That comment is now a helper all three call. The tests could not have caught it. planWith never leased its task, so every plan test ran in a state no agent can be in, which is exactly what the lease helper's own comment warns against. It leases now, and the mismatch block test fails without the fence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
174 lines
6.5 KiB
Go
174 lines
6.5 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)
|
|
}
|
|
|
|
// fenceToLease binds a lifecycle event to the lease that is producing it.
|
|
// Store.Append fences every lifecycle event on a leased task against the
|
|
// current owner and epoch, so an event that omits them is a conflict rather
|
|
// than a block. Every coordinator-side stop goes through here: two of them did
|
|
// not, and both failed silently against a live lease (F65). A task stops for a
|
|
// human only while some session is running, so the leased case is the only one
|
|
// that ever mattered.
|
|
func fenceToLease(payload map[string]any, t domain.Task) {
|
|
if t.Lease == nil {
|
|
return
|
|
}
|
|
payload["harness_id"] = t.Lease.HarnessID
|
|
payload["lease_epoch"] = t.Lease.Epoch
|
|
}
|
|
|
|
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",
|
|
}
|
|
fenceToLease(payload, t)
|
|
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
|
|
}
|